-- proto3 JSON mapping tests.
local t = require('luatest')
local ffi = require('ffi')
local pb = require('pb')
local json = require('json')
local hello = require('full.hello.hello_pb')
local function reparse(s) return json.decode(s) end
local g = t.group('json.scalars')
g.test_basic_round_trip = function()
local p = {name = 'Alice', age = 30}
local enc = pb.json.encode(hello.Person_descriptor, p)
local obj = reparse(enc)
t.assert_equals(obj.name, 'Alice')
t.assert_equals(obj.age, 30)
end
g.test_proto3_default_elision = function()
-- Defaults are elided from JSON output unless presence is meaningful.
local enc = pb.json.encode(hello.Person_descriptor, {age = 0, name = ''})
t.assert_equals(reparse(enc), setmetatable({}, getmetatable(reparse('{}'))))
end
g.test_camel_case_field_names = function()
local enc = pb.json.encode(hello.Person_descriptor, {user_id = ffi.cast('uint64_t', 1234567890123)})
t.assert_str_contains(enc, '"userId"', 'field emitted in camelCase')
end
g.test_int64_as_string = function()
local p = {user_id = ffi.cast('uint64_t', 12345678901234567890ULL)}
local obj = reparse(pb.json.encode(hello.Person_descriptor, p))
t.assert_equals(obj.userId, '12345678901234567890', 'uint64 stringified per spec')
end
g.test_bytes_base64 = function()
local p = {avatar = '\x00\x01\xff'}
local obj = reparse(pb.json.encode(hello.Person_descriptor, p))
t.assert_equals(obj.avatar, 'AAH/')
-- Round-trip
local p2 = pb.json.decode(hello.Person_descriptor, pb.json.encode(hello.Person_descriptor, p))
t.assert_equals(p2.avatar, p.avatar)
end
g.test_repeated_packed_scalar = function()
local p = {lucky_numbers = {1, 2, 3}}
local obj = reparse(pb.json.encode(hello.Person_descriptor, p))
t.assert_equals(obj.luckyNumbers, {1, 2, 3})
end
g.test_repeated_string = function()
local p = {emails = {'a@x', 'b@x'}}
local obj = reparse(pb.json.encode(hello.Person_descriptor, p))
t.assert_equals(obj.emails, {'a@x', 'b@x'})
end
g.test_enum_as_name = function()
local enc = pb.json.encode(hello.Person_descriptor, {status = hello.Status.ERROR})
t.assert_str_contains(enc, '"status":"ERROR"')
end
g.test_enum_string_input_accepted_on_decode = function()
local p = pb.json.decode(hello.Person_descriptor, '{"status":"OK"}')
t.assert_equals(p.status, hello.Status.OK)
end
g.test_snake_case_input_also_accepted = function()
local p = pb.json.decode(hello.Person_descriptor, '{"user_id":"99"}')
t.assert_equals(tonumber(p.user_id), 99)
end
g.test_nested_message_round_trip = function()
local p = {name = 'P', address = {street = 'X', zip = 1}}
local enc = pb.json.encode(hello.Person_descriptor, p)
local back = pb.json.decode(hello.Person_descriptor, enc)
t.assert_equals(back.name, 'P')
t.assert_equals(back.address.street, 'X')
t.assert_equals(back.address.zip, 1)
end
g.test_map_round_trip = function()
local p = {ages_by_nickname = {alice = 30, bob = 25}}
local back = pb.json.decode(hello.Person_descriptor,
pb.json.encode(hello.Person_descriptor, p))
t.assert_equals(back.ages_by_nickname.alice, 30)
t.assert_equals(back.ages_by_nickname.bob, 25)
end
-- ---------------------------------------------------------------------------
-- WKT
-- ---------------------------------------------------------------------------
local gwkt = t.group('json.wkt')
gwkt.test_timestamp_iso_8601 = function()
local datetime = require('datetime')
local dt = datetime.new({timestamp = 1700000000, nsec = 123456789})
local enc = pb.json.encode(hello.Event_descriptor, {created_at = dt})
local obj = reparse(enc)
-- Tarantool's datetime tostring is ISO 8601: "2023-11-14T22:13:20.123456789Z"
t.assert_str_matches(obj.createdAt, '^%d%d%d%d%-%d%d%-%d%dT.+Z$')
local back = pb.json.decode(hello.Event_descriptor, enc)
t.assert(datetime.is_datetime(back.created_at))
t.assert_equals(back.created_at.epoch, 1700000000)
t.assert_equals(back.created_at.nsec, 123456789)
end
gwkt.test_duration_string = function()
local enc = pb.json.encode(hello.Event_descriptor, {duration = {seconds = 5, nanos = 0}})
local obj = reparse(enc)
t.assert_equals(obj.duration, '5s')
local enc2 = pb.json.encode(hello.Event_descriptor, {duration = {seconds = 5, nanos = 1}})
t.assert_equals(reparse(enc2).duration, '5.000000001s')
local back = pb.json.decode(hello.Event_descriptor, enc2)
t.assert_equals(tonumber(back.duration.seconds), 5)
t.assert_equals(back.duration.nanos, 1)
end
gwkt.test_empty = function()
local enc = pb.json.encode(hello.Event_descriptor, {ack = {}})
local obj = reparse(enc)
t.assert_equals(type(obj.ack), 'table')
end
gwkt.test_wrappers_unwrap = function()
local enc = pb.json.encode(hello.Event_descriptor, {
retry_count = 5,
note = 'remember',
is_admin = true,
})
local obj = reparse(enc)
t.assert_equals(obj.retryCount, 5, 'Int32Value unwrapped on encode')
t.assert_equals(obj.note, 'remember', 'StringValue unwrapped')
t.assert_equals(obj.isAdmin, true, 'BoolValue unwrapped')
-- Zero-value wrappers preserve presence: round-trip through JSON.
local enc0 = pb.json.encode(hello.Event_descriptor, {retry_count = 0})
t.assert_equals(reparse(enc0).retryCount, 0)
local back = pb.json.decode(hello.Event_descriptor, enc)
t.assert_equals(back.retry_count, 5)
t.assert_equals(back.note, 'remember')
t.assert_equals(back.is_admin, true)
end
-- ---------------------------------------------------------------------------
-- Struct / Value / ListValue
-- ---------------------------------------------------------------------------
local gsv = t.group('json.struct_value')
gsv.test_struct_field_emits_object = function()
local enc = pb.json.encode(hello.Event_descriptor, {
payload = pb.wkt.struct({k = 'v', n = 42, on = true}),
})
local obj = reparse(enc)
t.assert_equals(type(obj.payload), 'table')
t.assert_equals(obj.payload.k, 'v')
t.assert_equals(obj.payload.n, 42)
t.assert_equals(obj.payload.on, true)
end
gsv.test_value_field_dispatches_on_lua_type = function()
local cases = {
{input = 'hello', expect = 'hello'},
{input = 42, expect = 42},
{input = true, expect = true},
{input = pb.NULL, expect = box.NULL},
}
for _, c in ipairs(cases) do
local enc = pb.json.encode(hello.Event_descriptor, {attribute = c.input})
local obj = reparse(enc)
t.assert_equals(obj.attribute, c.expect)
end
end
gsv.test_list_value_field_emits_array = function()
local enc = pb.json.encode(hello.Event_descriptor, {
tags = pb.wkt.list({'alpha', 7, false, pb.NULL}),
})
local obj = reparse(enc)
t.assert_equals(#obj.tags, 4)
t.assert_equals(obj.tags[1], 'alpha')
t.assert_equals(obj.tags[2], 7)
t.assert_equals(obj.tags[3], false)
t.assert_equals(obj.tags[4], box.NULL)
end
gsv.test_struct_value_json_round_trip = function()
local e = {
payload = pb.wkt.struct({nested = pb.wkt.struct({k = 1})}),
attribute = pb.wkt.list({'a', 'b'}),
tags = pb.wkt.list({pb.NULL, true, 'x'}),
}
local enc = pb.json.encode(hello.Event_descriptor, e)
local back = pb.json.decode(hello.Event_descriptor, enc)
t.assert_equals(back.payload.nested.k, 1)
t.assert_equals(back.attribute[1], 'a')
t.assert_equals(back.attribute[2], 'b')
t.assert_equals(back.tags[1], pb.NULL)
t.assert_equals(back.tags[2], true)
t.assert_equals(back.tags[3], 'x')
end
gsv.test_decode_json_null_is_pb_null_in_value = function()
-- Top-level Value: decode a literal JSON null.
local v = pb.json.decode(pb.wkt.Value_descriptor, 'null')
t.assert_equals(v, pb.NULL)
end
-- ---------------------------------------------------------------------------
-- Oneof
-- ---------------------------------------------------------------------------
local gone = t.group('json.oneof')
gone.test_oneof_branch_emitted = function()
local enc = pb.json.encode(hello.Result_descriptor, {id = 7, text = 'hi'})
local obj = reparse(enc)
t.assert_equals(obj.id, 7)
t.assert_equals(obj.text, 'hi')
t.assert_equals(obj.code, nil)
t.assert_equals(obj.details, nil)
end
gone.test_oneof_default_value_branch = function()
-- text='' is the proto3 default for string but presence is meaningful
-- inside an oneof: it must survive JSON round-trip.
local enc = pb.json.encode(hello.Result_descriptor, {text = ''})
t.assert_str_contains(enc, '"text"')
local back = pb.json.decode(hello.Result_descriptor, enc)
t.assert_equals(back.text, '')
end