Trog-DataModule-schema.t 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. use strict;
  2. use warnings;
  3. use Test::More;
  4. use Test::MockModule qw{strict};
  5. use Test::Deep;
  6. use Test::Fatal qw{exception};
  7. use JSON::MaybeXS();
  8. use Path::Tiny();
  9. use FindBin;
  10. use feature qw{signatures};
  11. no warnings qw{experimental::signatures};
  12. use lib "$FindBin::Bin/../lib";
  13. require_ok('Trog::DataModule') or BAIL_OUT("Can't find SUT");
  14. require_ok('Trog::Routes::HTML');
  15. # Point the SUT at a temp forms dir we control and hand it whatever sidecars
  16. # each subtest wants. The dir has to be real, as schema_for() keys its cache on
  17. # the dir's mtime.
  18. my $tempdir = Path::Tiny->tempdir();
  19. my %sidecars;
  20. my $thememock = Test::MockModule->new('Trog::Themes');
  21. $thememock->redefine( 'template_dirs', sub { ("$tempdir") } );
  22. $thememock->redefine(
  23. 'themed_file_in_dir',
  24. sub ( $path, $file, @ ) {
  25. return -f "$tempdir/$file" ? "$tempdir/$file" : undef;
  26. }
  27. );
  28. # The logger isn't initialized outside of a running server.
  29. my $logmock = Test::MockModule->new('Trog::DataModule');
  30. $logmock->redefine( 'WARN', sub { note(shift) } );
  31. my $slurpmock = Test::MockModule->new('File::Slurper');
  32. $slurpmock->redefine( 'read_text', sub { $sidecars{ Path::Tiny::path(shift)->basename } } );
  33. # -f has to agree with %sidecars, and the dir mtime has to move, or the
  34. # mtime-keyed cache will hand back a stale answer.
  35. my $generation = 0;
  36. sub set_sidecars {
  37. %sidecars = @_;
  38. $tempdir->child($_)->remove foreach map { $_->basename } $tempdir->children();
  39. $tempdir->child($_)->spew_utf8('{}') foreach keys %sidecars;
  40. $generation++;
  41. utime( time() + $generation, time() + $generation, "$tempdir" );
  42. return;
  43. }
  44. sub sidecar { return JSON::MaybeXS::encode_json( { type => 'object', @_ } ) }
  45. subtest 'the base schema applies with no sidecar at all' => sub {
  46. set_sidecars();
  47. my $schema = Trog::DataModule::schema_for('blog.tx');
  48. ok( exists $schema->{properties}{title}, "base properties are there" );
  49. ok( !exists $schema->{properties}{gravy}, "and nothing else is" );
  50. ok( !exists $schema->{required}, "an empty required list isn't emitted - it isn't legal OpenAPI" );
  51. is_deeply(
  52. Trog::DataModule::schema_for(''),
  53. $schema,
  54. "a post with no form still gets the base schema"
  55. );
  56. };
  57. subtest 'a sidecar merges over the base' => sub {
  58. set_sidecars(
  59. 'recipe.json' => sidecar(
  60. required => ['servings'],
  61. properties => { servings => { type => 'integer' }, vegan => { type => 'boolean' } },
  62. )
  63. );
  64. my $schema = Trog::DataModule::schema_for('recipe.tx');
  65. ok( exists $schema->{properties}{servings}, "the sidecar's fields are merged in" );
  66. ok( exists $schema->{properties}{title}, "...without losing the base's" );
  67. is_deeply( $schema->{required}, ['servings'], "required carries over" );
  68. ok( !exists Trog::DataModule::schema_for('blog.tx')->{properties}{servings},
  69. "and they don't leak onto another post type" );
  70. };
  71. subtest 'sidecars cannot redefine core post fields' => sub {
  72. set_sidecars(
  73. 'evil.json' => sidecar(
  74. properties => {
  75. id => { type => 'object' },
  76. acls => { type => 'string' },
  77. callback => { type => 'string' },
  78. },
  79. )
  80. );
  81. my $schema = Trog::DataModule::schema_for('evil.tx');
  82. is( $schema->{properties}{id}{type}, 'string', "id keeps its own type" );
  83. is( $schema->{properties}{acls}{type}, 'array', "acls stays an array" );
  84. is( $schema->{properties}{callback}{type}, 'callback', "callback keeps the type that actually checks the sub exists" );
  85. };
  86. subtest 'a malformed sidecar is skipped, not fatal' => sub {
  87. set_sidecars( 'busted.json' => '{ this is not json' );
  88. my $schema;
  89. is( exception { $schema = Trog::DataModule::schema_for('busted.tx') }, undef, "no exception" );
  90. ok( exists $schema->{properties}{title}, "we fall back to the base schema" );
  91. };
  92. subtest 'the cache follows the forms dir mtime' => sub {
  93. set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
  94. ok( exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings}, "declared" );
  95. set_sidecars();
  96. ok( !exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings},
  97. "removing the sidecar is picked up without a restart" );
  98. };
  99. subtest 'validate() filters, coerces and reports' => sub {
  100. set_sidecars(
  101. 'recipe.json' => sidecar(
  102. properties => {
  103. servings => { type => 'integer' },
  104. vegan => { type => 'boolean' },
  105. photo => { type => 'upload' },
  106. },
  107. )
  108. );
  109. my $post = {
  110. form => 'recipe.tx',
  111. title => 'Cookies',
  112. visibility => 'public',
  113. servings => '12',
  114. vegan => '1',
  115. # The router hands us plenty that was never a post field.
  116. app => 'junk',
  117. to => '/blog',
  118. gravy => 'yes please',
  119. };
  120. is_deeply( [ Trog::DataModule::validate($post) ], [], "a good post has no errors" );
  121. is( $post->{servings}, 12, "strings coerce to the declared type" );
  122. isa_ok( $post->{vegan}, 'JSON::PP::Boolean', "checkbox values become real booleans" );
  123. ok( !exists $post->{app} && !exists $post->{to} && !exists $post->{gravy}, "undescribed keys are filtered out" );
  124. my ($error) = Trog::DataModule::validate( { form => 'recipe.tx', servings => 'lots' } );
  125. like( $error, qr/servings/, "a bad value is reported rather than silently dropped" );
  126. # 'upload' is our own sugar for the string-or-hashref an upload field is,
  127. # depending on which direction it's travelling.
  128. is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => { filename => 'x' } } ) ], [], "an upload hashref passes" );
  129. is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => '/assets/x.jpg' } ) ], [], "so does the href it becomes" );
  130. };
  131. subtest 'empty inputs are treated as absent, except for strings' => sub {
  132. set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
  133. # An untouched number input submits '', which is not an integer and was
  134. # never meant to be one.
  135. my $post = { form => 'recipe.tx', servings => '', title => '' };
  136. is_deeply( [ Trog::DataModule::validate($post) ], [], "no spurious type error" );
  137. ok( !exists $post->{servings}, "the blank number is dropped" );
  138. is( $post->{title}, '', "a blank string field is left exactly as it was before any of this was typed" );
  139. };
  140. subtest 'the custom types actually bite' => sub {
  141. set_sidecars();
  142. my ($bad) = Trog::DataModule::validate( { title => 'x', callback => 'Some::Random::Sub' } );
  143. like( $bad, qr/cannot be loaded/, "a callback naming a module that isn't there is refused" );
  144. is_deeply(
  145. [ Trog::DataModule::validate( { title => 'x', callback => 'Trog::Routes::HTML::posts' } ) ],
  146. [],
  147. "a real one is fine"
  148. );
  149. };
  150. subtest 'add() rejects a post that does not validate' => sub {
  151. set_sidecars( 'recipe.json' => sidecar( required => ['servings'], properties => { servings => { type => 'integer' } } ) );
  152. my $written;
  153. {
  154. no warnings qw{once};
  155. @TestData::ISA = ('Trog::DataModule');
  156. *TestData::get = sub { return () };
  157. *TestData::write = sub { $written = $_[1]; return 0 };
  158. }
  159. my $why = exception {
  160. bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public' } );
  161. };
  162. is( ref $why, 'ARRAY', "it dies with the error list, not a string" );
  163. like( join( ' ', @$why ), qr/servings/, "which names the offending field" );
  164. # A string die would come back wearing a Carp::Always stack trace, which is
  165. # no use at all to whoever just submitted the form.
  166. unlike( join( ' ', @$why ), qr/DataModule\.pm line/, "and carries no stack trace" );
  167. ok( !$written, "nothing was written" );
  168. bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public', servings => '4' } );
  169. is( $written->[0]{servings}, 4, "the good one went through, coerced" );
  170. };
  171. subtest '_wizard_fields zips the parallel arrays' => sub {
  172. # The middle row was left blank. Dropping it must not shift the type of
  173. # the row after it -- this is the whole reason param_required is a select.
  174. my $fields = Trog::Routes::HTML::_wizard_fields(
  175. {
  176. param_name => [ 'Servings', '', 'cook_time', 'id', 'servings' ],
  177. param_type => [ 'number', 'text', 'textarea', 'text', 'text' ],
  178. param_label => [ 'Servings', '', 'Cook Time', 'ID', 'Dupe' ],
  179. param_placeholder => [ '4', '', '45 min', '', '' ],
  180. param_required => [ 1, 0, 0, 0, 0 ],
  181. }
  182. );
  183. is( scalar @$fields, 2, "blank, core-shadowing and duplicate rows are dropped" );
  184. is( $fields->[0]{name}, 'servings', "names are lowercased" );
  185. is( $fields->[0]{required}, 1, "required survives" );
  186. is( $fields->[1]{name}, 'cook_time', "the row after the blank one is still present" );
  187. is( $fields->[1]{type}, 'textarea', "...with its own type, not the blank row's" );
  188. };
  189. subtest '_wizard_sidecar emits a usable OpenAPIv3 schema' => sub {
  190. my $spec = Trog::Routes::HTML::_wizard_sidecar(
  191. 'recipe',
  192. [
  193. { name => 'servings', type => 'number', label => 'Servings', placeholder => '4', required => 1 },
  194. { name => 'vegan', type => 'checkbox', label => 'Vegan?', placeholder => '', required => 0 },
  195. ],
  196. ['inc_tags'],
  197. 'form_common.tx',
  198. );
  199. is( $spec->{type}, 'object', "it's an object schema" );
  200. is( $spec->{properties}{servings}{type}, 'integer', "a number input is an integer" );
  201. is( $spec->{properties}{vegan}{type}, 'boolean', "a checkbox is a boolean" );
  202. is_deeply( $spec->{required}, ['servings'], "only the required rows are required" );
  203. # UI metadata rides along on x- keys so there's only one file to keep in sync.
  204. is( $spec->{properties}{servings}{'x-tcms-label'}, 'Servings', "labels are kept" );
  205. is( $spec->{properties}{servings}{'x-tcms-placeholder'}, '4', "so are placeholders" );
  206. ok( !exists $spec->{properties}{vegan}{'x-tcms-placeholder'}, "but an empty placeholder isn't written out" );
  207. is_deeply( $spec->{'x-tcms-post-type'}{includes}, ['tags.tx'], "the includes are recorded for regeneration" );
  208. };
  209. subtest '_kolon_safe defuses template syntax' => sub {
  210. my $safe = Trog::Routes::HTML::_kolon_safe(qq{<: \$post.id :>\n: include "evil.tx";});
  211. unlike( $safe, qr/</, "no angle brackets survive" );
  212. unlike( $safe, qr/[\r\n]/, "no newlines survive, so ':' can't start a directive" );
  213. is( Trog::Routes::HTML::_kolon_safe( [] ), '', "a repeated param can't sneak a ref through" );
  214. };
  215. done_testing();