File Coverage

blib/lib/Test/JSON/Schema/Acceptance.pm
Criterion Covered Total %
statement 211 224 94.2
branch 80 98 81.6
condition 71 84 84.5
subroutine 37 40 92.5
pod 1 4 25.0
total 400 450 88.8


line stmt bran cond sub pod time code
1 17     17   4944288 use strict;
  17         170  
  17         622  
2 17     17   106 use warnings;
  17         47  
  17         1199  
3             package Test::JSON::Schema::Acceptance; # git description: v1.019-4-ge7332cb
4             # vim: set ts=8 sts=2 sw=2 tw=100 et :
5             # ABSTRACT: Acceptance testing for JSON-Schema based validators
6              
7             our $VERSION = '1.020';
8              
9 17     17   438 use 5.020;
  17         66  
10 17     17   9930 use Moo;
  17         131354  
  17         87  
11 17     17   35338 use strictures 2;
  17         29428  
  17         738  
12 17     17   5409 use stable 0.031 'postderef';
  17         19674  
  17         158  
13 17     17   3250 use experimental 'signatures';
  17         42  
  17         81  
14 17     17   1332 no if "$]" >= 5.031009, feature => 'indirect';
  17         37  
  17         169  
15 17     17   891 no if "$]" >= 5.033001, feature => 'multidimensional';
  17         42  
  17         100  
16 17     17   727 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  17         64  
  17         143  
17 17     17   606 use Test2::API ();
  17         45  
  17         479  
18 17     17   8438 use Test2::Todo;
  17         19822  
  17         473  
19 17     17   11055 use Test2::Tools::Compare ();
  17         2017923  
  17         684  
20 17     17   8918 use JSON::MaybeXS 1.004001;
  17         104419  
  17         1241  
21 17     17   5675 use File::ShareDir 'dist_dir';
  17         283820  
  17         1094  
22 17     17   8557 use Feature::Compat::Try;
  17         5464  
  17         85  
23 17     17   55258 use MooX::TypeTiny 0.002002;
  17         5926  
  17         101  
24 17     17   226135 use Types::Standard 1.016003 qw(Str InstanceOf ArrayRef HashRef Dict Any HasMethods Bool Optional Slurpy Enum);
  17         1990389  
  17         229  
25 17     17   77569 use Types::Common::Numeric 'PositiveOrZeroInt';
  17         434515  
  17         154  
26 17     17   24222 use Path::Tiny 0.069;
  17         138837  
  17         1339  
27 17     17   165 use List::Util 1.33 qw(any max sum0);
  17         322  
  17         1437  
28 17     17   9002 use Ref::Util qw(is_plain_arrayref is_plain_hashref is_ref);
  17         10027  
  17         1454  
29 17     17   8853 use namespace::clean;
  17         276019  
  17         144  
30              
31             # specification version => metaschema URI
32 17         89970 use constant METASCHEMA => {
33             'draft-next' => 'https://json-schema.org/draft/next/schema',
34             'draft2020-12' => 'https://json-schema.org/draft/2020-12/schema',
35             'draft2019-09' => 'https://json-schema.org/draft/2019-09/schema',
36             'draft7' => 'http://json-schema.org/draft-07/schema#',
37             'draft6' => 'http://json-schema.org/draft-06/schema#',
38             'draft4' => 'http://json-schema.org/draft-04/schema#',
39             'draft3' => 'http://json-schema.org/draft-03/schema#',
40 17     17   9357 };
  17         66  
41              
42             has specification => (
43             is => 'ro',
44             isa => Enum[keys METASCHEMA->%*],
45             lazy => 1,
46             default => 'draft2020-12',
47             predicate => '_has_specification',
48             );
49              
50             has supported_specifications => (
51             is => 'ro',
52             isa => ArrayRef[Enum[keys METASCHEMA->%*]],
53             lazy => 1,
54             default => sub { [ shift->specification ] },
55             );
56              
57             has test_dir => (
58             is => 'ro',
59             isa => InstanceOf['Path::Tiny'],
60             coerce => sub { path($_[0])->absolute('.') },
61             lazy => 1,
62             builder => '_build_test_dir',
63             predicate => '_has_test_dir',
64             );
65 55     55   723 sub _build_test_dir { path(dist_dir('Test-JSON-Schema-Acceptance'), 'tests', $_[0]->specification) };
66              
67             has additional_resources => (
68             is => 'ro',
69             isa => InstanceOf['Path::Tiny'],
70             coerce => sub { path($_[0])->absolute('.') },
71             lazy => 1,
72             default => sub { $_[0]->test_dir->parent->parent->child('remotes') },
73             );
74              
75             has verbose => (
76             is => 'ro',
77             isa => Bool,
78             default => 0,
79             );
80              
81             has include_optional => (
82             is => 'ro',
83             isa => Bool,
84             default => 0,
85             );
86              
87             has skip_dir => (
88             is => 'ro',
89             isa => ArrayRef[Str],
90             coerce => sub { ref($_[0]) ? $_[0] : [ $_[0] ] },
91             lazy => 1,
92             default => sub { [] },
93             );
94              
95             has test_schemas => (
96             is => 'ro',
97             isa => Bool,
98             );
99              
100             has results => (
101             is => 'rwp',
102             init_arg => undef,
103             isa => ArrayRef[Dict[
104             file => InstanceOf['Path::Tiny'],
105             map +($_ => PositiveOrZeroInt), qw(pass todo_fail fail),
106             ]],
107             );
108              
109             has results_text => (
110             is => 'ro',
111             init_arg => undef,
112             isa => Str,
113             lazy => 1,
114             builder => '_build_results_text',
115             );
116              
117             around BUILDARGS => sub ($orig, $class, @args) {
118             my %args = @args % 2 ? ( specification => 'draft'.$args[0] ) : @args;
119             $args{specification} = 'draft2020-12' if ($args{specification} // '') eq 'latest';
120             $class->$orig(\%args);
121             };
122              
123 54     54 0 11828 sub BUILD ($self, @) {
  54         145  
  54         100  
124 54 100       1050 -d $self->test_dir or die 'test_dir does not exist: '.$self->test_dir;
125             }
126              
127             sub acceptance {
128 58     58 1 300907 my $self = shift;
129 58 100       313 my $options = +{ ref $_[0] eq 'CODE' ? (validate_json_string => @_) : @_ };
130              
131             die 'require one or the other of "validate_data", "validate_json_string"'
132 58 50 66     296 if not $options->{validate_data} and not $options->{validate_json_string};
133              
134             die 'cannot provide both "validate_data" and "validate_json_string"'
135 58 50 66     329 if $options->{validate_data} and $options->{validate_json_string};
136              
137 58 100       233 warn "'skip_tests' option is deprecated" if $options->{skip_tests};
138              
139 58         234 my $ctx = Test2::API::context;
140              
141 58 100 66     5630 if ($options->{add_resource} and -d $self->additional_resources) {
142             # this is essentially what `bin/jsonschema_suite remote` does: resolves the filename against the
143             # base uri to determine the absolute schema location of each resource.
144 1         86 my $base = 'http://localhost:1234';
145 1         27 $ctx->note('adding resources from '.$self->additional_resources.' with the base URI "'.$base.'"...');
146 9         18 $self->additional_resources->visit(
147 9     9   14 sub ($path, @) {
  9         1814  
148 9 100 66     26 return if not $path->is_file or $path !~ /\.json$/;
149              
150             # skip resource files that are marked as being for an unsupported draft
151 5         263 my $relative_path = $path->relative($self->additional_resources);
152 5         1239 my ($topdir) = split qr{/}, $relative_path, 2;
153 5 100 100     120 return if $topdir =~ /^draft/ and not grep $topdir eq $_, $self->supported_specifications->@*;
154              
155 4         43 my $data = $self->json_deserialize($path->slurp_raw);
156 4         841 my $file = $path->relative($self->additional_resources);
157 4         781 my $uri = $base.'/'.$file;
158 4         30 $options->{add_resource}->($uri => $data);
159             },
160 1         1014 { recurse => 1 },
161             );
162             }
163              
164 58 100       1580 $ctx->note('running tests in '.$self->test_dir.' against '
165             .($self->_has_specification ? $self->specification : 'unknown version').'...');
166 58         19804 my $tests = $self->_test_data;
167              
168             # [ { file => .., pass => .., fail => .. }, ... ]
169 58         31802 my @results;
170              
171 58         185 foreach my $one_file (@$tests) {
172 223         486 my %results;
173             next if $options->{tests} and $options->{tests}{file}
174             and not grep $_ eq $one_file->{file},
175             (ref $options->{tests}{file} eq 'ARRAY'
176 223 100 100     867 ? $options->{tests}{file}->@* : $options->{tests}{file});
    100 100        
177              
178 212         843 $ctx->note('');
179              
180 212         48906 foreach my $test_group ($one_file->{json}->@*) {
181             next if $options->{tests} and $options->{tests}{group_description}
182             and not grep $_ eq $test_group->{description},
183             (ref $options->{tests}{group_description} eq 'ARRAY'
184 938 100 100     4157 ? $options->{tests}{group_description}->@* : $options->{tests}{group_description});
    100 100        
185              
186 914         1460 my $todo;
187             $todo = Test2::Todo->new(reason => 'Test marked TODO via "todo_tests"')
188             if $options->{todo_tests}
189             and any {
190 74     74   166 my $o = $_;
191             (not $o->{file} or grep $_ eq $one_file->{file}, (ref $o->{file} eq 'ARRAY' ? $o->{file}->@* : $o->{file}))
192             and
193             (not $o->{group_description} or grep $_ eq $test_group->{description}, (ref $o->{group_description} eq 'ARRAY' ? $o->{group_description}->@* : $o->{group_description}))
194             and not $o->{test_description}
195 74 100 100     622 }
      100        
      100        
196 914 100 100     2892 $options->{todo_tests}->@*;
197              
198 914         3104 my $schema_fails;
199 914 50       3087 if ($self->test_schemas) {
200 0 0       0 die 'specification_version unknown: cannot evaluate schema against metaschema'
201             if not $self->_has_specification;
202              
203             my $metaschema_uri = is_plain_hashref($test_group->{schema}) && $test_group->{schema}{'$schema'}
204             ? $test_group->{schema}{'$schema'}
205 0 0 0     0 : METASCHEMA->{$self->specification};
206 0         0 my $metaschema_schema = { '$ref' => $metaschema_uri };
207             my $result = $options->{validate_data}
208             ? $options->{validate_data}->($metaschema_schema, $test_group->{schema})
209 0 0       0 : $options->{validate_json_string}->($metaschema_schema, $self->json_serialize($test_group->{schema}));
210 0 0       0 if (not $result) {
211 0         0 $ctx->fail('schema for '.$one_file->{file}.': "'.$test_group->{description}.'" fails to validate against '.$metaschema_uri.':');
212 0         0 $ctx->note($self->json_prettyprint($result));
213 0         0 $schema_fails = 1;
214             }
215             }
216              
217 914         4271 foreach my $test ($test_group->{tests}->@*) {
218             next if $options->{tests} and $options->{tests}{test_description}
219             and not grep $_ eq $test->{description},
220             (ref $options->{tests}{test_description} eq 'ARRAY'
221 3076 100 100     10860 ? $options->{tests}{test_description}->@* : $options->{tests}{test_description});
    100 100        
222              
223 3045         4489 my $todo;
224             $todo = Test2::Todo->new(reason => 'Test marked TODO via deprecated "skip_tests"')
225             if ref $options->{skip_tests} eq 'ARRAY'
226             and grep +(($test_group->{description}.' - '.$test->{description}) =~ /$_/),
227 3045 100 100     9958 $options->{skip_tests}->@*;
228              
229             $todo = Test2::Todo->new(reason => 'Test marked TODO via "todo_tests"')
230             if $options->{todo_tests}
231             and any {
232 222     222   516 my $o = $_;
233             (not $o->{file} or grep $_ eq $one_file->{file}, (ref $o->{file} eq 'ARRAY' ? $o->{file}->@* : $o->{file}))
234             and
235             (not $o->{group_description} or grep $_ eq $test_group->{description}, (ref $o->{group_description} eq 'ARRAY' ? $o->{group_description}->@* : $o->{group_description}))
236             and
237 222 100 100     1829 (not $o->{test_description} or grep $_ eq $test->{description}, (ref $o->{test_description} eq 'ARRAY' ? $o->{test_description}->@* : $o->{test_description}))
    100 100        
      100        
      100        
238             }
239 3045 100 100     10970 $options->{todo_tests}->@*;
240              
241 3045         12109 my $result = $self->_run_test($one_file, $test_group, $test, $options);
242 3045 50       8553 $result = 0 if $schema_fails;
243              
244 3045 100       12953 ++$results{ $result ? 'pass' : $todo ? 'todo_fail' : 'fail' };
    100          
245             }
246             }
247              
248 212         2385 push @results, { file => $one_file->{file}, pass => 0, 'todo_fail' => 0, fail => 0, %results };
249             }
250              
251 58         1748 $self->_set_results(\@results);
252              
253 58 50       6510 my $diag = $self->verbose ? 'diag' : 'note';
254 58         1192 $ctx->$diag("\n\n".$self->results_text);
255 58         17770 $ctx->$diag('');
256              
257 58 100 100     14515 if ($self->test_dir !~ m{\boptional\b}
      66        
258             and grep +($_->{file} !~ m{^optional/} && $_->{todo_fail} + $_->{fail}), @results) {
259             # non-optional test failures will always be visible, even when not in verbose mode.
260 45         2179 $ctx->diag('WARNING: some non-optional tests are failing! This implementation is not fully compliant with the specification!');
261 45         9990 $ctx->diag('');
262             }
263             else {
264 13         496 $ctx->$diag('Congratulations, all non-optional tests are passing!');
265 13         3880 $ctx->$diag('');
266             }
267              
268 58         13212 $ctx->release;
269             }
270              
271 3045     3045   5189 sub _run_test ($self, $one_file, $test_group, $test, $options) {
  3045         4663  
  3045         4486  
  3045         4387  
  3045         4783  
  3045         4405  
  3045         4246  
272 3045         17182 my $test_name = $one_file->{file}.': "'.$test_group->{description}.'" - "'.$test->{description}.'"';
273              
274 3045         29078 my $pass; # ignores TODO status
275              
276             Test2::API::run_subtest($test_name,
277             sub {
278 3045     3045   975050 my ($result, $schema_before, $data_before, $schema_after, $data_after);
279             try {
280             ($schema_before, $data_before) = map $self->json_serialize($_),
281             $test_group->{schema}, $test->{data};
282              
283             $result = $options->{validate_data}
284             ? $options->{validate_data}->($test_group->{schema}, $test->{data})
285             : $options->{validate_json_string}->($test_group->{schema}, $self->json_serialize($test->{data}));
286              
287             ($schema_after, $data_after) = map $self->json_serialize($_),
288             $test_group->{schema}, $test->{data};
289              
290             my $ctx = Test2::API::context;
291              
292             # skip the ugly matrix comparison
293             my $expected = $test->{valid} ? 'true' : 'false';
294             if ($result xor $test->{valid}) {
295             my $got = $result ? 'true' : 'false';
296             $ctx->fail('evaluation result is incorrect', 'expected '.$expected.'; got '.$got);
297             $ctx->${ $self->verbose ? \'diag' : \'note' }($self->json_prettyprint($result));
298             $pass = 0;
299             }
300             else {
301             $ctx->ok(1, 'test passes: data is valid: '.$expected);
302             $pass = 1;
303             }
304              
305             my @mutated_data_paths = $self->_mutation_check($test->{data});
306             my @mutated_schema_paths = $self->_mutation_check($test_group->{schema});
307              
308             # string check path check behaviour
309             # 0 0 ::is(), and note. $pass = 0
310             # 0 1 ::is(). $pass = 0
311             # 1 0 ->fail and note. $pass = 0
312             # 1 1 no test. $pass does not change.
313              
314             if ($data_before ne $data_after) {
315             Test2::Tools::Compare::is($data_after, $data_before, 'evaluator did not mutate data');
316             $pass = 0;
317             }
318             elsif (@mutated_data_paths) {
319             $ctx->fail('evaluator did not mutate data');
320             $pass = 0
321             }
322              
323             $ctx->note('mutated data at location'.(@mutated_data_paths > 1 ? 's' : '').': '.join(', ', @mutated_data_paths)) if @mutated_data_paths;
324              
325             if ($schema_before ne $schema_after) {
326             Test2::Tools::Compare::is($schema_after, $schema_before, 'evaluator did not mutate schema');
327             $pass = 0;
328             }
329             elsif (@mutated_schema_paths) {
330             $ctx->fail('evaluator did not mutate schema');
331             $pass = 0;
332             }
333              
334             $ctx->note('mutated schema at location'.(@mutated_schema_paths > 1 ? 's' : '').': '.join(', ', @mutated_schema_paths)) if @mutated_schema_paths;
335              
336             $ctx->release;
337             }
338 3045         7599 catch ($e) {
339             chomp(my $exception = $e);
340             my $ctx = Test2::API::context;
341             $ctx->fail('died: '.$exception);
342             $ctx->release;
343             };
344             },
345 3045         26067 { buffered => 1, inherit_trace => 1 },
346             );
347              
348 3045         2853302 return $pass;
349             }
350              
351 6086     6086   9729 sub _mutation_check ($self, $data) {
  6086         9066  
  6086         8935  
  6086         8406  
352 6086         8728 my @error_paths;
353              
354             # [ path => data ]
355 6086         14320 my @nodes = ([ '', $data ]);
356 6086         14968 while (my $node = shift @nodes) {
357 19728 100       38871 if (not defined $node->[1]) {
358 291         798 next;
359             }
360 19437 100       45831 if (is_plain_arrayref($node->[1])) {
    100          
    100          
361 2154         10916 push @nodes, map [ $node->[0].'/'.$_, $node->[1][$_] ], 0 .. $node->[1]->$#*;
362 2154 50       8452 push @error_paths, $node->[0] if tied($node->[1]->@*);
363             }
364             elsif (is_plain_hashref($node->[1])) {
365 8121         41202 push @nodes, map [ $node->[0].'/'.(s/~/~0/gr =~ s!/!~1!gr), $node->[1]{$_} ], keys $node->[1]->%*;
366 8121 100       29414 push @error_paths, $node->[0] if tied($node->[1]->%*);
367             }
368             elsif (is_ref($node->[1])) {
369 1508         4103 next; # boolean or bignum
370             }
371             else {
372 7654         26129 my $flags = B::svref_2object(\$node->[1])->FLAGS;
373 7654 100 75     40536 push @error_paths, $node->[0]
374             if not ($flags & B::SVf_POK xor $flags & (B::SVf_IOK | B::SVf_NOK));
375             }
376             }
377              
378 6086         13335 return @error_paths;
379             }
380              
381             # used for internal serialization/deserialization; does not prettify the string.
382             has _json_serializer => (
383             is => 'ro',
384             isa => HasMethods[qw(encode decode)],
385             handles => {
386             json_serialize => 'encode',
387             json_deserialize => 'decode',
388             },
389             lazy => 1,
390             default => sub { JSON::MaybeXS->new(allow_nonref => 1, utf8 => 1, allow_blessed => 1, canonical => 1) },
391             );
392              
393             # used for displaying diagnostics only
394             has _json_prettyprinter => (
395             is => 'ro',
396             isa => HasMethods['encode'],
397             lazy => 1,
398             handles => {
399             json_prettyprint => 'encode',
400             },
401             default => sub {
402             my $encoder = JSON::MaybeXS->new(allow_nonref => 1, utf8 => 1, allow_blessed => 1, canonical => 1, convert_blessed => 1, pretty => 1)->space_before(0);
403             $encoder->indent_length(2) if $encoder->can('indent_length');
404             $encoder;
405             },
406             );
407              
408             # backcompat shims
409 0     0   0 sub _json_decoder { shift->_json_serializer(@_) }
410 22     22 0 1574 sub json_decoder { shift->_json_serializer(@_) }
411 0     0   0 sub _json_encoder { shift->_json_prettyprinter(@_) }
412 0     0 0 0 sub json_encoder { shift->_json_prettyprinter(@_) }
413              
414             # see JSON::MaybeXS::is_bool
415             my $json_bool = InstanceOf[qw(JSON::XS::Boolean Cpanel::JSON::XS::Boolean JSON::PP::Boolean)];
416              
417             has _test_data => (
418             is => 'lazy',
419             isa => ArrayRef[Dict[
420             file => InstanceOf['Path::Tiny'],
421             json => ArrayRef[Dict[
422             # id => Optional[Str],
423             description => Str,
424             comment => Optional[Str],
425             schema => $json_bool|HashRef,
426             tests => ArrayRef[Dict[
427             # id => Optional[Str],
428             data => Any,
429             description => Str,
430             comment => Optional[Str],
431             valid => $json_bool,
432             Slurpy[Any],
433             ]],
434             Slurpy[Any],
435             ]],
436             ]],
437             );
438              
439 42     42   2430 sub _build__test_data ($self) {
  42         88  
  42         88  
440 42         149 my @test_groups;
441              
442             $self->test_dir->visit(
443             sub {
444 625     625   66216 my ($path) = @_;
445 625 100       16619 return if any { $self->test_dir->child($_)->subsumes($path) } $self->skip_dir->@*;
  10         327  
446 623 100       9952 return if not $path->is_file;
447 599 100       15209 return if $path !~ /\.json$/;
448 598         5667 my $data = $self->json_deserialize($path->slurp_raw);
449 598 100       288041 return if not @$data; # placeholder files for renamed tests
450 587         11901 my $file = $path->relative($self->test_dir);
451 587         124662 push @test_groups, [
452             scalar(split('/', $file)),
453             {
454             file => $file,
455             json => $data,
456             },
457             ];
458             },
459 42         749 { recurse => $self->include_optional },
460             );
461              
462             return [
463             map $_->[1],
464 42 50       4338 sort { $a->[0] <=> $b->[0] || $a->[1]{file} cmp $b->[1]{file} }
  2304         15112  
465             @test_groups
466             ];
467             }
468              
469 34     34   551 sub _build_results_text ($self) {
  34         71  
  34         74  
470 34         73 my @lines;
471 34         947 push @lines, 'Results using '.ref($self).' '.$self->VERSION;
472              
473 34         798 my $test_dir = $self->test_dir;
474 34         432 my $orig_dir = $self->_build_test_dir;
475              
476 34         6925 my $submodule_status = path(dist_dir('Test-JSON-Schema-Acceptance'), 'submodule_status');
477 34 100 66     4125 if ($submodule_status->exists and $submodule_status->parent->subsumes($self->test_dir)) {
    50 33        
478 3         1059 chomp(my ($commit, $url) = $submodule_status->lines);
479 3         1160 push @lines, 'with commit '.$commit;
480 3         15 push @lines, 'from '.$url.':';
481             }
482             elsif ($test_dir eq $orig_dir and not -d '.git') {
483 0         0 die 'submodule_status file is missing - packaging error? cannot continue';
484             }
485              
486 34   50     8672 push @lines, 'specification version: '.($self->specification//'unknown');
487              
488 34 100       485 if ($test_dir ne $orig_dir) {
489 31 50       276 if ($orig_dir->subsumes($test_dir)) {
    50          
490 0         0 $test_dir = '<base test directory>/'.substr($test_dir, length($orig_dir)+1);
491             }
492             elsif (Path::Tiny->cwd->subsumes($test_dir)) {
493 31         7810 $test_dir = $test_dir->relative;
494             }
495 31         9577 push @lines, 'using custom test directory: '.$test_dir;
496             }
497 34 100       399 push @lines, 'optional tests included: '.($self->include_optional ? 'yes' : 'no');
498 34         792 push @lines, map 'skipping directory: '.$_, $self->skip_dir->@*;
499              
500 34         389 push @lines, '';
501 34         221 my $length = max(40, map length $_->{file}, $self->results->@*);
502              
503 34         818 push @lines, sprintf('%-'.$length.'s pass todo-fail fail', 'filename');
504 34         135 push @lines, '-'x($length + 23);
505 34         246 push @lines, map sprintf('%-'.$length.'s % 5d % 4d % 4d', $_->@{qw(file pass todo_fail fail)}),
506             $self->results->@*;
507              
508 34         878 my $total = +{ map { my $type = $_; $type => sum0(map $_->{$type}, $self->results->@*) } qw(pass todo_fail fail) };
  102         215  
  102         790  
509 34         148 push @lines, '-'x($length + 23);
510 34         244 push @lines, sprintf('%-'.$length.'s % 5d % 5d % 5d', 'TOTAL', $total->@{qw(pass todo_fail fail)});
511              
512 34         1108 return join("\n", @lines, '');
513             }
514              
515             1;
516              
517             __END__
518              
519             =pod
520              
521             =encoding UTF-8
522              
523             =for stopwords validators Schemas ANDed ORed TODO
524              
525             =head1 NAME
526              
527             Test::JSON::Schema::Acceptance - Acceptance testing for JSON-Schema based validators
528              
529             =head1 VERSION
530              
531             version 1.020
532              
533             =head1 SYNOPSIS
534              
535             This module allows the
536             L<JSON Schema Test Suite|https://github.com/json-schema/JSON-Schema-Test-Suite> tests to be used in
537             perl to test a module that implements the JSON Schema specification ("json-schema"). These are the
538             same tests that many modules (libraries, plugins, packages, etc.) use to confirm support of
539             json-schema. Using this module to confirm support gives assurance of interoperability with other
540             modules that run the same tests in different languages.
541              
542             In the JSON::Schema::Modern module, a test could look like the following:
543              
544             use Test::More;
545             use JSON::Schema::Modern;
546             use Test::JSON::Schema::Acceptance;
547              
548             my $accepter = Test::JSON::Schema::Acceptance->new(specification => 'draft7');
549              
550             $accepter->acceptance(
551             validate_data => sub ($schema, $input_data) {
552             return JSON::Schema::Modern->new($schema)->validate($input_data);
553             },
554             todo_tests => [ { file => 'dependencies.json' } ],
555             );
556              
557             done_testing();
558              
559             This would determine if JSON::Schema::Modern's C<validate> method returns the right result for all
560             of the cases in the JSON Schema Test Suite, except for those listed in C<$skip_tests>.
561              
562             =head1 DESCRIPTION
563              
564             L<JSON Schema|http://json-schema.org> is an IETF draft (at time of writing) which allows you to
565             define the structure of JSON.
566              
567             From the overview of the L<draft 2020-12 version of the
568             specification|https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.3>:
569              
570             =over 4
571              
572             This document proposes a new media type "application/schema+json" to identify a JSON Schema for
573             describing JSON data. It also proposes a further optional media type,
574             "application/schema-instance+json", to provide additional integration features. JSON Schemas are
575             themselves JSON documents. This, and related specifications, define keywords allowing authors to
576             describe JSON data in several ways.
577              
578             JSON Schema uses keywords to assert constraints on JSON instances or annotate those instances with
579             additional information. Additional keywords are used to apply assertions and annotations to more
580             complex JSON data structures, or based on some sort of condition.
581              
582             =back
583              
584             This module allows other perl modules (for example JSON::Schema::Modern) to test that they are JSON
585             Schema-compliant, by running the tests from the official test suite, without having to manually
586             convert them to perl tests.
587              
588             You are unlikely to want this module, unless you are attempting to write a module which implements
589             JSON Schema the specification, and want to test your compliance.
590              
591             =head1 CONSTRUCTOR
592              
593             Test::JSON::Schema::Acceptance->new(specification => $specification_version)
594              
595             Create a new instance of Test::JSON::Schema::Acceptance.
596              
597             Available options (which are also available as accessor methods on the object) are:
598              
599             =head2 specification
600              
601             This determines the draft version of the schema to confirm compliance to.
602             Possible values are:
603              
604             =over 4
605              
606             =item *
607              
608             C<draft3>
609              
610             =item *
611              
612             C<draft4>
613              
614             =item *
615              
616             C<draft6>
617              
618             =item *
619              
620             C<draft7>
621              
622             =item *
623              
624             C<draft2019-09>
625              
626             =item *
627              
628             C<draft2020-12>
629              
630             =item *
631              
632             C<latest> (alias for C<draft2020-12>)
633              
634             =item *
635              
636             C<draft-next>
637              
638             =back
639              
640             The default is C<latest>, but in the synopsis example, L<JSON::Schema::Modern> is testing draft 7
641             compliance.
642              
643             (For backwards compatibility, C<new> can be called with a single numeric argument of 3 to 7, which
644             maps to C<draft3> through C<draft7>.)
645              
646             =head2 supported_specifications
647              
648             The version(s) that the implementation supports; used to skip adding remote resources that reference
649             unsupported schema versions (for cross-schema tests). Defaults to C<< [ $self->specification ] >>.
650              
651             =head2 test_dir
652              
653             Instead of specifying a draft specification to test against, which will select the most appropriate
654             tests, you can pass in the name of a directory of tests to run directly. Files in this directory
655             should be F<.json> files following the format described in
656             L<https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/README.md>.
657              
658             =head2 additional_resources
659              
660             A directory of additional resources which should be made available to the implementation under the
661             base URI C<http://localhost:1234>. This is automatically provided if you did not override
662             C</test_dir>; otherwise, you need to supply it yourself, if any tests require it (for example by
663             containing C<< {"$ref": "http://localhost:1234/foo.json/#a/b/c"} >>). If you supply an
664             L</add_resource> value to L</acceptance> (see below), this will be done for you.
665              
666             =head2 verbose
667              
668             Optional. When true, prints version information and the test result table such that it is visible
669             during C<make test> or C<prove>.
670              
671             =head2 include_optional
672              
673             Optional. When true, tests in subdirectories (most notably F<optional/> are also included.
674              
675             =head2 skip_dir
676              
677             Optional. Pass a string or arrayref consisting of relative path name(s) to indicate directories
678             (within the test directory as specified above with C<specification> or C<test_dir>) which will be
679             skipped. Note that this is only useful currently with C<include_optional => 1>, as otherwise all
680             subdirectories would be skipped anyway.
681              
682             =head2 results
683              
684             After calling L</acceptance>, a list of test results are provided here. It is an arrayref of
685             hashrefs with four keys:
686              
687             =over 4
688              
689             =item *
690              
691             file - the filename
692              
693             =item *
694              
695             pass - the number of pass results for that file
696              
697             =item *
698              
699             todo_fail - the number of fail results for that file that were marked TODO
700              
701             =item *
702              
703             fail - the number of fail results for that file (not including TODO tests)
704              
705             =back
706              
707             =head2 results_text
708              
709             After calling L</acceptance>, a text string tabulating the test results are provided here. This is
710             the same table that is printed at the end of the test run.
711              
712             =head2 test_schemas
713              
714             =for stopwords metaschema
715              
716             Optional. A boolean that, when true, will test every schema against its
717             specification metaschema. (When set, C<specification> must also be set.)
718              
719             This normally should not be set as the official test suite has already been
720             sanity-tested, but you may want to set this in development environments if you
721             are using your own test files.
722              
723             Defaults to false.
724              
725             =head1 SUBROUTINES/METHODS
726              
727             =head2 acceptance
728              
729             =for stopwords truthy falsey
730              
731             Accepts a hash of options as its arguments.
732              
733             (Backwards-compatibility mode: accepts a subroutine which is used as C<validate_json_string>,
734             and a hashref of arguments.)
735              
736             Available options are:
737              
738             =head3 validate_data
739              
740             A subroutine reference, which is passed two arguments: the JSON Schema, and the B<inflated> data
741             structure to be validated. This is the main entry point to your JSON Schema library being tested.
742              
743             The subroutine should return truthy or falsey depending on if the schema was valid for the input or
744             not (an object with a boolean overload is acceptable).
745              
746             Either C<validate_data> or C<validate_json_string> is required.
747              
748             =head3 validate_json_string
749              
750             A subroutine reference, which is passed two arguments: the JSON Schema, and the B<JSON string>
751             containing the data to be validated. This is an alternative to L</validate_data> above, if your
752             library only accepts JSON strings.
753              
754             The subroutine should return truthy or falsey depending on if the schema was valid for the input or
755             not (an object with a boolean overload is acceptable).
756              
757             Exactly one of C<validate_data> or C<validate_json_string> is required.
758              
759             =head3 add_resource
760              
761             Optional. A subroutine reference, which will be called at the start of L</acceptance> multiple
762             times, with two arguments: a URI (string), and a data structure containing schema data to be
763             associated with that URI, for use in some tests that use additional resources (see above). If you do
764             not provide this option, you will be responsible for ensuring that those additional resources are
765             made available to your implementation for the successful execution of the tests that rely on them.
766              
767             For more information, see <https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.9.1.2>.
768              
769             =head3 tests
770              
771             Optional. Restricts tests to just those mentioned (the conditions are ANDed together, not ORed).
772             The syntax can take one of many forms:
773              
774             # run tests in this file
775             tests => { file => 'dependencies.json' }
776              
777             # run tests in these files
778             tests => { file => [ 'dependencies.json', 'refRemote.json' ] }
779              
780             # run tests in this file with this group description
781             tests => {
782             file => 'refRemote.json',
783             group_description => 'remote ref',
784             }
785              
786             # run tests in this file with these group descriptions
787             tests => {
788             file => 'const.json',
789             group_description => [ 'const validation', 'const with object' ],
790             }
791              
792             # run tests in this file with this group description and test description
793             tests => {
794             file => 'const.json',
795             group_description => 'const validation',
796             test_description => 'another type is invalid',
797             }
798              
799             # run tests in this file with this group description and these test descriptions
800             tests => {
801             file => 'const.json',
802             group_description => 'const validation',
803             test_description => [ 'same value is valid', 'another type is invalid' ],
804             }
805              
806             =head3 todo_tests
807              
808             Optional. Mentioned tests will run as L<"TODO"|Test::More/TODO: BLOCK>. Uses arrayrefs of
809             the same hashref structure as L</tests> above, which are ORed together.
810              
811             todo_tests => [
812             # all tests in this file are TODO
813             { file => 'dependencies.json' },
814             # just some tests in this file are TODO
815             { file => 'boolean_schema.json', test_description => 'array is invalid' },
816             # .. etc
817             ]
818              
819             =head1 ACKNOWLEDGEMENTS
820              
821             =for stopwords Perrett Signes
822              
823             Daniel Perrett <perrettdl@cpan.org> for the concept and help in design.
824              
825             Ricardo Signes <rjbs@cpan.org> for direction to and creation of Test::Fatal.
826              
827             Various others in #perl-help.
828              
829             =for stopwords OpenAPI
830              
831             =head1 SUPPORT
832              
833             Bugs may be submitted through L<https://github.com/karenetheridge/Test-JSON-Schema-Acceptance/issues>.
834              
835             You can also find me on the L<JSON Schema Slack server|https://json-schema.slack.com> and L<OpenAPI Slack
836             server|https://open-api.slack.com>, which are also great resources for finding help.
837              
838             =head1 AUTHOR
839              
840             Ben Hutton (@relequestual) <relequest@cpan.org>
841              
842             =head1 CONTRIBUTORS
843              
844             =for stopwords Karen Etheridge Daniel Perrett
845              
846             =over 4
847              
848             =item *
849              
850             Karen Etheridge <ether@cpan.org>
851              
852             =item *
853              
854             Daniel Perrett <dp13@sanger.ac.uk>
855              
856             =back
857              
858             =head1 COPYRIGHT AND LICENCE
859              
860             This software is Copyright (c) 2015 by Ben Hutton.
861              
862             This is free software, licensed under:
863              
864             The MIT (X11) License
865              
866             This distribution includes data from the L<https://json-schema.org> test suite, which carries its own
867             licence (see F<share/LICENSE>).
868              
869             =for Pod::Coverage BUILDARGS BUILD json_encoder json_decoder
870              
871             =cut