* 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',
'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',
'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',
'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',
'oidcConfigured' => true,
'localAdminEnabled' => true,
'authError' => false,
'sprintRows' => [],
]);
self::assertStringContainsString('Sign in with Microsoft', $html);
// R01-N02: the Runtime panel must not leak PHP_VERSION,
// dbPath, schema version, OIDC/local-admin flags to anonymous visitors.
self::assertStringNotContainsString('Runtime', $html);
self::assertStringNotContainsString('Schema version', $html);
self::assertStringNotContainsString('/var/data/app.sqlite', $html);
self::assertStringNotContainsString(PHP_VERSION, $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);
}
}