Trog-Routes-HTML-series-lifecycle.t 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109
  1. #!/usr/bin/env perl
  2. # Stands up a genuinely fresh tCMS in a temp dir, then walks the loop a user
  3. # actually performs: register an admin, invent a post type in the wizard, and
  4. # for each content post type build a topbar series, confirm it lands in the
  5. # nav, add a child post and confirm the child renders on the series page.
  6. #
  7. # Verification goes through Trog::Routes::HTML::index() -- directly, or via
  8. # series() which delegates to it -- and matches on the returned HTML.
  9. use strict;
  10. use warnings;
  11. # FindBin MUST come first. $FindBin::Bin is derived from $0, which prove hands
  12. # us relatively, and it is computed once. chdir before this loads and every
  13. # module's `use FindBin::libs` resolves against the sandbox instead of the repo.
  14. use FindBin;
  15. use lib "$FindBin::Bin/../lib";
  16. use Test::More;
  17. use Cwd ();
  18. use Encode ();
  19. use File::Copy ();
  20. use File::Find ();
  21. use File::Path ();
  22. use File::Temp ();
  23. use Time::HiRes ();
  24. use URI::Escape ();
  25. use JSON::MaybeXS ();
  26. our ( $REPO, $ROOT, $OLDCWD );
  27. our %REPO_BEFORE;
  28. # name => (size, mtime) for every file under the repo dirs this test could
  29. # plausibly disturb. Cheap enough, and it is the only real proof of isolation.
  30. sub _snapshot {
  31. my ($dir) = @_;
  32. my %seen;
  33. File::Find::find(
  34. {
  35. no_chdir => 1,
  36. wanted => sub {
  37. return unless -f $File::Find::name;
  38. my @st = stat(_);
  39. $seen{$File::Find::name} = "$st[7]:$st[9]";
  40. },
  41. },
  42. $dir
  43. );
  44. return %seen;
  45. }
  46. sub _copy_tree {
  47. my ( $from, $to ) = @_;
  48. File::Path::make_path($to);
  49. File::Find::find(
  50. {
  51. no_chdir => 1,
  52. wanted => sub {
  53. my $rel = substr( $File::Find::name, length($from) );
  54. return unless length $rel;
  55. my $dst = "$to$rel";
  56. if ( -d $File::Find::name ) { File::Path::make_path($dst) }
  57. elsif ( -f $File::Find::name ) { File::Copy::copy( $File::Find::name, $dst ) or die "copy $File::Find::name: $!" }
  58. },
  59. },
  60. $from
  61. );
  62. return;
  63. }
  64. # Everything the sandbox needs, in one BEGIN. The Trog:: modules are require()d
  65. # at runtime below, which is unconditionally after every BEGIN -- so no later
  66. # edit can accidentally load them before the chdir. That matters: TCMS.pm runs
  67. # build_routes() at file scope and FlatFile.pm reads the tag index at file
  68. # scope, both against whatever cwd happens to be current.
  69. BEGIN {
  70. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  71. $OLDCWD = Cwd::getcwd();
  72. %REPO_BEFORE = map { _snapshot("$REPO/$_") } qw{config data www/templates www/assets logs totp};
  73. $ROOT = File::Temp::tempdir( 'tcms-lifecycle-XXXXXX', TMPDIR => 1, CLEANUP => 0 );
  74. # Installer.mk's tree, plus upload-src for the file.tx fixture.
  75. File::Path::make_path(
  76. map { "$ROOT/$_" } qw{
  77. config schema data/files logs totp upload-src
  78. www/assets/private www/statics www/themes www/scripts
  79. }
  80. );
  81. # A real fresh install: get() looks for config/main.cfg, doesn't find one,
  82. # and falls through to the shipped default.
  83. File::Copy::copy( "$REPO/config/default.cfg", "$ROOT/config/default.cfg" ) or die $!;
  84. # Hardcoded paths in Auth.pm, TagIndex.pm and Log.pm -- not negotiable.
  85. File::Copy::copy( "$REPO/schema/$_", "$ROOT/schema/$_" ) or die $! for qw{auth.schema flatfile.schema log.schema};
  86. # COPY, don't symlink: post_wizard_save() writes into Trog::Themes::forms_dir(),
  87. # and a symlink would land the generated post type in the real repo.
  88. _copy_tree( "$REPO/www/templates", "$ROOT/www/templates" );
  89. # posts() renders the emoji picker unconditionally, and EmojiPicker dies
  90. # outright if this file is missing. Only category and emoji are read.
  91. open( my $fh, '>', "$ROOT/www/scripts/list.min.json" ) or die $!;
  92. print {$fh} '{"emojis":[{"category":"Smileys","emoji":"X"}]}';
  93. close $fh;
  94. # Text::Xslate computes its default cache dir from $HOME once, at load time.
  95. # Without this the sandbox writes compiled templates into the real ~/.xslate_cache.
  96. $ENV{HOME} = $ROOT;
  97. chdir($ROOT) or die "could not chdir to sandbox $ROOT: $!";
  98. }
  99. END {
  100. # Never rmtree the directory you are standing in.
  101. chdir($OLDCWD) if $OLDCWD;
  102. if ( $ENV{TCMS_TEST_KEEP} ) {
  103. diag("TCMS_TEST_KEEP set, sandbox left at $ROOT");
  104. }
  105. elsif ( $ROOT && -d $ROOT ) {
  106. # SQLite handles are file-lexical in Trog::SQLite and unreachable from
  107. # here; DBI disconnects at global destruction, after these are gone.
  108. eval { File::Path::remove_tree($ROOT) };
  109. }
  110. }
  111. diag("sandbox: $ROOT");
  112. require Trog::Log;
  113. Trog::Log::log_init( 'logs/tcms.log', 'info' ); # sets $LOGDIR, hence logs/log.db
  114. require Trog::Config;
  115. require Trog::Themes;
  116. require Trog::Data;
  117. require Trog::Auth;
  118. require Trog::SQLite::TagIndex;
  119. require Trog::Routes::HTML;
  120. require Trog::Routes::JSON;
  121. #--------------------------------------------------------------------------
  122. # A stand-in for the tPSGI object the router injects as $query->{tpsgi}.
  123. #--------------------------------------------------------------------------
  124. {
  125. package Test::TPSGI;
  126. sub new {
  127. my ( $class, %args ) = @_;
  128. return bless {
  129. callbacks => 0,
  130. renders => {},
  131. invalidated => [],
  132. restarts => 0,
  133. tpsgi_dir => Cwd::getcwd(),
  134. log_dir => 'logs',
  135. gid => $),
  136. verbose => 0,
  137. %args,
  138. }, $class;
  139. }
  140. sub INFO {1}
  141. sub DEBUG {1}
  142. sub WARN {1}
  143. sub ERROR {1}
  144. sub CRIT {1}
  145. sub ALERT {1}
  146. sub EMERG {1}
  147. sub FATAL {1}
  148. sub see_also { return [ 303, [ 'Location' => $_[1], 'Content-Length' => 0 ], [''] ] }
  149. sub redirect { return [ 302, [ 'Location' => $_[1], 'Content-Length' => 0 ], [''] ] }
  150. sub redirect_permanent { return [ 301, [ 'Location' => $_[1], 'Content-Length' => 0 ], [''] ] }
  151. sub _reply { return [ $_[1], [ 'Content-type' => 'text/html' ], [ defined $_[2] ? $_[2] : '' ] ] }
  152. sub ok { return $_[0]->_reply( 200, $_[2] ) }
  153. sub notfound { return $_[0]->_reply( 404, $_[2] // 'Not Found' ) }
  154. sub forbidden { return $_[0]->_reply( 403, $_[2] // 'Forbidden' ) }
  155. sub badrequest { return $_[0]->_reply( 400, $_[2] // 'Bad Request' ) }
  156. sub error { return $_[0]->_reply( 500, $_[2] // 'Error' ) }
  157. sub serve { return [ 200, [], [''] ] }
  158. # These two must be inert.
  159. #
  160. # add_post_close_callback is the ONLY path by which Trog::Renderer and
  161. # post_save reach save_render()/invalidate_renders(). Dropping the callback
  162. # on the floor is what guarantees this test never writes into www/statics,
  163. # whatever the renderer's own skip-save logic decides.
  164. sub add_post_close_callback { $_[0]{callbacks}++; return 1 }
  165. # The real one is `kill 'HUP', getppid`. Under prove, that is prove.
  166. sub signal_restart_parent { $_[0]{restarts}++; return 1 }
  167. sub invalidate_renders { push @{ $_[0]{invalidated} }, $_[1]; return 1 }
  168. sub invalidate_render { push @{ $_[0]{invalidated} }, [ @_[ 1 .. $#_ ] ]; return 1 }
  169. sub save_render { $_[0]{renders}{"$_[1]:$_[2]"} = $_[3]; return 1 }
  170. }
  171. our $TPSGI = Test::TPSGI->new();
  172. our $ADMIN_USER = 'specadmin';
  173. our $ADMIN_DISPLAY = 'Spec Admin';
  174. our $ADMIN_EMAIL = 'specadmin@example.com';
  175. our $ADMIN_PASS = 'spec-password';
  176. #--------------------------------------------------------------------------
  177. # Harness
  178. #--------------------------------------------------------------------------
  179. # Always build a fresh query: posts() pushes onto user_acls, and series()
  180. # rewrites route/aclname/tag/in_series/primary_post in place.
  181. sub _query {
  182. my (%over) = @_;
  183. my %q = (
  184. route => '/',
  185. method => 'GET',
  186. scheme => 'http', # Renderer::Base compares this with no undef guard
  187. domain => 'tcms.test',
  188. start => [ Time::HiRes::gettimeofday() ], # tv_interval() dies on anything else
  189. deflate => 0,
  190. streaming => 0,
  191. ranges => [],
  192. last_fetched => undef,
  193. has_query => 0,
  194. social_meta => 0,
  195. acls => [],
  196. user_acls => [],
  197. body => undef,
  198. tpsgi => $TPSGI,
  199. %over,
  200. );
  201. $q{fullpath} //= $q{route};
  202. return \%q;
  203. }
  204. sub _anon { return _query( user => undef, user_acls => [], @_ ) }
  205. sub _admin { return _query( user => $ADMIN_USER, user_acls => ['admin'], @_ ) }
  206. # Returns ($code, $body, $error). Never dies, so one bad post type cannot take
  207. # the rest of the file with it -- which matters because a failed render inside
  208. # index() dies rather than returning a 500 (there is no www/templates/html/500.tx).
  209. sub _render {
  210. my ( $query, $how ) = @_;
  211. $how ||= \&Trog::Routes::HTML::index;
  212. my $res = eval { $how->($query) };
  213. return ( 0, '', "died: $@" ) if !$res;
  214. return ( 0, '', 'returned a ' . ( ref($res) || 'plain scalar' ) ) if ref $res ne 'ARRAY';
  215. my ( $code, $headers, $body ) = @$res;
  216. $body = ref $body eq 'ARRAY' ? join( '', @$body ) : ( $body // '' );
  217. return ( $code, Encode::decode_utf8($body), '', $headers );
  218. }
  219. # FlatFile snapshots the tag list at load time and then filters each query's
  220. # tags against it IN PLACE -- so a tag created later isn't merely unindexed, it
  221. # is deleted from the query, and the filter silently degrades to "every public
  222. # post". Production gets away with it because a save HUPs the parent and the
  223. # worker re-execs. We have to do it by hand.
  224. sub _reindex {
  225. no warnings 'once';
  226. @Trog::Data::FlatFile::tags = Trog::SQLite::TagIndex::tags();
  227. %Trog::Data::FlatFile::posts_by_tag = ();
  228. return;
  229. }
  230. sub _data { return Trog::Data->new( Trog::Config::get() ) }
  231. # categories.tx renders into <span id="categories">. Scope to it -- index.tx
  232. # emits other class="topbar" anchors outside that span.
  233. sub _topbar_titles {
  234. my ($body) = @_;
  235. my ($span) = $body =~ m{<span id="categories">(.*?)</span>}s;
  236. return () unless defined $span;
  237. return $span =~ m{class="topbar">([^<]*)</a>}g;
  238. }
  239. # post_title.tx emits exactly one of these per displayed post.
  240. sub _post_count {
  241. my $n = () = $_[0] =~ m{<h3 class='blogtitles'>}g;
  242. return $n;
  243. }
  244. # The rendered page is ~40KB and mostly JavaScript. Show the content well.
  245. sub _kontent {
  246. my ($body) = @_;
  247. my ($k) = $body =~ m{<div id="kontent" class="kontained">(.*?)<div id="rightbar"}s;
  248. $k //= $body;
  249. $k =~ s/<script\b.*?<\/script>//gs;
  250. return length($k) > 2500 ? substr( $k, 0, 2500 ) . "\n...[truncated]" : $k;
  251. }
  252. # Save through post_save() rather than $data->add(): add() dies with an
  253. # arrayref of validator errors, whereas post_save catches them and hands back a
  254. # 400 with the field-level detail in the body -- which is the single most
  255. # useful thing to see when a fixture is wrong.
  256. sub _save_post {
  257. my ( $label, %post ) = @_;
  258. my $to = delete $post{to};
  259. my $res = eval { Trog::Routes::HTML::post_save( _admin( route => '/post/save', method => 'POST', to => $to, %post ) ) };
  260. if ( !$res || ref $res ne 'ARRAY' ) {
  261. fail("$label: saved");
  262. diag( $@ || 'post_save returned nothing' );
  263. return undef;
  264. }
  265. my ( $code, $headers, $body ) = @$res;
  266. my %h = @{ $headers || [] };
  267. # Both outcomes redirect now, so the status alone proves nothing -- the
  268. # outcome is in the query string post_save hands to the destination.
  269. if ( $code != 303 || ( $h{Location} // '' ) =~ m/savefailed=/ ) {
  270. fail("$label: saved");
  271. diag( "post_save returned $code, Location: " . ( $h{Location} // '(none)' ) );
  272. diag( join( '', @{ $body || [''] } ) ) if $body && @$body;
  273. return undef;
  274. }
  275. pass("$label: saved");
  276. _reindex(); # mandatory: see the comment on _reindex
  277. my $saved = _find_post( $post{title} );
  278. ok( $saved, "$label: readable back out of the datastore" ) or return undef;
  279. return $saved;
  280. }
  281. sub _find_post {
  282. my ($title) = @_;
  283. # filter() short-circuits on title; the admin acl skips the visibility filter.
  284. my @posts = _data()->get( title => $title, acls => ['admin'], limit => 0 );
  285. return $posts[0];
  286. }
  287. #--------------------------------------------------------------------------
  288. subtest 'the sandbox is what we are actually running against' => sub {
  289. is( Cwd::getcwd(), Cwd::abs_path($ROOT), 'cwd is the sandbox' );
  290. ok( -d 'data/files', 'datastore dir exists' );
  291. ok( -f 'config/default.cfg', 'config was seeded' );
  292. ok( !-f 'config/main.cfg', 'and there is no main.cfg, so we fall through to the default' );
  293. my $conf = Trog::Config::get();
  294. is( $conf->param('general.data_model'), 'FlatFile', 'config resolves inside the sandbox' );
  295. like( Trog::Themes::forms_dir(), qr/^www\/templates/, 'forms dir is relative, hence sandboxed' );
  296. ok( -d Cwd::abs_path( Trog::Themes::forms_dir() ), 'and it exists' );
  297. like( Cwd::abs_path( Trog::Themes::forms_dir() ), qr/^\Q$ROOT\E/, 'and it is under the sandbox, not the repo' );
  298. };
  299. subtest 'registering the first admin bootstraps the install' => sub {
  300. ok( !-f 'config/has_users', 'no users yet, so login() is in register mode' );
  301. my ( $code, $body, $err, $headers ) = _render(
  302. _anon(
  303. route => '/login',
  304. method => 'POST',
  305. username => $ADMIN_USER,
  306. display_name => $ADMIN_DISPLAY,
  307. contact_email => $ADMIN_EMAIL,
  308. password => $ADMIN_PASS,
  309. to => '/',
  310. ),
  311. \&Trog::Routes::HTML::login,
  312. );
  313. is( $code, 200, 'login() rendered' ) or diag($err);
  314. my %h = @{ $headers || [] };
  315. like( $h{'Set-Cookie'} // '', qr/^tcmslogin=[0-9a-f-]{36};/, 'a session cookie came back' );
  316. ok( -f 'config/has_users', 'registration is now closed' );
  317. is_deeply( Trog::Auth::acls4user($ADMIN_USER), ['admin'], 'the new user is an admin' );
  318. _reindex();
  319. my @seeded = _data()->get( acls => ['admin'], limit => 0 );
  320. is( scalar @seeded, 4, 'the four seed posts were written' )
  321. or diag( 'got: ' . join( ', ', map { "$_->{title} ($_->{form})" } @seeded ) );
  322. };
  323. subtest 'the post wizard invents a new post type' => sub {
  324. my %wizard = (
  325. name => 'spec_widget',
  326. title_placeholder => 'Widget Name',
  327. body_form => 'form_common.tx',
  328. wrapper => 1,
  329. inc_post_title => 1,
  330. inc_post_tags => 1,
  331. inc_title_input => 1,
  332. inc_visibility => 1,
  333. inc_tags => 1,
  334. inc_aliases => 1,
  335. param_name => ['flavor'],
  336. param_type => ['text'],
  337. param_label => ['Flavor'],
  338. param_placeholder => ['Strawberry'],
  339. param_required => [0],
  340. display => q{<div class="postData responsive-text" id="postData-<: $post.id :>">}
  341. . q{<span class="spec-flavor"><: $post.flavor :></span>}
  342. . q{<: render_it($post.data) | mark_raw :></div>},
  343. );
  344. my ( $code, $body, $err ) = _render(
  345. _admin( route => '/admin/wyzzerdd/save', method => 'POST', %wizard ),
  346. \&Trog::Routes::HTML::post_wizard_save,
  347. );
  348. is( $code, 200, 'wizard save rendered' ) or diag($err);
  349. ok( -f 'www/templates/html/components/forms/spec_widget.tx', 'the template was written' );
  350. ok( -f 'www/templates/html/components/forms/spec_widget.json', 'the schema sidecar was written' );
  351. like( $body, qr{<li>spec_widget\.tx</li>}, 'and the new type is listed back to us' );
  352. # This is the assertion that the sandbox is real: a symlinked template tree
  353. # would have put these in the developer's actual checkout.
  354. ok( !-e "$REPO/www/templates/html/components/forms/spec_widget.tx", 'nothing was written into the repo' );
  355. # The relation controls have to be present in every row and always submit,
  356. # or the parallel param_* arrays de-align server side.
  357. like( $body, qr/name="param_relation_form"/, 'the relation target select is in the row template' );
  358. like( $body, qr/name="param_relation_mode"/, 'so is the relation mode select' );
  359. like( $body, qr/<option value="relation">/, 'and relation is offered as a field type' );
  360. # A series can ask for its children to be tiled. A generated type has to
  361. # carry that on its wrapper like the hand-written forms do, or it is the
  362. # one kind of post that silently ignores the setting.
  363. my $generated = Path::Tiny->new('www/templates/html/components/forms/spec_widget.tx')->slurp_utf8;
  364. # Single quoted: the wrapper is Kolon source, so every sigil in it is
  365. # literal and must not be interpolated by the test that checks for it.
  366. my $wrapper = q{<div class="post <: $style :> <: $tiled ? 'tile' : '' :>">};
  367. ok( index( $generated, $wrapper ) >= 0, 'the generated wrapper carries the tile class when the series asks for it' )
  368. or diag( "wanted: $wrapper\ngot: " . ( split( /\n/, $generated ) )[1] );
  369. # Every post has a title, a visibility and acls. The wizard used to let a
  370. # type opt out of collecting them, which is how a hypervisor post ended up
  371. # stored with no visibility and an undef in its tags.
  372. foreach my $required ( 'visibility.tx', 'acls.tx' ) {
  373. like( $generated, qr/\Q: include "$required";\E/, "$required is included whether or not it was asked for" );
  374. }
  375. like( $generated, qr/name="title"/, 'and so is the title input' );
  376. foreach my $gone (qw{inc_title_input inc_visibility inc_acls}) {
  377. unlike( $body, qr/name="\Q$gone\E"/, "the wizard no longer offers '$gone' as a choice" );
  378. }
  379. like( $body, qr/name="param_private"/, 'and it offers per-field privacy' );
  380. like( $body, qr/name="datasource"/, 'and a datasource to draw the posts from' );
  381. like( $body, qr/Trog::DataSource::Virt/, 'listing the ones that exist' );
  382. };
  383. subtest 'a datasource-backed type gets no editor' => sub {
  384. # A datasource whose posts are built rather than written has nothing behind
  385. # a form to save, so generating one would produce an editor that writes
  386. # real posts to sit alongside the synthesized ones.
  387. my ( $code, $body, $err ) = _render(
  388. _admin(
  389. route => '/admin/wyzzerdd/save',
  390. method => 'POST',
  391. name => 'spec_sourced',
  392. datasource => 'Trog::DataSource::Virt',
  393. body_form => 'form_common.tx',
  394. wrapper => 1,
  395. inc_post_title => 1,
  396. display => q{<div class="s"><: $post.title :></div>},
  397. ),
  398. \&Trog::Routes::HTML::post_wizard_save,
  399. );
  400. is( $code, 200, 'the type was created' ) or diag($err);
  401. my $generated = Path::Tiny->new('www/templates/html/components/forms/spec_sourced.tx')->slurp_utf8;
  402. unlike( $generated, qr{action="/post/save"}, 'no editor was generated' );
  403. unlike( $generated, qr/\$can_edit/, 'and nothing guards one' );
  404. like( $generated, qr/class="s"/, 'but the display half is there' );
  405. my $sidecar = JSON::MaybeXS::decode_json(
  406. Path::Tiny->new('www/templates/html/components/forms/spec_sourced.json')->slurp_utf8 );
  407. is( $sidecar->{'x-tcms-datasource'}, 'Trog::DataSource::Virt', 'and the sidecar records the datasource' );
  408. # The same type without one keeps its editor.
  409. _render(
  410. _admin(
  411. route => '/admin/wyzzerdd/save',
  412. method => 'POST',
  413. name => 'spec_stored',
  414. body_form => 'form_common.tx',
  415. wrapper => 1,
  416. inc_post_title => 1,
  417. display => q{<div class="s"><: $post.title :></div>},
  418. ),
  419. \&Trog::Routes::HTML::post_wizard_save,
  420. );
  421. my $stored = Path::Tiny->new('www/templates/html/components/forms/spec_stored.tx')->slurp_utf8;
  422. like( $stored, qr{action="/post/save"}, 'a type on the datastore still gets one' );
  423. # The name is require()d later, so it has to be one we found.
  424. ( $code, $body ) = _render(
  425. _admin( route => '/admin/wyzzerdd/save', method => 'POST', name => 'spec_bogus', datasource => 'Evil::Module' ),
  426. \&Trog::Routes::HTML::post_wizard_save,
  427. );
  428. like( $body, qr/not a datasource I can find/, 'a datasource we did not find is refused' );
  429. ok( !-e 'www/templates/html/components/forms/spec_bogus.tx', 'and nothing was written' );
  430. };
  431. subtest 'a field can be declared editors-only' => sub {
  432. my ( $code, $body, $err ) = _render(
  433. _admin(
  434. route => '/admin/wyzzerdd/save',
  435. method => 'POST',
  436. name => 'spec_secretive',
  437. body_form => 'form_common.tx',
  438. wrapper => 1,
  439. inc_post_title => 1,
  440. inc_tags => 1,
  441. param_name => [ 'shown', 'hidden' ],
  442. param_type => [ 'text', 'text' ],
  443. param_label => [ 'Shown', 'Hidden' ],
  444. param_placeholder => [ '', '' ],
  445. param_required => [ 0, 0 ],
  446. param_private => [ 0, 1 ],
  447. param_relation_form => [ 'blog.tx', 'blog.tx' ],
  448. param_relation_mode => [ 'one', 'one' ],
  449. # Deliberately unguarded: declaring the field private is supposed
  450. # to be enough, so an author does not have to remember.
  451. display => q{<div class="s"><: $post.shown :></div><div class="h"><: $post.hidden :></div>},
  452. ),
  453. \&Trog::Routes::HTML::post_wizard_save,
  454. );
  455. is( $code, 200, 'the type was created' ) or diag($err);
  456. my $sidecar = JSON::MaybeXS::decode_json(
  457. Path::Tiny->new('www/templates/html/components/forms/spec_secretive.json')->slurp_utf8 );
  458. ok( $sidecar->{properties}{hidden}{'x-tcms-private'}, 'the private field is marked so' );
  459. ok( !exists $sidecar->{properties}{shown}{'x-tcms-private'}, 'and a public one carries no flag at all' );
  460. _reindex();
  461. _make_series( 'spec_secretive', 'specsecretive', 'spec_secretive.tx', 'Spec Secretive Series', no_topbar => 1 ) or return;
  462. my $child = _save_post(
  463. 'spec_secretive child',
  464. form => 'spec_secretive.tx',
  465. title => 'Spec Secretive Child',
  466. shown => 'public value',
  467. hidden => 'private value',
  468. visibility => 'public',
  469. tags => ['specsecretive'],
  470. to => '/specsecretive',
  471. ) or return;
  472. is( $child->{hidden}, 'private value', 'the value is stored like any other' );
  473. # An editor sees both.
  474. my ( $acode, $abody ) = _render( _admin( route => '/specsecretive' ), \&Trog::Routes::HTML::series );
  475. is( $acode, 200, 'the series renders for an admin' );
  476. like( $abody, qr{<div class="h">private value</div>}, 'who sees the private field' );
  477. # A logged out reader sees only the public one, without the template
  478. # having guarded anything.
  479. my ( $ncode, $nbody ) = _render( _anon( route => '/specsecretive' ), \&Trog::Routes::HTML::series );
  480. is( $ncode, 200, 'and for everyone else' );
  481. like( $nbody, qr{<div class="s">public value</div>}, 'who still sees the public field' );
  482. unlike( $nbody, qr/private value/, 'but not the private one' );
  483. };
  484. #--------------------------------------------------------------------------
  485. # The matrix.
  486. #
  487. # Per type: build a topbar series whose child_form is that type, check it shows
  488. # up in the nav, add a child post, check the child renders on the series page.
  489. #
  490. # Everything is verified ANONYMOUSLY. can_edit is then false, so the editor
  491. # half of each form never renders and a title appears exactly once -- which is
  492. # what makes like($body, qr/$title/) mean anything. It also sidesteps the
  493. # /secure prefixing that both _post_helper and categories.tx apply when a user
  494. # is set.
  495. #--------------------------------------------------------------------------
  496. sub _make_series {
  497. my ( $label, $aclname, $child_form, $title, %opt ) = @_;
  498. # _get_series caps the bar at 10 and the seeds already use two, so only the
  499. # series whose topbar behaviour we actually care about get tagged.
  500. my @tags = $opt{no_topbar} ? ('series') : ( 'series', 'topbar' );
  501. my $series = _save_post(
  502. "$label series",
  503. form => 'series.tx',
  504. title => $title,
  505. aclname => $aclname, # required by series.json
  506. child_form => $child_form, # required by series.json
  507. local_href => "/$aclname",
  508. href => "/$aclname",
  509. data => "$title subhead",
  510. callback => 'Trog::Routes::HTML::series',
  511. visibility => 'public',
  512. tags => \@tags,
  513. tiled => 0,
  514. to => '/',
  515. ) or return undef;
  516. return $series if $opt{no_topbar};
  517. # The topbar comes from _get_series(), which index() renders through
  518. # categories.tx. Check it on / -- the series has no children yet, and
  519. # posts() 404s on an empty series.
  520. my ( $code, $body, $err ) = _render( _anon( route => '/' ) );
  521. is( $code, 200, "$label: / renders" ) or diag($err);
  522. my @titles = _topbar_titles($body);
  523. ok( ( grep { $_ eq $title } @titles ), "$label: series is in the topbar" )
  524. or diag( 'topbar was: ' . join( ', ', @titles ) );
  525. return $series;
  526. }
  527. sub _series_page {
  528. my ( $label, $aclname, $series_title, $expected_posts ) = @_;
  529. my ( $code, $body, $err ) = _render( _anon( route => "/$aclname" ), \&Trog::Routes::HTML::series );
  530. is( $code, 200, "$label: series page renders" ) or do { diag($err); return '' };
  531. like( $body, qr{<title>[^<]*\Q$series_title\E}, "$label: series page is the right series" );
  532. is( _post_count($body), $expected_posts, "$label: exactly $expected_posts post(s) listed" )
  533. or diag( _kontent($body) );
  534. return $body;
  535. }
  536. # Entities first: the invoice type resolves payee/payor against existing public
  537. # entities.tx posts, so those have to be in the datastore before we get there.
  538. our ( $PAYEE, $PAYOR );
  539. subtest 'entities' => sub {
  540. my $title = 'Spec Entities Series';
  541. _make_series( 'entities', 'specentities', 'entities.tx', $title ) or return;
  542. $PAYEE = _save_post(
  543. 'entities payee child',
  544. form => 'entities.tx',
  545. title => 'Spec Payee Entity',
  546. data => 'Acme Payments LLC, 1 Road, Metropolis',
  547. payment_method => 'ACH',
  548. payment_details => 'Routing 0000 Account 1111',
  549. visibility => 'public',
  550. tags => ['specentities'],
  551. to => '/specentities',
  552. ) or return;
  553. $PAYOR = _save_post(
  554. 'entities payor child',
  555. form => 'entities.tx',
  556. title => 'Spec Payor Entity',
  557. data => 'Wile E Coyote, PO Box 9',
  558. payment_method => 'Carrier Pigeon',
  559. payment_details => 'none',
  560. visibility => 'public',
  561. tags => ['specentities'],
  562. to => '/specentities',
  563. ) or return;
  564. my $body = _series_page( 'entities', 'specentities', $title, 2 ) or return;
  565. like( $body, qr{id="postData-\Q$PAYEE->{id}\E"}, 'payee body rendered' );
  566. like( $body, qr/Acme Payments LLC/, 'payee data rendered' );
  567. like( $body, qr/Wile E Coyote/, 'payor data rendered' );
  568. # entity_details.tx is included only in the editor half, so these are stored
  569. # but never displayed. Pinning that so a template change is noticed.
  570. unlike( $body, qr/Carrier Pigeon/, 'payment details are not shown to anonymous readers' );
  571. is( _find_post('Spec Payee Entity')->{payment_method}, 'ACH', 'but payment_method did survive the schema' );
  572. };
  573. subtest 'blog' => sub {
  574. my $title = 'Spec Blog Series';
  575. _make_series( 'blog', 'specblog', 'blog.tx', $title ) or return;
  576. my $child = _save_post(
  577. 'blog child',
  578. form => 'blog.tx',
  579. title => 'Spec Blog Child',
  580. data => 'Spec blog body text.',
  581. visibility => 'public',
  582. tags => ['specblog'],
  583. to => '/specblog',
  584. ) or return;
  585. my $body = _series_page( 'blog', 'specblog', $title, 1 ) or return;
  586. like( $body, qr/Spec Blog Child/, 'child title rendered' );
  587. like( $body, qr{href='/posts/\Q$child->{id}\E'}, 'child permalink rendered' );
  588. like( $body, qr{id="postData-\Q$child->{id}\E"}, 'child body block rendered' );
  589. like( $body, qr/Spec blog body text\./, 'child body text rendered' );
  590. unlike( $body, qr/Spec Payee Entity/, 'no bleed from the entities series' );
  591. };
  592. subtest 'microblog' => sub {
  593. my $title = 'Spec Microblog Series';
  594. _make_series( 'microblog', 'specmicroblog', 'microblog.tx', $title ) or return;
  595. my $child = _save_post(
  596. 'microblog child',
  597. form => 'microblog.tx',
  598. title => 'Spec Microblog Child',
  599. href => 'https://example.com/spec',
  600. data => 'Spec microblog body.',
  601. visibility => 'public',
  602. tags => ['specmicroblog'],
  603. to => '/specmicroblog',
  604. ) or return;
  605. my $body = _series_page( 'microblog', 'specmicroblog', $title, 1 ) or return;
  606. like( $body, qr{href='https://example\.com/spec' >Spec Microblog Child</a>}, 'title links out to href' );
  607. like( $body, qr{id="postData-\Q$child->{id}\E"}, 'body block rendered' );
  608. like( $body, qr/Spec microblog body\./, 'body text rendered' );
  609. };
  610. subtest 'file' => sub {
  611. my $title = 'Spec File Series';
  612. _make_series( 'file', 'specfile', 'file.tx', $title ) or return;
  613. # _handle_upload MOVES the file, so it has to be created fresh here.
  614. my $src = "$ROOT/upload-src/spec-upload.txt";
  615. open( my $fh, '>', $src ) or die $!;
  616. print {$fh} "spec upload contents\n";
  617. close $fh;
  618. my $child = _save_post(
  619. 'file child',
  620. form => 'file.tx',
  621. title => 'Spec File Child',
  622. data => 'Spec file body.',
  623. file => { tempname => $src, filename => 'spec-upload.txt' },
  624. visibility => 'public',
  625. tags => ['specfile'],
  626. to => '/specfile',
  627. ) or return;
  628. ok( !-e $src, 'the upload was moved out of its temp location, not copied' );
  629. ok( -f "www/assets/$child->{id}.spec-upload.txt", 'and it landed in www/assets' );
  630. my $body = _series_page( 'file', 'specfile', $title, 1 ) or return;
  631. like( $body, qr{href='/assets/\Q$child->{id}\E\.spec-upload\.txt'}, 'title links to the asset' );
  632. like( $body, qr{id="postData-\Q$child->{id}\E"}, 'body block carries a unique id, like every other form' );
  633. like( $body, qr/Spec file body\./, 'body text rendered' );
  634. # This form used to emit <div class="postData" id="postData" class="responsive-text">
  635. # -- a duplicated class attribute, and an id shared by every file post on
  636. # the page. Neither should come back.
  637. unlike( $body, qr{<div class="postData" id="postData"}, 'no duplicated class attribute' );
  638. # text/plain, so none of the media branches fire.
  639. unlike( $body, qr/<video/, 'no video block for a text file' );
  640. unlike( $body, qr/<audio/, 'no audio block either' );
  641. };
  642. subtest 'presentation' => sub {
  643. my $title = 'Spec Presentation Series';
  644. _make_series( 'presentation', 'specpresentation', 'presentation.tx', $title ) or return;
  645. my $child = _save_post(
  646. 'presentation child',
  647. form => 'presentation.tx',
  648. title => 'Spec Presentation Child',
  649. data => [ 'Slide one body.', 'Slide two body.' ],
  650. data_is_array => 1,
  651. visibility => 'public',
  652. tags => ['specpresentation'],
  653. to => '/specpresentation',
  654. ) or return;
  655. is_deeply( $child->{data}, [ 'Slide one body.', 'Slide two body.' ], 'the multi-page data round-tripped as an array' );
  656. my $body = _series_page( 'presentation', 'specpresentation', $title, 1 ) or return;
  657. # Slides only render under nochrome; the series page just links to the deck.
  658. like( $body, qr{<a href="/posts/\Q$child->{id}\E\?embed=1&nochrome=1">Click Here to view</a>}, 'deck link rendered' );
  659. unlike( $body, qr/Slide one body\./, 'slides themselves are not rendered inline' );
  660. };
  661. subtest 'invoice' => sub {
  662. my $title = 'Spec Invoice Series';
  663. _make_series( 'invoice', 'specinvoice', 'invoice.tx', $title ) or return;
  664. my $child = _save_post(
  665. 'invoice child',
  666. form => 'invoice.tx',
  667. title => 'Spec Invoice Child',
  668. data => [ "Widget assembly\nPrice: \$420", "Shipping\nPrice: \$80" ],
  669. data_is_array => 1,
  670. payee => $PAYEE->{id},
  671. payor => $PAYOR->{id},
  672. due_days => 30,
  673. visibility => 'public',
  674. tags => ['specinvoice'],
  675. to => '/specinvoice',
  676. ) or return;
  677. my $body = _series_page( 'invoice', 'specinvoice', $title, 1 ) or return;
  678. like( $body, qr/Invoice ID# \Q$child->{id}\E/, 'invoice header rendered' );
  679. # These two only resolve because the entities subtest ran first and left
  680. # public entities.tx posts behind for the enrich hook to find.
  681. like( $body, qr/Acme Payments LLC/, 'payee entity resolved' );
  682. like( $body, qr/Wile E Coyote/, 'payor entity resolved' );
  683. like( $body, qr/ACH/, 'payee payment method pulled through' );
  684. like( $body, qr/Widget assembly/, 'line items rendered' );
  685. like( $body, qr/Total: \$500/, 'total summed from the line item prices' );
  686. };
  687. subtest 'spec_widget (the wizard-built type)' => sub {
  688. my $title = 'Spec Widget Series';
  689. _make_series( 'spec_widget', 'specwidget', 'spec_widget.tx', $title ) or return;
  690. my $child = _save_post(
  691. 'spec_widget child',
  692. form => 'spec_widget.tx',
  693. title => 'Spec Widget Child',
  694. data => 'Spec widget body.',
  695. flavor => 'Strawberry',
  696. bogus_field => 'nope',
  697. visibility => 'public',
  698. tags => ['specwidget'],
  699. to => '/specwidget',
  700. ) or return;
  701. is( $child->{flavor}, 'Strawberry', 'the custom field survived validation' );
  702. ok( !exists $child->{bogus_field}, 'an undeclared field was still stripped' );
  703. my $body = _series_page( 'spec_widget', 'specwidget', $title, 1 ) or return;
  704. # The payoff: this can only render if the wizard wrote spec_widget.json,
  705. # schema_for merged it over the base post schema, and validate() therefore
  706. # kept 'flavor' instead of deleting it as an unknown key.
  707. like( $body, qr{<span class="spec-flavor">Strawberry</span>}, 'the custom field rendered on the page' );
  708. like( $body, qr/Spec widget body\./, 'and the body came through the generated template' );
  709. };
  710. subtest 'a wizard-built type can depend on another type' => sub {
  711. # The type we will point at. The relation is what is under test, so keep
  712. # the target itself boring.
  713. my ( $code, $body, $err ) = _render(
  714. _admin(
  715. route => '/admin/wyzzerdd/save',
  716. method => 'POST',
  717. name => 'spec_target',
  718. body_form => 'form_common.tx',
  719. wrapper => 1,
  720. inc_post_title => 1,
  721. inc_title_input => 1,
  722. inc_visibility => 1,
  723. inc_tags => 1,
  724. display => q{<div><: render_it($post.data) | mark_raw :></div>},
  725. ),
  726. \&Trog::Routes::HTML::post_wizard_save,
  727. );
  728. is( $code, 200, 'target type created' ) or diag($err);
  729. # ...and the type that depends on it: one picked by id, one pulling in the
  730. # whole set.
  731. ( $code, $body, $err ) = _render(
  732. _admin(
  733. route => '/admin/wyzzerdd/save',
  734. method => 'POST',
  735. name => 'spec_relator',
  736. body_form => 'form_common.tx',
  737. wrapper => 1,
  738. inc_post_title => 1,
  739. inc_title_input => 1,
  740. inc_visibility => 1,
  741. inc_tags => 1,
  742. param_name => [ 'chosen', 'everything' ],
  743. param_type => [ 'relation', 'relation' ],
  744. param_label => [ 'Chosen One', 'All Of Them' ],
  745. param_placeholder => [ '', '' ],
  746. param_required => [ 0, 0 ],
  747. param_relation_form => [ 'spec_target.tx', 'spec_target.tx' ],
  748. param_relation_mode => [ 'one', 'all' ],
  749. display => q{<div class="rel"><span class="chosen"><: $post.chosen_post.title :></span>}
  750. . q{<: for $post.everything -> $t { :><span class="each"><: $t.title :></span><: } :></div>},
  751. ),
  752. \&Trog::Routes::HTML::post_wizard_save,
  753. );
  754. is( $code, 200, 'relating type created' ) or diag($err);
  755. my $sidecar = JSON::MaybeXS::decode_json(
  756. Path::Tiny->new('www/templates/html/components/forms/spec_relator.json')->slurp_utf8 );
  757. # 'one' is a field the post stores; 'all' is not a field at all.
  758. is( $sidecar->{properties}{chosen}{type}, 'relation', 'the picked relation is a stored property' );
  759. is( $sidecar->{properties}{chosen}{'x-tcms-relation-form'}, 'spec_target.tx', 'naming its target type' );
  760. ok( !exists $sidecar->{properties}{everything}, "the 'all' relation stores nothing" );
  761. is_deeply(
  762. $sidecar->{'x-tcms-relations'},
  763. {
  764. chosen_post => { form => 'spec_target.tx', from => 'chosen' },
  765. everything => { form => 'spec_target.tx' },
  766. },
  767. 'and both are declared for resolution at render'
  768. );
  769. # The relation pseudo-type has to become a real one before validation, or
  770. # every relation field would be stripped as an unknown type.
  771. is( Trog::DataModule::schema_for('spec_relator.tx')->{properties}{chosen}{type},
  772. 'string', "'relation' expands to a real schema type" );
  773. _reindex();
  774. # Now exercise it end to end.
  775. _make_series( 'spec_target', 'spectarget', 'spec_target.tx', 'Spec Target Series', no_topbar => 1 ) or return;
  776. my $target = _save_post(
  777. 'spec_target child',
  778. form => 'spec_target.tx',
  779. title => 'Spec Target Post',
  780. data => 'Spec target body.',
  781. visibility => 'public',
  782. tags => ['spectarget'],
  783. to => '/spectarget',
  784. ) or return;
  785. _make_series( 'spec_relator', 'specrelator', 'spec_relator.tx', 'Spec Relator Series', no_topbar => 1 ) or return;
  786. my $relator = _save_post(
  787. 'spec_relator child',
  788. form => 'spec_relator.tx',
  789. title => 'Spec Relator Post',
  790. data => 'Spec relator body.',
  791. chosen => $target->{id},
  792. visibility => 'public',
  793. tags => ['specrelator'],
  794. to => '/specrelator',
  795. ) or return;
  796. is( $relator->{chosen}, $target->{id}, 'the referenced id survived validation' );
  797. my $page = _series_page( 'spec_relator', 'specrelator', 'Spec Relator Series', 1 ) or return;
  798. # Both halves of the relation resolved: the one we picked, and the set.
  799. like( $page, qr{<span class="chosen">Spec Target Post</span>}, 'the picked post resolved into the page' );
  800. like( $page, qr{<span class="each">Spec Target Post</span>}, 'and so did the whole set' );
  801. };
  802. subtest '/api/posts_of_form feeds the relation picker' => sub {
  803. my $target = _find_post('Spec Target Post');
  804. ok( $target, 'the target post is there to be listed' ) or return;
  805. my $res = Trog::Routes::JSON::posts_of_form( _admin( route => '/api/posts_of_form', form => 'spec_target.tx' ) );
  806. is( ref $res, 'ARRAY', 'a PSGI response came back' ) or return;
  807. my ( $code, undef, $body ) = @$res;
  808. is( $code, 200, 'and it is a 200' );
  809. my $payload = JSON::MaybeXS::decode_json( join( '', @$body ) );
  810. is_deeply(
  811. $payload->{posts},
  812. [ { id => $target->{id}, title => 'Spec Target Post' } ],
  813. 'listing exactly the posts of that type, id and title only'
  814. );
  815. # An unknown type is an empty list, not an error -- a type with no posts
  816. # yet is the normal state of affairs right after you create it.
  817. $res = Trog::Routes::JSON::posts_of_form( _admin( route => '/api/posts_of_form', form => 'nosuchtype.tx' ) );
  818. $payload = JSON::MaybeXS::decode_json( join( '', @{ $res->[2] } ) );
  819. is_deeply( $payload->{posts}, [], 'an unknown type lists nothing rather than failing' );
  820. # The route's own declaration is what keeps a malformed form out; check it
  821. # matches what series.json demands of child_form.
  822. my $validator = $Trog::Routes::JSON::routes{'/api/posts_of_form'}{parameters}{form};
  823. ok( $validator->('blog.tx'), 'the parameter validator accepts a form name' );
  824. ok( !$validator->('../../etc/passwd'), 'and rejects a path' );
  825. ok( $Trog::Routes::JSON::routes{'/api/posts_of_form'}{auth}, 'the route requires a login' );
  826. };
  827. subtest 'a series resolves its relations for logged out visitors too' => sub {
  828. # The relations series() resolves land on the primary post, which is what a
  829. # datasource reads to find its hypervisors -- so assert on that rather than
  830. # on the rendered children, whose relations posts() resolves later, after
  831. # it has widened user_acls and thus by a path that was never broken.
  832. foreach my $case ( [ 'anonymously', _anon( route => '/specrelator' ) ], [ 'as an admin', _admin( route => '/specrelator' ) ] ) {
  833. my ( $label, $query ) = @$case;
  834. my ( $code, undef, $err ) = _render( $query, \&Trog::Routes::HTML::series );
  835. is( $code, 200, "the series renders $label" ) or do { diag($err); next };
  836. my $resolved = $query->{primary_post}{everything};
  837. is( ref $resolved, 'ARRAY', "$label, the series' own relation resolved" );
  838. cmp_ok( scalar @{ $resolved || [] }, '>', 0, "$label, to something rather than nothing" );
  839. }
  840. };
  841. subtest 'saves report back through the jsalert banner' => sub {
  842. # A good save redirects to where you came from, carrying the outcome.
  843. my $res = Trog::Routes::HTML::post_save(
  844. _admin(
  845. route => '/post/save',
  846. method => 'POST',
  847. to => '/specblog',
  848. form => 'blog.tx',
  849. title => 'Spec Feedback Child',
  850. data => 'Spec feedback body.',
  851. visibility => 'public',
  852. tags => ['specblog'],
  853. )
  854. );
  855. my ( $code, $headers ) = @$res;
  856. my %h = @{ $headers || [] };
  857. is( $code, 303, 'a good save redirects' );
  858. like( $h{Location}, qr{^/secure/specblog\?saved=}, 'to where we came from, flagged as saved' );
  859. unlike( $h{Location}, qr/\n/, 'and the message is one line, since it lands in a JS string' );
  860. _reindex();
  861. # Follow the redirect the way a browser would.
  862. my ($qs) = $h{Location} =~ m/\?(.*)$/;
  863. my %params = map { my ( $k, $v ) = split( /=/, $_, 2 ); ( $k => URI::Escape::uri_unescape($v) ) } split( /&/, $qs );
  864. my ( $rcode, $body, $err ) = _render(
  865. _admin( route => '/specblog', has_query => 1, %params ),
  866. \&Trog::Routes::HTML::series,
  867. );
  868. is( $rcode, 200, 'the destination renders' ) or diag($err);
  869. like( $body, qr/var loginFailure = 0;/, 'the banner is in success mode' );
  870. like( $body, qr/Saved post &#39;Spec Feedback Child&#39;\./, 'and says what was saved' );
  871. # Nothing to navigate on to, so the banner must not bounce us anywhere.
  872. unlike( $body, qr/window\.location=/, 'and does not redirect onwards' );
  873. # A rejected save comes back the same way, in red.
  874. $res = Trog::Routes::HTML::post_save(
  875. _admin(
  876. route => '/post/save',
  877. method => 'POST',
  878. to => '/specblog',
  879. form => 'blog.tx',
  880. title => 'Spec Rejected Child',
  881. visibility => 'not-a-visibility',
  882. tags => ['specblog'],
  883. )
  884. );
  885. ( $code, $headers ) = @$res;
  886. %h = @{ $headers || [] };
  887. is( $code, 303, 'a rejected save redirects too, rather than dead-ending on a 400' );
  888. like( $h{Location}, qr{^/secure/specblog\?savefailed=}, 'flagged as failed' );
  889. like( URI::Escape::uri_unescape( $h{Location} ), qr/Not in enum list/, 'carrying the validator error' );
  890. ok( !_find_post('Spec Rejected Child'), 'and the bad post was not written' );
  891. ($qs) = $h{Location} =~ m/\?(.*)$/;
  892. %params = map { my ( $k, $v ) = split( /=/, $_, 2 ); ( $k => URI::Escape::uri_unescape($v) ) } split( /&/, $qs );
  893. ( $rcode, $body, $err ) = _render(
  894. _admin( route => '/specblog', has_query => 1, %params ),
  895. \&Trog::Routes::HTML::series,
  896. );
  897. is( $rcode, 200, 'the destination still renders' ) or diag($err);
  898. like( $body, qr/var loginFailure = 1;/, 'the banner is in failure mode' );
  899. like( $body, qr/Not in enum list/, 'and shows why' );
  900. };
  901. subtest 'the topbar did not silently overflow' => sub {
  902. my ( $code, $body, $err ) = _render( _anon( route => '/' ) );
  903. is( $code, 200, '/ renders' ) or diag($err);
  904. my @titles = _topbar_titles($body);
  905. # 2 seeded series (Series, About) + the 7 we built. _get_series() caps at
  906. # 10 (HTML.pm), so an eighth post type would start silently dropping one.
  907. is( scalar @titles, 9, 'every series we made is in the topbar' )
  908. or diag( 'topbar was: ' . join( ', ', @titles ) );
  909. cmp_ok( scalar @titles, '<=', 10, 'and we are not at the _get_series limit' );
  910. };
  911. subtest 'the tpsgi side effects stayed inert' => sub {
  912. cmp_ok( $TPSGI->{restarts}, '>', 0, 'post_save did try to signal a restart' );
  913. cmp_ok( $TPSGI->{callbacks}, '>', 0, 'and did queue post-close callbacks' );
  914. # We never run the queued callbacks, which is what keeps save_render and
  915. # invalidate_renders from ever firing.
  916. is_deeply( $TPSGI->{renders}, {}, 'no static renders were written' );
  917. is_deeply( $TPSGI->{invalidated}, [], 'and nothing was invalidated' );
  918. my @statics = glob('www/statics/*');
  919. is_deeply( \@statics, [], 'www/statics is empty' );
  920. };
  921. subtest 'the real repo was not touched' => sub {
  922. my %after = map { _snapshot("$REPO/$_") } qw{config data www/templates www/assets logs totp};
  923. my @changed = grep { ( $REPO_BEFORE{$_} // '' ) ne ( $after{$_} // '' ) } keys %after;
  924. my @removed = grep { !exists $after{$_} } keys %REPO_BEFORE;
  925. is_deeply( [ sort @changed ], [], 'no repo file was added or modified' );
  926. is_deeply( [ sort @removed ], [], 'and none were removed' );
  927. ok( !-e "$REPO/www/templates/html/components/forms/spec_widget.tx", 'the wizard type is not in the repo' );
  928. };
  929. done_testing();