* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Tests\Http; use App\Http\View; use App\Tests\TestCase; use ReflectionClass; use ReflectionProperty; /** * Smoke tests for the Twig-backed View. Phase 19 swapped the bare-PHP renderer * for Twig 3 — these confirm that the controllers' (name, $data) call shape * still produces HTML containing the markers each page is expected to carry. */ final class TwigViewTest extends TestCase { private View $view; protected function setUp(): void { parent::setUp(); $this->view = new View(__DIR__ . '/../../views'); } private function makeUser(bool $isAdmin = true): object { $u = new \stdClass(); $u->id = 1; $u->email = 'alice@example.com'; $u->displayName = 'Alice'; $u->isAdmin = $isAdmin; return $u; } public function testHomeRendersForSignedInAdmin(): void { $html = $this->view->render('home', [ 'title' => 'Sprint Planner', 'csrfToken' => 'tok', 'currentUser' => $this->makeUser(true), 'schemaVersion' => 3, 'dbPath' => '/tmp/x', 'appEnv' => 'production', 'appVersion' => '0.0.0-test', 'appCreator' => 'Test Creator', 'oidcConfigured' => false, 'localAdminEnabled' => true, 'authError' => false, 'sprintRows' => [], ]); self::assertStringContainsString('Sprint Planner', $html); self::assertStringContainsString('Sprints', $html); self::assertStringContainsString('Alice', $html); // displayName self::assertStringContainsString('admin', $html); // admin badge self::assertStringContainsString('/auth/logout', $html); // Phase 19: vendored Alpine + htmx + sortable scripts must be linked. self::assertStringContainsString('/assets/js/vendor/alpine-csp.min.js', $html); self::assertStringContainsString('/assets/js/vendor/htmx.min.js', $html); } public function testHomeRendersDeletedSprintFlashWhenSet(): void { // R01-N26: the green "Sprint X was deleted" chip is now driven by // the `deletedSprintName` view variable (sourced from a one-shot // session flash, not from `?deleted=`). Pin that the chip appears // for any non-empty string and that the name is HTML-escaped. $html = $this->view->render('home', [ 'title' => 'Sprint Planner', 'csrfToken' => 'tok', 'currentUser' => $this->makeUser(true), 'schemaVersion' => 3, 'dbPath' => '/tmp/x', 'appEnv' => 'production', 'appVersion' => '0.0.0-test', 'appCreator' => 'Test Creator', 'oidcConfigured' => false, 'localAdminEnabled' => true, 'authError' => false, 'deletedSprintName' => 'Sprint ', 'sprintRows' => [], ]); self::assertStringContainsString('was deleted', $html); self::assertStringContainsString('Sprint <Alpha>', $html); self::assertStringNotContainsString( 'Sprint ', $html, 'sprint name must be Twig-autoescaped — never rendered raw', ); } public function testHomeOmitsDeletedSprintFlashWhenEmpty(): void { $html = $this->view->render('home', [ 'title' => 'Sprint Planner', 'csrfToken' => 'tok', 'currentUser' => $this->makeUser(true), 'schemaVersion' => 3, 'dbPath' => '/tmp/x', 'appEnv' => 'production', 'appVersion' => '0.0.0-test', 'appCreator' => 'Test Creator', 'oidcConfigured' => false, 'localAdminEnabled' => true, 'authError' => false, 'deletedSprintName' => '', 'sprintRows' => [], ]); self::assertStringNotContainsString('was deleted', $html); } public function testHomeForAnonymousUserHidesRuntimePanel(): void { $html = $this->view->render('home', [ 'title' => 'Sprint Planner', 'csrfToken' => 'tok', 'currentUser' => null, 'schemaVersion' => 3, 'dbPath' => '/var/data/app.sqlite', 'appEnv' => 'production', 'appVersion' => '0.0.0-test', 'appCreator' => 'Test Creator', 'oidcConfigured' => true, 'localAdminEnabled' => true, 'authError' => false, 'sprintRows' => [], ]); self::assertStringContainsString('Sign in with Microsoft', $html); // R01-N02: the Runtime
panel must not leak app metadata, // dbPath, schema version, or OIDC/local-admin flags to anonymous // visitors. PHP version was removed from the panel entirely; the // app-version + creator strings now live there in its place. self::assertStringNotContainsString('Runtime', $html); self::assertStringNotContainsString('Schema version', $html); self::assertStringNotContainsString('/var/data/app.sqlite', $html); self::assertStringNotContainsString('0.0.0-test', $html); self::assertStringNotContainsString('Test Creator', $html); // R01-N31 falls out of the same gate: no /healthz hint either. self::assertStringNotContainsString('/healthz', $html); } public function testAuditRendersWithEmptyResults(): void { $html = $this->view->render('audit/index', [ 'title' => 'Audit', 'csrfToken' => 'tok', 'currentUser' => $this->makeUser(true), 'filters' => [ 'user_email' => '', 'action' => '', 'entity_type' => '', 'entity_id' => '', 'from_date' => '', 'to_date' => '', ], 'page' => 1, 'pages' => 1, 'pageSize' => 50, 'total' => 0, 'rows' => [], 'actions' => ['CREATE', 'UPDATE', 'DELETE'], 'entityTypes' => ['worker', 'sprint'], 'users' => [], ]); self::assertStringContainsString('Audit log', $html); self::assertStringContainsString('No audit rows match', $html); // hx-boost wired on the filter form (Phase 19). self::assertStringContainsString('hx-boost="true"', $html); } public function testSprintsShowRendersTaskGridAndStatusFilter(): void { $sprint = (new ReflectionClass(\App\Domain\Sprint::class))->newInstanceWithoutConstructor(); foreach ([ 'id' => 1, 'name' => 'S1', 'startDate' => '2026-01-01', 'endDate' => '2026-01-15', 'reserveFraction' => 0.2, 'isArchived' => false, 'createdAt' => '2026-01-01', ] as $k => $v) { (new ReflectionProperty($sprint, $k))->setValue($sprint, $v); } $weekClass = new ReflectionClass(\App\Domain\SprintWeek::class); $w = $weekClass->newInstanceWithoutConstructor(); foreach ([ 'id' => 10, 'sprintId' => 1, 'isoWeek' => 1, 'startDate' => '2026-01-05', 'sortOrder' => 1, 'maxWorkingDays' => 5, 'activeDaysMask' => 31, ] as $k => $v) { (new ReflectionProperty($w, $k))->setValue($w, $v); } $swClass = new ReflectionClass(\App\Domain\SprintWorker::class); $sw = $swClass->newInstanceWithoutConstructor(); foreach ([ 'id' => 100, 'sprintId' => 1, 'workerId' => 50, 'workerName' => 'Bob', 'sortOrder' => 1, 'rtb' => 0.1, ] as $k => $v) { (new ReflectionProperty($sw, $k))->setValue($sw, $v); } $html = $this->view->render('sprints/show', [ 'title' => 'S1', 'csrfToken' => 'tok', 'currentUser' => $this->makeUser(true), 'sprint' => $sprint, 'weeks' => [$w], 'sprintWorkers' => [$sw], 'grid' => [100 => [10 => 2.5]], 'capacity' => [100 => [ 'ressourcen' => 2.5, 'after_reserves' => 2.0, 'committed_prio1' => 0.0, 'available' => 2.0, ]], 'tasks' => [], 'taskGrid' => [], 'statusGrid' => [], 'ownerChoices' => [], 'taskStatusEnabled' => true, ]); self::assertStringContainsString('data-sprint-root', $html); self::assertStringContainsString('data-sprint-id="1"', $html); self::assertStringContainsString('data-task-status-enabled="1"', $html); self::assertStringContainsString('Status', $html); // status filter visible self::assertStringContainsString('No tasks yet', $html); // Phase 19: page-specific JS still loaded. self::assertStringContainsString('/assets/js/sprint-planner.js', $html); } }