TwigViewTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Tests\Http;
  12. use App\Http\View;
  13. use App\Tests\TestCase;
  14. use ReflectionClass;
  15. use ReflectionProperty;
  16. /**
  17. * Smoke tests for the Twig-backed View. Phase 19 swapped the bare-PHP renderer
  18. * for Twig 3 — these confirm that the controllers' (name, $data) call shape
  19. * still produces HTML containing the markers each page is expected to carry.
  20. */
  21. final class TwigViewTest extends TestCase
  22. {
  23. private View $view;
  24. protected function setUp(): void
  25. {
  26. parent::setUp();
  27. $this->view = new View(__DIR__ . '/../../views');
  28. }
  29. private function makeUser(bool $isAdmin = true): object
  30. {
  31. $u = new \stdClass();
  32. $u->id = 1;
  33. $u->email = 'alice@example.com';
  34. $u->displayName = 'Alice';
  35. $u->isAdmin = $isAdmin;
  36. return $u;
  37. }
  38. public function testHomeRendersForSignedInAdmin(): void
  39. {
  40. $html = $this->view->render('home', [
  41. 'title' => 'Sprint Planner',
  42. 'csrfToken' => 'tok',
  43. 'currentUser' => $this->makeUser(true),
  44. 'schemaVersion' => 3,
  45. 'dbPath' => '/tmp/x',
  46. 'appEnv' => 'production',
  47. 'appVersion' => '0.0.0-test',
  48. 'appCreator' => 'Test Creator',
  49. 'oidcConfigured' => false,
  50. 'localAdminEnabled' => true,
  51. 'authError' => false,
  52. 'sprintRows' => [],
  53. ]);
  54. self::assertStringContainsString('<title>Sprint Planner</title>', $html);
  55. self::assertStringContainsString('Sprints', $html);
  56. self::assertStringContainsString('Alice', $html); // displayName
  57. self::assertStringContainsString('admin', $html); // admin badge
  58. self::assertStringContainsString('/auth/logout', $html);
  59. // Phase 19: vendored Alpine + htmx + sortable scripts must be linked.
  60. self::assertStringContainsString('/assets/js/vendor/alpine-csp.min.js', $html);
  61. self::assertStringContainsString('/assets/js/vendor/htmx.min.js', $html);
  62. }
  63. public function testHomeRendersDeletedSprintFlashWhenSet(): void
  64. {
  65. // R01-N26: the green "Sprint X was deleted" chip is now driven by
  66. // the `deletedSprintName` view variable (sourced from a one-shot
  67. // session flash, not from `?deleted=`). Pin that the chip appears
  68. // for any non-empty string and that the name is HTML-escaped.
  69. $html = $this->view->render('home', [
  70. 'title' => 'Sprint Planner',
  71. 'csrfToken' => 'tok',
  72. 'currentUser' => $this->makeUser(true),
  73. 'schemaVersion' => 3,
  74. 'dbPath' => '/tmp/x',
  75. 'appEnv' => 'production',
  76. 'appVersion' => '0.0.0-test',
  77. 'appCreator' => 'Test Creator',
  78. 'oidcConfigured' => false,
  79. 'localAdminEnabled' => true,
  80. 'authError' => false,
  81. 'deletedSprintName' => 'Sprint <Alpha>',
  82. 'sprintRows' => [],
  83. ]);
  84. self::assertStringContainsString('was deleted', $html);
  85. self::assertStringContainsString('Sprint &lt;Alpha&gt;', $html);
  86. self::assertStringNotContainsString(
  87. '<b>Sprint <Alpha></b>',
  88. $html,
  89. 'sprint name must be Twig-autoescaped — never rendered raw',
  90. );
  91. }
  92. public function testHomeOmitsDeletedSprintFlashWhenEmpty(): void
  93. {
  94. $html = $this->view->render('home', [
  95. 'title' => 'Sprint Planner',
  96. 'csrfToken' => 'tok',
  97. 'currentUser' => $this->makeUser(true),
  98. 'schemaVersion' => 3,
  99. 'dbPath' => '/tmp/x',
  100. 'appEnv' => 'production',
  101. 'appVersion' => '0.0.0-test',
  102. 'appCreator' => 'Test Creator',
  103. 'oidcConfigured' => false,
  104. 'localAdminEnabled' => true,
  105. 'authError' => false,
  106. 'deletedSprintName' => '',
  107. 'sprintRows' => [],
  108. ]);
  109. self::assertStringNotContainsString('was deleted', $html);
  110. }
  111. public function testHomeForAnonymousUserHidesRuntimePanel(): void
  112. {
  113. $html = $this->view->render('home', [
  114. 'title' => 'Sprint Planner',
  115. 'csrfToken' => 'tok',
  116. 'currentUser' => null,
  117. 'schemaVersion' => 3,
  118. 'dbPath' => '/var/data/app.sqlite',
  119. 'appEnv' => 'production',
  120. 'appVersion' => '0.0.0-test',
  121. 'appCreator' => 'Test Creator',
  122. 'oidcConfigured' => true,
  123. 'localAdminEnabled' => true,
  124. 'authError' => false,
  125. 'sprintRows' => [],
  126. ]);
  127. self::assertStringContainsString('Sign in with Microsoft', $html);
  128. // R01-N02: the Runtime <details> panel must not leak app metadata,
  129. // dbPath, schema version, or OIDC/local-admin flags to anonymous
  130. // visitors. PHP version was removed from the panel entirely; the
  131. // app-version + creator strings now live there in its place.
  132. self::assertStringNotContainsString('Runtime', $html);
  133. self::assertStringNotContainsString('Schema version', $html);
  134. self::assertStringNotContainsString('/var/data/app.sqlite', $html);
  135. self::assertStringNotContainsString('0.0.0-test', $html);
  136. self::assertStringNotContainsString('Test Creator', $html);
  137. // R01-N31 falls out of the same gate: no /healthz hint either.
  138. self::assertStringNotContainsString('/healthz', $html);
  139. }
  140. public function testAuditRendersWithEmptyResults(): void
  141. {
  142. $html = $this->view->render('audit/index', [
  143. 'title' => 'Audit',
  144. 'csrfToken' => 'tok',
  145. 'currentUser' => $this->makeUser(true),
  146. 'filters' => [
  147. 'user_email' => '',
  148. 'action' => '',
  149. 'entity_type' => '',
  150. 'entity_id' => '',
  151. 'from_date' => '',
  152. 'to_date' => '',
  153. ],
  154. 'page' => 1,
  155. 'pages' => 1,
  156. 'pageSize' => 50,
  157. 'total' => 0,
  158. 'rows' => [],
  159. 'actions' => ['CREATE', 'UPDATE', 'DELETE'],
  160. 'entityTypes' => ['worker', 'sprint'],
  161. 'users' => [],
  162. ]);
  163. self::assertStringContainsString('Audit log', $html);
  164. self::assertStringContainsString('No audit rows match', $html);
  165. // hx-boost wired on the filter form (Phase 19).
  166. self::assertStringContainsString('hx-boost="true"', $html);
  167. }
  168. public function testSprintsShowRendersTaskGridAndStatusFilter(): void
  169. {
  170. $sprint = (new ReflectionClass(\App\Domain\Sprint::class))->newInstanceWithoutConstructor();
  171. foreach ([
  172. 'id' => 1, 'name' => 'S1',
  173. 'startDate' => '2026-01-01', 'endDate' => '2026-01-15',
  174. 'reserveFraction' => 0.2, 'isArchived' => false, 'createdAt' => '2026-01-01',
  175. ] as $k => $v) {
  176. (new ReflectionProperty($sprint, $k))->setValue($sprint, $v);
  177. }
  178. $weekClass = new ReflectionClass(\App\Domain\SprintWeek::class);
  179. $w = $weekClass->newInstanceWithoutConstructor();
  180. foreach ([
  181. 'id' => 10, 'sprintId' => 1, 'isoWeek' => 1,
  182. 'startDate' => '2026-01-05', 'sortOrder' => 1,
  183. 'maxWorkingDays' => 5, 'activeDaysMask' => 31,
  184. ] as $k => $v) {
  185. (new ReflectionProperty($w, $k))->setValue($w, $v);
  186. }
  187. $swClass = new ReflectionClass(\App\Domain\SprintWorker::class);
  188. $sw = $swClass->newInstanceWithoutConstructor();
  189. foreach ([
  190. 'id' => 100, 'sprintId' => 1, 'workerId' => 50, 'workerName' => 'Bob',
  191. 'sortOrder' => 1, 'rtb' => 0.1,
  192. ] as $k => $v) {
  193. (new ReflectionProperty($sw, $k))->setValue($sw, $v);
  194. }
  195. $html = $this->view->render('sprints/show', [
  196. 'title' => 'S1',
  197. 'csrfToken' => 'tok',
  198. 'currentUser' => $this->makeUser(true),
  199. 'sprint' => $sprint,
  200. 'weeks' => [$w],
  201. 'sprintWorkers' => [$sw],
  202. 'grid' => [100 => [10 => 2.5]],
  203. 'capacity' => [100 => [
  204. 'ressourcen' => 2.5,
  205. 'after_reserves' => 2.0,
  206. 'committed_prio1' => 0.0,
  207. 'available' => 2.0,
  208. ]],
  209. 'tasks' => [],
  210. 'taskGrid' => [],
  211. 'statusGrid' => [],
  212. 'ownerChoices' => [],
  213. 'taskStatusEnabled' => true,
  214. ]);
  215. self::assertStringContainsString('data-sprint-root', $html);
  216. self::assertStringContainsString('data-sprint-id="1"', $html);
  217. self::assertStringContainsString('data-task-status-enabled="1"', $html);
  218. self::assertStringContainsString('Status', $html); // status filter visible
  219. self::assertStringContainsString('No tasks yet', $html);
  220. // Phase 19: page-specific JS still loaded.
  221. self::assertStringContainsString('/assets/js/sprint-planner.js', $html);
  222. // R02-N02: + Add task clones a server-rendered <template> shell;
  223. // it must be present whenever the admin can hit the button. The
  224. // template re-uses _task_row.twig so JS-built rows can never drift
  225. // from the for-loop rows above. Pin a few load-bearing markers from
  226. // inside the template body — drift here breaks live task creation.
  227. self::assertStringContainsString('<template data-task-row-template>', $html);
  228. self::assertStringContainsString('data-owner-select', $html);
  229. self::assertStringContainsString('data-prio-select', $html);
  230. self::assertStringContainsString('data-task-menu-trigger', $html);
  231. self::assertStringContainsString('data-col="sw-100"', $html);
  232. }
  233. public function testSprintsShowOmitsTaskRowTemplateForReadonlyUser(): void
  234. {
  235. // Non-admins cannot click "+ Add task", so the template (which
  236. // contains admin-only inputs) must not be served — keeps the rest
  237. // of the page lighter and rules out any chance of cloned-but-orphan
  238. // editor inputs being injected client-side.
  239. $sprint = (new ReflectionClass(\App\Domain\Sprint::class))->newInstanceWithoutConstructor();
  240. foreach ([
  241. 'id' => 1, 'name' => 'S1',
  242. 'startDate' => '2026-01-01', 'endDate' => '2026-01-15',
  243. 'reserveFraction' => 0.2, 'isArchived' => false, 'createdAt' => '2026-01-01',
  244. ] as $k => $v) {
  245. (new ReflectionProperty($sprint, $k))->setValue($sprint, $v);
  246. }
  247. $weekClass = new ReflectionClass(\App\Domain\SprintWeek::class);
  248. $w = $weekClass->newInstanceWithoutConstructor();
  249. foreach ([
  250. 'id' => 10, 'sprintId' => 1, 'isoWeek' => 1,
  251. 'startDate' => '2026-01-05', 'sortOrder' => 1,
  252. 'maxWorkingDays' => 5, 'activeDaysMask' => 31,
  253. ] as $k => $v) {
  254. (new ReflectionProperty($w, $k))->setValue($w, $v);
  255. }
  256. $swClass = new ReflectionClass(\App\Domain\SprintWorker::class);
  257. $sw = $swClass->newInstanceWithoutConstructor();
  258. foreach ([
  259. 'id' => 100, 'sprintId' => 1, 'workerId' => 50, 'workerName' => 'Bob',
  260. 'sortOrder' => 1, 'rtb' => 0.1,
  261. ] as $k => $v) {
  262. (new ReflectionProperty($sw, $k))->setValue($sw, $v);
  263. }
  264. $html = $this->view->render('sprints/show', [
  265. 'title' => 'S1',
  266. 'csrfToken' => 'tok',
  267. 'currentUser' => $this->makeUser(false),
  268. 'sprint' => $sprint,
  269. 'weeks' => [$w],
  270. 'sprintWorkers' => [$sw],
  271. 'grid' => [100 => [10 => 2.5]],
  272. 'capacity' => [100 => [
  273. 'ressourcen' => 2.5,
  274. 'after_reserves' => 2.0,
  275. 'committed_prio1' => 0.0,
  276. 'available' => 2.0,
  277. ]],
  278. 'tasks' => [],
  279. 'taskGrid' => [],
  280. 'statusGrid' => [],
  281. 'ownerChoices' => [],
  282. 'taskStatusEnabled' => true,
  283. ]);
  284. self::assertStringNotContainsString('<template data-task-row-template>', $html);
  285. self::assertStringNotContainsString('+ Add task', $html);
  286. }
  287. }