Files
gmconfig/test/gmconfig_schema_utils_tests.erl
uwiger 08287da7b7 Validator extensions, zomp vsn 0.2.0 (#5)
Co-authored-by: Ulf Wiger <ulf@wiger.net>
Reviewed-on: #5
2026-05-14 17:00:02 +09:00

405 lines
14 KiB
Erlang

-module(gmconfig_schema_utils_tests).
-export([tr/0]).
-include_lib("eunit/include/eunit.hrl").
-import(gmconfig_schema_utils, [ valid/2
, validate/3 ]).
%%-define(t(Expr), ?_test(?debugVal(Expr))).
-define(t(Expr), ?_test(Expr)).
tr() ->
dbg:tracer(),
dbg:tpl(?MODULE, x),
dbg:tpl(gmconfig_schema_utils,x),
dbg:p(all,[c]),
eunit:test(?MODULE).
simple_type_test_() ->
{"Simple type tests",
[
?t(t_integer())
, ?t(t_number())
, ?t(t_boolean())
, ?t(t_string())
, ?t(t_array())
, ?t(t_object())
, ?t(t_shortcut_schema())
]}.
update_test_() ->
{"Validated update tests",
[
?t(t_update_objects())
]}.
schema_spec_examples_test_() ->
{"Examples from JSON-Schema docs",
[
?t(t_ref_loop())
, ?t(t_recursive_def())
, ?t(t_nested_refs())
]}.
array() -> #{<<"type">> => <<"array">>}.
int() -> #{<<"type">> => <<"integer">>}.
str() -> #{<<"type">> => <<"string">>}.
obj(Ps) -> #{<<"type">> => <<"object">>,
<<"properties">> => Ps}.
t_update_objects() ->
S0 = obj(
#{<<"a">> => int(),
<<"b">> => obj(#{<<"b1">> => array(),
<<"b2">> =>
obj(#{<<"b21">> => str()})
})
}),
%%
%% Only objects are merged; other types are replaced.
A0 = #{<<"a">> => 1,
<<"b">> => #{<<"b1">> => [1,2],
<<"b2">> => #{}}},
B0 = #{<<"a">> => 2,
<<"b">> => #{<<"b1">> => [4,5],
<<"b2">> => #{<<"b21">> => <<"foo">>}}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}}} =
gmconfig_schema_utils:merge(A0, B0, S0),
%%
%% Modified update semantics - replace instead of merge
S0Ps = maps:get(<<"properties">>, S0),
S01Ps = S0Ps#{<<"c">> => #{<<"type">> => <<"object">>,
<<"updateSemantics">> => <<"replace">>,
<<"properties">> =>
#{<<"c1">> => #{<<"type">> => <<"integer">>},
<<"c2">> => #{<<"type">> => <<"integer">>}
}}},
S01 = S0#{<<"properties">> => S01Ps},
A01 = A0#{<<"c">> => #{<<"c1">> => 1}},
B01 = B0#{<<"c">> => #{<<"c2">> => 2}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c1">> := 1}} =
gmconfig_schema_utils:merge(A01, B01, S01),
A0r = #{<<"a">> => 1,
<<"b">> => #{<<"$updateSemantics">> => <<"replace">>,
<<"b1">> => [1,2],
<<"b2">> => #{<<"$updateSemantics">> => <<"merge">>}
}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := B2M}} =
gmconfig_schema_utils:merge(A0r, B0, S0),
%% B2M is the empty map (updateSemantics prop removed recursively)
?assertEqual(#{}, B2M),
A0r1 = #{<<"a">> => 1,
<<"b">> => #{<<"$updateSemantics">> => <<"merge">>,
<<"b1">> => [1,2],
<<"b2">> => #{<<"$updateSemantics">> => <<"replace">>}
}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := B2M1}} =
gmconfig_schema_utils:merge(A0r1, B0, S0),
%% B2M is the empty map (updateSemantics prop removed recursively)
?assertEqual(#{}, B2M1),
%%
%% With updateSemantics : suggest, the offered data is accepted if
%% the existing is missing or 'null'.
S02Ps = S0Ps#{<<"c">> => #{<<"type">> => <<"object">>,
<<"updateSemantics">> => <<"suggest">>,
<<"properties">> =>
#{<<"c1">> => #{<<"type">> => <<"integer">>},
<<"c2">> => #{<<"type">> => <<"integer">>}
}}},
S02 = S0#{<<"properties">> => S02Ps},
A02 = A0#{<<"c">> => #{<<"c1">> => 1}},
B02 = B0#{<<"c">> => #{<<"c2">> => 2}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c2">> := 2}} =
gmconfig_schema_utils:merge(A02, B02, S02),
B03 = B0#{<<"c">> => null},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c1">> := 1}} =
gmconfig_schema_utils:merge(A02, B0, S02),
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c1">> := 1}} =
gmconfig_schema_utils:merge(A02, B03, S02),
ok.
t_integer() ->
S0 = #{<<"type">> => <<"integer">>},
is_valid(17, S0),
all_fail([17.0, [], #{}, <<>>], S0, wrong_type),
S1 = S0#{<<"minimum">> => 1, <<"maximum">> => 4},
all_valid([1, 2, 4], S1),
all_fail([0, 5], S1, not_in_range),
S2 = S0#{<<"exclusiveMinimum">> => 1, <<"exclusiveMaximum">> => 3},
is_valid(2, S2),
all_fail([1, 3], S2, not_in_range),
S3 = S0#{<<"multipleOf">> => 2},
is_valid(4, S3),
fails(3, S3, not_a_multiple),
ok.
t_number() ->
S0 = #{<<"type">> => <<"number">>},
all_valid([17, 17.0], S0),
all_fail([#{}, [], <<>>, true], S0, wrong_type),
S1 = S0#{<<"minimum">> => 1, <<"maximum">> => 4.0},
all_valid([1, 1.0, 2, 2.3, 3.999, 4, 4.0], S1),
all_fail([0, 5], S1, not_in_range),
S2 = S0#{<<"exclusiveMinimum">> => 1, <<"exclusiveMaximum">> => 3},
is_valid(2, S2),
all_fail([1, 3], S2, not_in_range),
S3 = S0#{<<"multipleOf">> => 2},
is_valid(4, S3),
all_fail([3, 4.0], S3, not_a_multiple),
ok.
t_boolean() ->
S0 = #{<<"type">> => <<"boolean">>},
all_valid([true, false], S0),
%% truthy and falsy types not accepted
all_fail([1, 0, null, [], <<>>, #{}], S0, wrong_type),
ok.
t_string() ->
S0 = #{<<"type">> => <<"string">>},
all_valid([<<>>, <<"foo">>], S0),
fails("foo", S0, wrong_type),
ok.
t_array() ->
S0 = #{<<"type">> => <<"array">>},
all_valid([ [], [1,1], [1,2,3], [#{}] ], S0),
all_fail([1, false, #{}, <<"foo">>], S0, wrong_type),
S1 = S0#{<<"minItems">> => 2},
all_valid([[1,2], [1,2,3,4]], S1),
all_fail([[], [1]], S1, not_in_range),
S2 = S0#{<<"maxItems">> => 4},
all_valid([[], [1], [1,2], [1,2,3,4]], S2),
fails([1,2,3,4,5], S2, not_in_range),
S3 = maps:merge(S1, S2),
all_valid([ [1,2], [1,2,3], [1,2,3,4] ], S3),
all_fail([ [1], [1,2,3,4,5] ], S3, not_in_range),
S4 = S0#{<<"uniqueItems">> => true},
all_valid([ [], [1], [1,2] ], S4),
all_fail([ [1,1], [2,1,2] ], S4, not_unique),
S5 = S0#{<<"items">> => #{<<"type">> => <<"integer">>}},
all_valid([ [], [1], [1,2,3] ], S5),
fails([<<"foo">>], S5, #{e => wrong_type, p => [array,items,0,integer]}),
fails([1,2,false], S5, #{e => wrong_type, p => [array,items,2,integer]}),
S6 = S0#{<<"prefixItems">> => [#{<<"type">> => <<"string">>},
#{<<"type">> => <<"integer">>},
#{<<"type">> => <<"integer">>}]},
is_valid([<<"foo">>, 1], S6),
is_valid([<<"foo">>, 1, 2], S6),
is_valid([<<"foo">>, 1, 2, 3], S6),
fails([1,2,3], S6, #{e => wrong_type, p => [array,prefixItems,0,string]}),
S7 = S6#{<<"items">> => false},
fails([<<"foo">>, 1, 2, 3], S7, #{e => invalid,
p => [array,prefixItems,items,3]}),
S71 = S6#{<<"items">> => #{<<"type">> => <<"integer">>}},
is_valid([<<>>, 1,2,3,4,5,6], S71),
fails([<<>>, 1,2,3,4,5,[]], S71,
#{e => wrong_type,
p => [array,prefixItems,items,6,integer]}),
S8 = S0#{<<"contains">> => #{<<"type">> => <<"integer">>}},
is_valid([1,2,3], S8),
is_valid([true,true,1], S8),
fails([true], S8, contains),
fails([], S8, contains), % not sure where specifed, but makes sense
S81 = S8#{<<"minContains">> => 2},
is_valid([1,2,3], S81),
is_valid([true,1,2], S81),
fails([1], S81, #{e => not_in_range, p => [array, contains, min,
integer, minimum]}),
S82 = S8#{<<"maxContains">> => 4},
all_valid([ [1], [1,2,3], [true,1,2,3,4] ], S82),
S83 = maps:merge(S81, S82),
is_valid([1,2,3], S83),
fails([1], S83, #{e => not_in_range, p => [array, contains, min,
integer, minimum]}),
fails([1,2,3,4,5], S83, #{e => not_in_range, p => [array, contains, max,
integer, maximum]}),
is_valid([], S8#{<<"minContains">> => 0}),
ok.
t_object() ->
S0 = #{<<"type">> => <<"object">>},
all_valid([ #{}, #{<<"a">> => 1} ], S0),
all_fail([ true, 17, 17.0, [], <<"foo">> ], S0, #{e => wrong_type}),
S1 = S0#{<<"properties">> => #{<<"a">> => #{<<"type">> => <<"integer">>}}},
all_valid([ #{<<"a">> => 1}, #{}, #{<<"b">> => 1} ], S1),
fails(#{<<"a">> => true}, S1, #{e => wrong_type,
p => [object, <<"a">>, integer]}),
is_valid(#{<<"b">> => 1}, S1#{<<"additionalProperties">> => true}),
fails(#{<<"b">> => 1}, S1#{<<"additionalProperties">> => false},
#{e => invalid,
p => [object, <<"b">>],
a => [additionalProperties]}),
fails(#{<<"b">> => 1}, S1#{<<"required">> => [<<"a">>]},
#{e => required, p => [object,required], a => [[<<"a">>]]}),
all_valid([ #{<<"a">> => 1}, #{<<"a">> => true} ],
S0#{<<"properties">> =>
#{<<"a">> => #{<<"oneOf">> =>
[#{<<"type">> => <<"integer">>},
#{<<"type">> => <<"boolean">>}]}
}}),
S2 = S0#{<<"properties">> =>
#{<<"a">> => #{<<"type">> => <<"integer">>,
<<"allOf">> =>
[#{<<"minimum">> => 2},
#{<<"maximum">> => 5},
#{<<"multipleOf">> => 2}
]}}
},
is_valid(#{<<"a">> => 4}, S2),
fails(#{<<"a">> => 3}, S2, #{e => failing_schemas,
a => [[{2,'_'}]|'_'],
p => [object, <<"a">>, allOf]}),
ok.
t_shortcut_schema() ->
Vs = [<<"foo">>,
#{<<"a">> => 1},
17],
all_valid(Vs, #{}),
all_valid(Vs, true),
all_fail(Vs, false, invalid),
ok.
is_valid(V, S) ->
{V, V} = {valid(V, S), V}.
%% ?assertEqual(V, valid(V, S)).
fails(V, S, Reason) ->
fails(V, S, #{}, Reason).
fails(V, S, Opts, Reason) when is_atom(Reason) ->
fails(V, S, Opts, #{e => Reason});
fails(V, S, Opts, Expect) ->
try validate(V, S, Opts) of
_ ->
error({expected_exception, #{v => V,
s => S,
e => Expect}})
catch
error:R ->
match_expected(Expect, R)
end.
%% ?assertError({Reason, [], V}, valid(V, S)).
match_expected(E, R) ->
case maps:fold(
fun(K, V, Acc) ->
case maps:find(K, R) of
{ok, V} -> Acc;
{ok, Other} ->
case match_other(V, Other) of
true -> Acc;
false ->
Acc#{K => Other}
end;
error ->
Acc#{K => '$not_found'}
end
end, #{}, E) of
M when map_size(M) == 0 ->
ok;
Unexpected ->
error({expected, E, Unexpected})
end.
match_other(V, V) -> true;
match_other([H1|T1], [H2|T2]) ->
case match_other(H1, H2) of
true ->
match_other(T1, T2);
false ->
false
end;
match_other(T1, T2) when tuple_size(T1) =:= tuple_size(T2) ->
lists:all(fun({A,B}) -> match_other(A, B) end,
lists:zip(tuple_to_list(T1),
tuple_to_list(T2)));
match_other('_', _) ->
true;
match_other(_, _) ->
false.
all_valid(Vs, S) ->
[is_valid(V, S) || V <- Vs].
all_fail(Vs, S, Reason) ->
[fails(V, S, Reason) || V <- Vs].
read(F) ->
FullF = filename:join(
filename:dirname(code:which(?MODULE)), F),
?debugFmt("FullF = ~s~n", [FullF]),
{ok, Bin} = file:read_file(FullF),
dec(Bin).
dec(Str) ->
try json:decode(Str)
catch
error:E ->
error(#{type => json_decode_error,
error => E})
end.
t_ref_loop() ->
S = read("data/ref_loop_schema.json"),
try gmconfig_schema_utils:schema([<<"$defs">>,<<"alice">>], S) of
Unexpected ->
error({unexpected, Unexpected})
catch
error:nested_references ->
ok
end.
t_recursive_def() ->
S = read("data/recursion_schema.json"),
D = read("data/recursion_data.json"),
Df = read("data/recursion_data_faulty.json"),
is_valid(D, S),
fails(Df, S, #{e => wrong_type, p => [object, <<"children">>,
array, items, 0,
object, <<"children">>,
array, items, 0,
object,<<"children">>,
array, items, 0,
object, <<"name">>, string
]}),
ok.
t_nested_refs() ->
S = read("data/nested_refs_schema.json"),
F = fun(Str, #{<<"tags">> := Tags}) ->
true = lists:any(
fun(T) ->
nomatch =/= string:prefix(Str, T)
end, Tags)
end,
Opts = #{extensions => #{<<"x-serialization">> => F}},
Vs = #{<<"tx">> => #{<<"from">> => <<"ak_good">>}},
Vf = #{<<"tx">> => #{<<"from">> => <<"ac_bad">>}},
validate(Vs, S, Opts),
fails(Vf, S, Opts, #{e => failing_schemas}),
ok.