feat: PHPUnit Test-Infrastruktur und Router-Tests #64

Merged
greggy merged 2 commits from feature/phpunit-tests into main 2026-05-22 21:33:32 +02:00
12 changed files with 2546 additions and 22 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@
package-lock.json package-lock.json
.continue/ .continue/
.playwright-mcp/ .playwright-mcp/
vendor/
.phpunit.cache/

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\View;
abstract class Controller
{
protected View $view;
public function __construct()
{
$this->view = new View();
}
protected function render(string $view, array $data = [], string $layout = 'main'): void
{
foreach ($data as $key => $value) {
$this->view->assign($key, $value);
}
$this->view->render($view, $layout);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
class DatenschutzController extends Controller
{
public function index(): void
{
$this->render('datenschutz/index', [
'pageTitle' => 'Datenschutzerklärung Haus Schleusingen',
'pageDescription' => 'Datenschutzerklärung der Website haus-schleusingen.de',
'robots' => 'noindex',
'canonical' => 'https://haus-schleusingen.de/datenschutz',
]);
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
class HomeController extends Controller
{
public function index(): void
{
session_start();
// --- Helper functions ---
$normalizeContactValue = function (string $value): string {
return trim($value);
};
$escapeContactValue = function (string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
};
$containsHeaderInjection = function (string $value): bool {
return (bool) preg_match('/[\r\n]/', $value);
};
// --- Form processing ---
$formErrors = [];
$formSuccess = false;
if (!empty($_SESSION['form_success'])) {
$formSuccess = true;
unset($_SESSION['form_success']);
}
if (!empty($_SESSION['form_errors'])) {
$formErrors = $_SESSION['form_errors'];
unset($_SESSION['form_errors']);
}
if (!empty($_SESSION['form_data'])) {
$formData = $_SESSION['form_data'];
unset($_SESSION['form_data']);
} else {
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'Besichtigung anfragen', 'message' => ''];
}
// CSRF-Token generieren (nach Session-Start)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF-Token validieren
$csrfToken = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
header('Location: /#form-result');
$_SESSION['form_errors'] = ['Sicherheitsüberprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.'];
exit;
}
$formData['fname'] = $normalizeContactValue((string) ($_POST['fname'] ?? ''));
$formData['lname'] = $normalizeContactValue((string) ($_POST['lname'] ?? ''));
$formData['email'] = $normalizeContactValue((string) ($_POST['email'] ?? ''));
$formData['phone'] = $normalizeContactValue((string) ($_POST['phone'] ?? ''));
$formData['interest'] = $normalizeContactValue((string) ($_POST['interest'] ?? ''));
$formData['message'] = $normalizeContactValue((string) ($_POST['message'] ?? ''));
$honeypot = $normalizeContactValue((string) ($_POST['website'] ?? ''));
if ($honeypot !== '') {
header('Location: /#form-result');
$_SESSION['form_success'] = true;
exit;
} else {
if ($formData['fname'] === '') {
$formErrors[] = 'Bitte geben Sie Ihren Vornamen an.';
}
if ($formData['lname'] === '') {
$formErrors[] = 'Bitte geben Sie Ihren Nachnamen an.';
}
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
$formErrors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
}
if ($formData['message'] === '') {
$formErrors[] = 'Bitte geben Sie eine Nachricht ein.';
}
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
$formErrors[] = 'Ungültige Zeichen in den Eingabefeldern.';
}
$formTime = isset($_POST['form_time']) ? (int) $_POST['form_time'] : 0;
if ($formTime > 0 && (time() - $formTime) < 3) {
$formErrors[] = 'Das Formular wurde zu schnell abgeschickt. Bitte versuchen Sie es erneut.';
}
$lastSubmit = $_SESSION['last_contact_submit'] ?? 0;
if ($lastSubmit && (time() - $lastSubmit) < 60) {
$formErrors[] = 'Bitte warten Sie einen Moment vor der nächsten Anfrage.';
}
if (empty($formErrors)) {
$to = 'mki@kies-media.de';
$subject = 'Kontaktanfrage: ' . $formData['interest'];
$body = "Von: {$formData['fname']} {$formData['lname']}\n"
. "E-Mail: {$formData['email']}\n";
if ($formData['phone'] !== '') {
$body .= "Telefon: {$formData['phone']}\n";
}
$body .= "Anliegen: {$formData['interest']}\n\n"
. $formData['message'];
$headers = "From: {$formData['email']}\r\n";
$headers .= "Reply-To: {$formData['email']}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$headers .= "X-Mailer: PHP/" . phpversion();
$mailSent = mail($to, $subject, $body, $headers);
if ($mailSent) {
$_SESSION['last_contact_submit'] = time();
header('Location: /#form-result');
$_SESSION['form_success'] = true;
exit;
} else {
$formErrors[] = 'Leider konnte die E-Mail nicht gesendet werden. Bitte versuchen Sie es später erneut oder schreiben Sie uns direkt an mki@kies-media.de.';
}
}
}
if (!empty($formErrors)) {
header('Location: /#form-result');
$_SESSION['form_errors'] = $formErrors;
$_SESSION['form_data'] = $formData;
exit;
}
}
$this->render('home/index', [
'formSuccess' => $formSuccess,
'formErrors' => $formErrors,
'formData' => $formData,
'escapeContactValue' => $escapeContactValue,
'pageTitle' => 'Einfamilienhaus mieten Schleusingen | 227 m², 6 Zimmer | 1.300 € Kaltmiete',
'pageDescription' => 'Einfamilienhaus zur Langzeitmiete in Schleusingen: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €. Bahnhofstraße 10, 98553 Schleusingen. Ab sofort verfügbar.',
'canonical' => 'https://haus-schleusingen.de/',
'openGraph' => [
'ogTitle' => 'Einfamilienhaus zur Miete in Schleusingen 227 m², 6 Zimmer',
'ogDescription' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m², 6 Zimmer, 3 Etagen + Dachterrasse. Kaltmiete 1.300 €. Ab sofort verfügbar in Schleusingen.',
'ogImage' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
'ogUrl' => 'https://haus-schleusingen.de/',
],
'structuredData' => json_encode([
'@context' => 'https://schema.org',
'@type' => 'RealEstateListing',
'name' => 'Einfamilienhaus zur Miete in Schleusingen',
'description' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €.',
'url' => 'https://haus-schleusingen.de/',
'image' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
'datePosted' => '2026-05-14',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => 'Bahnhofstraße 10',
'addressLocality' => 'Schleusingen',
'postalCode' => '98553',
'addressCountry' => 'DE',
],
'offers' => [
'@type' => 'Offer',
'price' => '1300',
'priceCurrency' => 'EUR',
'priceSpecification' => [
'@type' => 'UnitPriceSpecification',
'price' => '1300',
'priceCurrency' => 'EUR',
'unitCode' => 'MON',
'description' => 'Kaltmiete pro Monat',
],
],
'floorSize' => [
'@type' => 'QuantitativeValue',
'value' => '227',
'unitCode' => 'MTK',
],
'numberOfRooms' => [
'@type' => 'QuantitativeValue',
'value' => '6',
],
]),
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
class ImpressumController extends Controller
{
public function index(): void
{
$this->render('impressum/index', [
'pageTitle' => 'Impressum Haus Schleusingen',
'pageDescription' => 'Impressum der Website haus-schleusingen.de',
'robots' => 'noindex',
'canonical' => 'https://haus-schleusingen.de/impressum',
]);
}
}

60
app/Core/Router.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Core;
class Router
{
private array $routes = [];
public function addRoute(string $path, string $controller, string $action = 'index'): void
{
$this->routes[$path] = [
'controller' => $controller,
'action' => $action,
];
}
public function dispatch(string $uri): void
{
// Normalize: strip query string and trailing slash
$path = parse_url($uri, PHP_URL_PATH);
$path = rtrim($path, '/') ?: '/';
// Direct match
if (isset($this->routes[$path])) {
$this->execute($this->routes[$path]);
return;
}
// Legacy .html redirect (301)
if (preg_match('#^/(impressum|datenschutz)\.html$#', $path, $m)) {
header('Location: /' . $m[1], true, 301);
exit;
}
// 404
http_response_code(404);
echo '<h1>404 Seite nicht gefunden</h1>';
echo '<p><a href="/">Zurück zur Startseite</a></p>';
}
private 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.");
}
$controller->$action();
}
}

46
app/Core/View.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Core;
class View
{
private string $viewsPath;
private array $data = [];
public function __construct(?string $viewsPath = null)
{
$this->viewsPath = $viewsPath ?? dirname(__DIR__) . '/views';
}
public function assign(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function render(string $view, string $layout = 'main'): void
{
$viewFile = $this->viewsPath . '/' . $view . '.php';
$layoutFile = $this->viewsPath . '/layouts/' . $layout . '.php';
if (!file_exists($viewFile)) {
throw new \RuntimeException("View {$view} nicht gefunden: {$viewFile}");
}
if (!file_exists($layoutFile)) {
throw new \RuntimeException("Layout {$layout} nicht gefunden: {$layoutFile}");
}
// Extract data to variables for the view
extract($this->data, EXTR_SKIP);
// Capture view content
ob_start();
require $viewFile;
$content = ob_get_clean();
// Render layout with $content
require $layoutFile;
}
}

16
composer.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "greggy/landingpage-haus-schleusingen",
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"require-dev": {
"phpunit/phpunit": "^11.0"
}
}

1800
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
phpunit.xml Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
beStrictAboutOutputDuringTests="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
</phpunit>

24
public/index.php Normal file → Executable file
View File

@@ -7,28 +7,8 @@ declare(strict_types=1);
* All requests are routed through this file. * All requests are routed through this file.
*/ */
// Autoloader (PSR-4 style, simple) // Autoloader (composer PSR-4)
spl_autoload_register(function (string $class): void { require_once __DIR__ . '/../vendor/autoload.php';
$prefix = 'App\\';
$baseDir = __DIR__ . '/../app/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
// Lowercase directory names, keep class name case
$parts = explode('\\', $relativeClass);
$className = array_pop($parts);
$parts = array_map('strtolower', $parts);
$parts[] = $className;
$file = $baseDir . implode('/', $parts) . '.php';
if (file_exists($file)) {
require $file;
}
});
use App\Core\Router; use App\Core\Router;

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';
}
}