router = new TestableRouter(); } // ────────────────────────────────────────────── // addRoute() // ────────────────────────────────────────────── public function testAddsRouteWithDefaultAction(): void { $this->router->addRoute('/about', 'App\\Controllers\\AboutController'); $this->assertEquals([ 'controller' => 'App\\Controllers\\AboutController', 'action' => 'index', ], $this->router->getLastRoute('/about')); } public function testAddsRouteWithCustomAction(): void { $this->router->addRoute('/contact', 'App\\Controllers\\ContactController', 'send'); $this->assertEquals([ 'controller' => 'App\\Controllers\\ContactController', 'action' => 'send', ], $this->router->getLastRoute('/contact')); } public function testOverwritesExistingRoute(): void { $this->router->addRoute('/page', 'App\\Controllers\\OldController'); $this->router->addRoute('/page', 'App\\Controllers\\NewController'); $this->assertEquals( 'App\\Controllers\\NewController', $this->router->getLastRoute('/page')['controller'] ); } // ────────────────────────────────────────────── // dispatch() – URL normalization // ────────────────────────────────────────────── public function testDispatchStripsQueryString(): void { $this->router->addRoute('/home', 'Tests\\Core\\DummyController', 'greet'); $this->router->dispatch('/home?foo=bar'); $this->assertTrue($this->router->hasDispatched()); $this->assertEquals('Tests\\Core\\DummyController', $this->router->getLastController()); } public function testDispatchStripsTrailingSlash(): void { $this->router->addRoute('/home', 'Tests\\Core\\DummyController', 'greet'); $this->router->dispatch('/home/'); $this->assertTrue($this->router->hasDispatched()); $this->assertEquals('Tests\\Core\\DummyController', $this->router->getLastController()); } public function testDispatchNormalizesRootPath(): void { $this->router->addRoute('/', 'Tests\\Core\\DummyController', 'greet'); $this->router->dispatch('/'); $this->assertTrue($this->router->hasDispatched()); } // ────────────────────────────────────────────── // dispatch() – direct match // ────────────────────────────────────────────── public function testDispatchExecutesMatchingRoute(): void { $this->router->addRoute('/home', 'Tests\\Core\\DummyController', 'greet'); $this->router->dispatch('/home'); $this->assertTrue($this->router->hasDispatched()); $this->assertEquals('Tests\\Core\\DummyController', $this->router->getLastController()); $this->assertEquals('greet', $this->router->getLastAction()); } public function testDispatchExecutesCustomAction(): void { $this->router->addRoute('/dummy', 'Tests\\Core\\DummyController', 'greet'); $this->router->dispatch('/dummy'); $this->assertEquals('greet', $this->router->getLastAction()); } // ────────────────────────────────────────────── // dispatch() – legacy .html redirect // ────────────────────────────────────────────── public function testDispatchRedirectsLegacyImpressumHtml(): void { $this->router->dispatch('/impressum.html'); $this->assertTrue($this->router->hasRedirected()); $this->assertEquals(301, $this->router->getLastStatusCode()); $this->assertEquals('Location: /impressum', $this->router->getLastHeader()); } public function testDispatchRedirectsLegacyDatenschutzHtml(): void { $this->router->dispatch('/datenschutz.html'); $this->assertTrue($this->router->hasRedirected()); $this->assertEquals(301, $this->router->getLastStatusCode()); $this->assertEquals('Location: /datenschutz', $this->router->getLastHeader()); } #[DataProvider('provideNonMatchingLegacyPaths')] public function testDispatchDoesNotRedirectNonMatchingLegacyPaths(string $path): void { $this->router->dispatch($path); $this->assertFalse($this->router->hasRedirected()); } public static function provideNonMatchingLegacyPaths(): array { return [ '/impressum.htm' => ['/impressum.htm'], '/kontakt.html' => ['/kontakt.html'], '/about.html' => ['/about.html'], '/impressum.html.bak' => ['/impressum.html.bak'], ]; } // ────────────────────────────────────────────── // dispatch() – 404 // ────────────────────────────────────────────── public function testDispatchReturns404ForUnknownPath(): void { $this->router->dispatch('/nonexistent'); $this->assertFalse($this->router->hasDispatched()); $this->assertFalse($this->router->hasRedirected()); $this->assertStringContainsString('404', $this->router->getOutput()); } // ────────────────────────────────────────────── // dispatch() – controller not found // ────────────────────────────────────────────── public function testDispatchThrowsWhenControllerDoesNotExist(): void { $this->router->addRoute('/broken', 'App\\Controllers\\NonExistentController'); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage( 'Controller App\\Controllers\\NonExistentController nicht gefunden.' ); $this->router->dispatch('/broken'); } // ────────────────────────────────────────────── // dispatch() – action not found // ────────────────────────────────────────────── public function testDispatchThrowsWhenActionDoesNotExist(): void { $this->router->addRoute('/broken', 'Tests\\Core\\DummyController', 'nonExistentAction'); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage( 'Action nonExistentAction in Tests\\Core\\DummyController nicht gefunden.' ); $this->router->dispatch('/broken'); } // ────────────────────────────────────────────── // dispatch() – successful controller invocation // ────────────────────────────────────────────── public function testDispatchInvokesControllerAction(): void { $this->router->addRoute('/dummy', 'Tests\\Core\\DummyController', 'greet'); $this->router->dispatch('/dummy'); $this->assertTrue($this->router->hasDispatched()); $this->assertStringContainsString('hello-from-dummy', $this->router->getOutput()); } } // ────────────────────────────────────────────────────────── // Test helpers – not part of the application // ────────────────────────────────────────────────────────── /** * Subclass of Router that intercepts side-effects (header, exit, echo) * so they can be asserted in tests. */ class TestableRouter extends Router { private bool $dispatched = false; private bool $redirected = false; private string $lastController = ''; private string $lastAction = ''; private int $lastStatusCode = 200; private string $lastHeader = ''; private string $output = ''; public function getLastRoute(string $path): ?array { $ref = new \ReflectionClass(Router::class); $prop = $ref->getProperty('routes'); $routes = $prop->getValue($this); return $routes[$path] ?? null; } public function hasDispatched(): bool { return $this->dispatched; } public function hasRedirected(): bool { return $this->redirected; } public function getLastController(): string { return $this->lastController; } public function getLastAction(): string { return $this->lastAction; } public function getLastStatusCode(): int { return $this->lastStatusCode; } public function getLastHeader(): string { return $this->lastHeader; } public function getOutput(): string { return $this->output; } protected function execute(array $route): void { $controllerClass = $route['controller']; $action = $route['action']; if (!class_exists($controllerClass)) { throw new \RuntimeException( "Controller {$controllerClass} nicht gefunden." ); } $controller = new $controllerClass(); if (!method_exists($controller, $action)) { throw new \RuntimeException( "Action {$action} in {$controllerClass} nicht gefunden." ); } $this->lastController = $controllerClass; $this->lastAction = $action; ob_start(); $controller->$action(); $this->output = ob_get_clean(); $this->dispatched = true; } public function dispatch(string $uri): void { $path = parse_url($uri, \PHP_URL_PATH); $path = rtrim($path, '/') ?: '/'; if (isset($this->getRoutes()[$path])) { $this->execute($this->getRoutes()[$path]); return; } if (preg_match('#^/(impressum|datenschutz)\.html$#', $path, $m)) { $this->lastHeader = 'Location: /' . $m[1]; $this->lastStatusCode = 301; $this->redirected = true; return; } $this->lastStatusCode = 404; $this->output = '