feat: add PHPUnit test infrastructure and Router tests
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/)
This commit is contained in:
greggy
2026-05-22 19:02:02 +00:00
parent a170afa7c0
commit 57b97b5069
11 changed files with 2544 additions and 0 deletions

349
tests/Core/RouterTest.php Normal file
View File

@@ -0,0 +1,349 @@
<?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';
}
}