All checks were successful
Deploy Feature Branch to Test / deploy (push) Successful in 28s
Lint / PHP Syntax Check (push) Successful in 36s
Lint / CSS Lint (stylelint) (push) Successful in 1m18s
Lint / HTML Lint (htmlhint) (push) Successful in 1m11s
Lint / PHP Syntax Check (pull_request) Successful in 37s
Lint / CSS Lint (stylelint) (pull_request) Successful in 1m20s
Lint / HTML Lint (htmlhint) (pull_request) Successful in 1m13s
- Add composer.json with PHPUnit 11 and PSR-4 autoloading - Add phpunit.xml configuration - Rename app/core/ → app/Core/ and app/controllers/ → app/Controllers/ (PSR-4) - Add 18 unit tests for App\Core\Router (31 assertions) - addRoute(): default action, custom action, overwrite - dispatch(): URL normalization, direct match, legacy redirects - dispatch(): 404 handling, controller/action not found exceptions - TestableRouter subclass to intercept side-effects - Update .gitignore (vendor/, .phpunit.cache/)
350 lines
12 KiB
PHP
350 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Tests\Core;
|
||
|
||
use App\Core\Router;
|
||
use PHPUnit\Framework\Attributes\DataProvider;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/**
|
||
* Tests for App\Core\Router.
|
||
*
|
||
* Assumptions:
|
||
* - The Router dispatches to controllers via instantiation and method calls.
|
||
* - header(), http_response_code(), exit() are global side-effects and cannot
|
||
* be directly asserted. We use a testable subclass to intercept them.
|
||
*/
|
||
class RouterTest extends TestCase
|
||
{
|
||
private TestableRouter $router;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->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 = '<h1>404 – Seite nicht gefunden</h1>';
|
||
$this->output .= '<p><a href="/">Zurück zur Startseite</a></p>';
|
||
}
|
||
|
||
private function getRoutes(): array
|
||
{
|
||
$ref = new \ReflectionClass(Router::class);
|
||
$prop = $ref->getProperty('routes');
|
||
return $prop->getValue($this);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Minimal controller for testing dispatch.
|
||
*/
|
||
class DummyController
|
||
{
|
||
public function greet(): void
|
||
{
|
||
echo 'hello-from-dummy';
|
||
}
|
||
}
|