Trog-Component-page.t 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. #!/usr/bin/env perl
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use Test::More;
  6. use Test::Fatal qw{exception};
  7. use Test::MockModule qw{strict};
  8. use FindBin;
  9. use FindBin::libs;
  10. # The components render real templates through the real renderer, which resolves
  11. # them relative to the tCMS root. Xslate also computes its cache dir from $HOME
  12. # at load time, so point that somewhere disposable before it is loaded.
  13. my $ROOT;
  14. BEGIN {
  15. require File::Temp;
  16. $ROOT = File::Temp::tempdir( 'tcms-component-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  17. $ENV{HOME} = $ROOT;
  18. chdir("$FindBin::Bin/..") or die "could not chdir to the tCMS root: $!";
  19. }
  20. require_ok('Trog::Component') or BAIL_OUT("Can't find SUT");
  21. require_ok('Trog::Component::Header') or BAIL_OUT("Can't find SUT");
  22. require_ok('Trog::Component::PostHeader') or BAIL_OUT("Can't find SUT");
  23. subtest 'the furniture with no arguments' => sub {
  24. # These six are empty or a stock placeholder, and read no variables at all.
  25. # The test is that each resolves its own template and comes back with a
  26. # string rather than dying -- which is the whole of what index.tx asks of it.
  27. foreach my $name (qw{HtmlTitle MidTitle TopBar LeftBar RightBar FootBar Footer}) {
  28. my $out = Trog::Component::component($name);
  29. is( exception { Trog::Component::component($name) }, undef, "$name renders" );
  30. isa_ok( $out, 'Text::Xslate::Type::Raw', "$name comes back marked raw" );
  31. }
  32. like( Trog::Component::component('HtmlTitle'), qr/tCMS/, "HtmlTitle is the site title" );
  33. like( Trog::Component::component('Footer'), qr{</html>}i, "Footer closes the document" );
  34. };
  35. subtest 'Header' => sub {
  36. my $out = Trog::Component::component(
  37. 'Header',
  38. {
  39. title => 'A Page',
  40. route => '/somewhere',
  41. stylesheets => ['/styles/screen.css'],
  42. scripts => ['/scripts/post.js'],
  43. }
  44. );
  45. like( $out, qr/<!doctype[ ]html>/i, "the doctype is emitted by default" );
  46. like( $out, qr{<title>A[ ]Page</title>}, "the title lands" );
  47. like(
  48. Trog::Component::component( 'Header', { title => '<b>x</b>' } ),
  49. qr{<title>&lt;b&gt;x&lt;/b&gt;</title>},
  50. "and is text, not markup"
  51. );
  52. like( $out, qr{href="/styles/screen\.css"}, "stylesheets land" );
  53. like( $out, qr{src="/scripts/post\.js"}, "scripts land" );
  54. like( $out, qr{lang="en-US"}, "lang defaults rather than rendering empty" );
  55. like( $out, qr{href="/somewhere\?format=rss"}, "the rss link uses the route" );
  56. # rss_style() renders into an XSL document, so the doctype has to go.
  57. my $xsl = Trog::Component::component( 'Header', { title => 'RSS', no_doctype => 1 } );
  58. unlike( $xsl, qr/<!doctype/i, "no_doctype suppresses it" );
  59. like(
  60. Trog::Component::component( 'Header', { title => 'RSS', title_select => 'rss/channel/title', no_doctype => 1 } ),
  61. qr{<title><xsl:value-of[ ]select="rss/channel/title"/></title>},
  62. "and title_select makes the title an XSL value"
  63. );
  64. # It also has no stylesheets or scripts to give, and must not invent any.
  65. unlike( $xsl, qr/rel="stylesheet"[ ]type="text\/css"[ ]href="\/styles\/screen/, "no stylesheets are conjured up" );
  66. like(
  67. exception { Trog::Component::component( 'Header', { bogus => 1 } ) },
  68. qr/takes[ ]no[ ]argument[ ]named[ ]bogus/,
  69. "an argument it does not take is fatal"
  70. );
  71. # header.tx marks meta_tags raw, so social markup has to survive as markup.
  72. my $social = Trog::Component::component( 'Header', { meta_tags => '<meta name="twitter:card" />' } );
  73. like( $social, qr{<meta[ ]name="twitter:card"[ ]/>}, "meta_tags stay markup" );
  74. };
  75. subtest 'Header links each icon from the theme or the stock icons' => sub {
  76. my $themes = Test::MockModule->new('Trog::Themes');
  77. $themes->redefine( get_dir => sub { '' } );
  78. my $stock = Trog::Component::component('Header');
  79. like( $stock, qr{href="/img/icon/favicon[.]svg"}, "with no theme, the stock icon is linked from the root of the site" );
  80. unlike( $stock, qr{href="(?!/)[^"]*favicon}, "and no icon has a relative link, which would break below the top level" );
  81. my $original = Trog::Themes->can('themed');
  82. $themes->redefine( themed => sub ($resource) { $resource eq 'img/icon/favicon.svg' ? "www/themes/spec/$resource" : $original->($resource) } );
  83. my $themed = Trog::Component::component('Header');
  84. like( $themed, qr{href="/themes/spec/img/icon/favicon\.svg"}, "an icon that the theme has is linked from the theme" );
  85. like( $themed, qr{href="/img/icon/favicon-48\.png"}, "and one that it has not is linked from the stock icons" );
  86. };
  87. subtest 'CategoryBar' => sub {
  88. my $categories = [ { local_href => '/series/blog', title => 'Blog' } ];
  89. my $out = Trog::Component::component( 'CategoryBar', { categories => $categories } );
  90. like( $out, qr{href="/series/blog"}, "an anonymous reader gets the plain link" );
  91. my $secure = Trog::Component::component( 'CategoryBar', { categories => $categories, user => 'someone' } );
  92. like( $secure, qr{href="/secure/series/blog"}, "a logged in reader gets the secure link" );
  93. is( exception { Trog::Component::component('CategoryBar') }, undef, "no categories is not fatal" );
  94. };
  95. subtest 'PostHeader and PostFooter validate the name' => sub {
  96. # The name comes out of post data, and before this it went straight into a
  97. # path with nothing checking it.
  98. foreach my $bad (qw{../../etc/passwd headers/../../../etc/passwd nope.tx about_header}) {
  99. like(
  100. exception { Trog::Component::component( 'PostHeader', { name => $bad } ) },
  101. qr/No[ ]such[ ]header/,
  102. "'$bad' is refused"
  103. );
  104. }
  105. # An empty name is how a post with no header asks, so it must not be fatal.
  106. is( Trog::Component::component( 'PostHeader', { name => '' } ), '', "no name renders nothing" );
  107. is( Trog::Component::component('PostHeader'), '', "a missing name renders nothing" );
  108. is( Trog::Component::component( 'PostFooter', { name => undef } ), '', "an undef name renders nothing" );
  109. # Whatever the edit form offers has to actually render.
  110. my $available = Trog::Component::PostHeader::available();
  111. ok( scalar(@$available), "there is at least one stock header to try" ) or return;
  112. foreach my $name (@$available) {
  113. is( exception { Trog::Component::component( 'PostHeader', { name => $name } ) }, undef, "$name renders" );
  114. }
  115. # available() is the same list the series edit form is built from.
  116. is_deeply(
  117. $available,
  118. Trog::Themes::themed_templates_in_dir( 'headers', 'text/html', 1 ),
  119. "available() is the list the edit form offers"
  120. );
  121. };
  122. subtest 'the whole page still composes' => sub {
  123. require Trog::Renderer::Base;
  124. # index.tx is a page, not a component, so it resolves in www/templates/html
  125. # rather than the components dir and comes back as a PSGI triplet.
  126. my $psgi = eval {
  127. Trog::Renderer::Base::render(
  128. template => 'index.tx',
  129. contenttype => 'text/html',
  130. code => 200,
  131. data => {
  132. title => 'Composed',
  133. route => '/',
  134. content => '<p>the body</p>',
  135. categories => [ { local_href => '/series/blog', title => 'Blog' } ],
  136. stylesheets => ['/styles/screen.css'],
  137. print_styles => [],
  138. scripts => [],
  139. ( map { $_ => undef } qw{author default_tags embed failure is_admin lang message meta_desc meta_tags search_help search_lang search_route show_madeby to user user_class} ),
  140. },
  141. );
  142. };
  143. my $err = $@;
  144. is( $err, '', "index.tx renders" ) or diag($err);
  145. my $body = ref $psgi eq 'ARRAY' ? $psgi->[2][0] : undef;
  146. like( $body, qr/<!doctype[ ]html>/i, "Header came through" );
  147. like( $body, qr{<title>Composed</title>}, "the title came through" );
  148. like( $body, qr{<p>the[ ]body</p>}, "the content is still the content" );
  149. like( $body, qr{href="/series/blog"}, "CategoryBar came through" );
  150. like( $body, qr{</html>}, "Footer came through" );
  151. # The furniture divs are the bars' sockets; they must still be there even
  152. # though the stock bars render placeholders.
  153. like( $body, qr{id="leftbar"}, "the left bar socket is intact" );
  154. like( $body, qr{id="footbar"}, "the footer bar socket is intact" );
  155. };
  156. done_testing();