diff options
| author | Brett Parson <brett@brett-parson.com> | 2026-08-29 15:56:31 -0500 |
|---|---|---|
| committer | Brett Parson <brett@brett-parson.com> | 2026-08-29 15:56:31 -0500 |
| commit | 965551532d684456b74baf960157c0c2cef7335d (patch) | |
| tree | 1445afbc47730caa11d08fd84d9d65db132821f5 | |
| parent | d039a5d62a9ac6ababae70d9d96422ca59f2b2d4 (diff) | |
| download | miniroute-965551532d684456b74baf960157c0c2cef7335d.tar.gz miniroute-965551532d684456b74baf960157c0c2cef7335d.zip | |
Add regression and integration test suite
Port the stress-test findings into PHPUnit: realistic brett-parson.com
route model, known-issue baselines, router boundary cases, and a
standalone scaling benchmark (composer bench).
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | composer.json | 6 | ||||
| -rw-r--r-- | tests/Fixtures/AdminLoggingMiddleware.php | 31 | ||||
| -rw-r--r-- | tests/Fixtures/CollidingController.php | 21 | ||||
| -rw-r--r-- | tests/Fixtures/CsrfLoggingMiddleware.php | 30 | ||||
| -rw-r--r-- | tests/Fixtures/DuplicateParamController.php | 21 | ||||
| -rw-r--r-- | tests/Fixtures/MixedVisibilityController.php | 27 | ||||
| -rw-r--r-- | tests/Fixtures/OnlyGetController.php | 21 | ||||
| -rw-r--r-- | tests/Fixtures/RealisticAdminController.php | 98 | ||||
| -rw-r--r-- | tests/Fixtures/RealisticPublicController.php | 72 | ||||
| -rw-r--r-- | tests/Fixtures/StatefulController.php | 23 | ||||
| -rw-r--r-- | tests/Fixtures/StatefulMiddleware.php | 26 | ||||
| -rw-r--r-- | tests/Integration/RealisticRouteTableTest.php | 153 | ||||
| -rw-r--r-- | tests/Regression/KnownIssuesTest.php | 158 | ||||
| -rw-r--r-- | tests/RouterTest.php | 61 | ||||
| -rw-r--r-- | tests/bench/bench.php | 107 |
16 files changed, 857 insertions, 1 deletions
@@ -95,7 +95,8 @@ configuration loader. Those stay application concerns. ```bash composer install -vendor/bin/phpunit +composer check # lint + unit tests +composer bench # registration/dispatch scaling benchmark ``` Or, without Composer, drop a `phpunit.phar` into the repo root and run diff --git a/composer.json b/composer.json index e9f55d1..dcda42e 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,12 @@ "require-dev": { "phpunit/phpunit": "^10.5 || ^11.0" }, + "scripts": { + "test": "phpunit", + "bench": "php tests/bench/bench.php", + "lint": "find src tests -name '*.php' -exec php -l {} +", + "check": ["@lint", "@test"] + }, "config": { "sort-packages": true } diff --git a/tests/Fixtures/AdminLoggingMiddleware.php b/tests/Fixtures/AdminLoggingMiddleware.php new file mode 100644 index 0000000..8613475 --- /dev/null +++ b/tests/Fixtures/AdminLoggingMiddleware.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use ArrayObject; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewareInterface; + +/** + * Logs before/after into a shared log. Stands in for the real app's admin + * gate; distinct class from CsrfLoggingMiddleware so both group layers can + * be resolved independently and their onion order asserted. + */ +final class AdminLoggingMiddleware implements MiddlewareInterface +{ + public function __construct(private readonly ArrayObject $log) + { + } + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $this->log->append('admin:before'); + $response = $next($request); + $this->log->append('admin:after'); + + return $response; + } +} diff --git a/tests/Fixtures/CollidingController.php b/tests/Fixtures/CollidingController.php new file mode 100644 index 0000000..434ffbf --- /dev/null +++ b/tests/Fixtures/CollidingController.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Http\ResponseInterface; + +/** + * Collides with FakeController::index (GET /entries) to probe + * cross-controller duplicate detection at registration. + */ +final class CollidingController +{ + #[Get('/entries')] + public function otherIndex(): ResponseInterface + { + return new TestResponse(200, 'other'); + } +} diff --git a/tests/Fixtures/CsrfLoggingMiddleware.php b/tests/Fixtures/CsrfLoggingMiddleware.php new file mode 100644 index 0000000..4fa25d5 --- /dev/null +++ b/tests/Fixtures/CsrfLoggingMiddleware.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use ArrayObject; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewareInterface; + +/** + * Logs before/after into a shared log. Stands in for the real app's CSRF + * guard, applied to the /admin prefix for POST only. + */ +final class CsrfLoggingMiddleware implements MiddlewareInterface +{ + public function __construct(private readonly ArrayObject $log) + { + } + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $this->log->append('csrf:before'); + $response = $next($request); + $this->log->append('csrf:after'); + + return $response; + } +} diff --git a/tests/Fixtures/DuplicateParamController.php b/tests/Fixtures/DuplicateParamController.php new file mode 100644 index 0000000..8ea3060 --- /dev/null +++ b/tests/Fixtures/DuplicateParamController.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Http\ResponseInterface; + +/** + * Declares a route with a duplicated parameter name — a route that can + * never match (v0.1.0 baseline; see miniroute-uhs.1). + */ +final class DuplicateParamController +{ + #[Get('/dup/{id}/x/{id}')] + public function run(): ResponseInterface + { + return new TestResponse(200, 'dup'); + } +} diff --git a/tests/Fixtures/MixedVisibilityController.php b/tests/Fixtures/MixedVisibilityController.php new file mode 100644 index 0000000..2f5ccff --- /dev/null +++ b/tests/Fixtures/MixedVisibilityController.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Http\ResponseInterface; + +/** + * One public and one private method carrying route attributes. The private + * route is invisible to the v0.1.0 loader (see miniroute-uhs.4). + */ +final class MixedVisibilityController +{ + #[Get('/pub')] + public function pub(): ResponseInterface + { + return new TestResponse(200, 'pub'); + } + + #[Get('/secret')] + private function secret(): ResponseInterface + { + return new TestResponse(200, 'secret'); + } +} diff --git a/tests/Fixtures/OnlyGetController.php b/tests/Fixtures/OnlyGetController.php new file mode 100644 index 0000000..0ab18f4 --- /dev/null +++ b/tests/Fixtures/OnlyGetController.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Http\ResponseInterface; + +/** + * A single GET route, used to probe method handling: POST and HEAD on the + * same path (v0.1.0 baselines; see miniroute-uhs.2 / miniroute-uhs.3). + */ +final class OnlyGetController +{ + #[Get('/only')] + public function get(): ResponseInterface + { + return new TestResponse(200, 'only-get'); + } +} diff --git a/tests/Fixtures/RealisticAdminController.php b/tests/Fixtures/RealisticAdminController.php new file mode 100644 index 0000000..e49fcd2 --- /dev/null +++ b/tests/Fixtures/RealisticAdminController.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Attribute\Post; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; + +/** + * Admin half of the brett-parson.com route model (13 routes). + * + * Includes the ordering nightmare case: POST /admin/entries/preview is a + * literal that must beat POST /admin/entries/{id}, independent of + * registration order. + */ +final class RealisticAdminController +{ + #[Get('/admin')] + public function dashboard(): ResponseInterface + { + return new TestResponse(200, 'admin-dashboard'); + } + + #[Get('/admin/notes')] + public function notesIndex(): ResponseInterface + { + return new TestResponse(200, 'notes-index'); + } + + #[Get('/admin/notes/new')] + public function notesNew(): ResponseInterface + { + return new TestResponse(200, 'notes-new'); + } + + #[Get('/admin/notes/{id}/edit')] + public function notesEdit(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'notes-edit:' . $request->param('id')); + } + + #[Post('/admin/notes')] + public function notesStore(): ResponseInterface + { + return new TestResponse(200, 'notes-store'); + } + + #[Post('/admin/notes/{id}')] + public function notesUpdate(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'notes-update:' . $request->param('id')); + } + + #[Post('/admin/notes/{id}/delete')] + public function notesDelete(): ResponseInterface + { + return new TestResponse(200, 'notes-delete'); + } + + #[Post('/admin/notes/{id}/restore')] + public function notesRestore(): ResponseInterface + { + return new TestResponse(200, 'notes-restore'); + } + + #[Get('/admin/entries/new')] + public function entriesNew(): ResponseInterface + { + return new TestResponse(200, 'entries-new'); + } + + #[Post('/admin/entries')] + public function entriesStore(): ResponseInterface + { + return new TestResponse(200, 'entries-store'); + } + + #[Post('/admin/entries/preview')] + public function entriesPreview(): ResponseInterface + { + return new TestResponse(200, 'entries-preview-literal'); + } + + #[Get('/admin/entries/{id}/edit')] + public function entriesEdit(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'entries-edit:' . $request->param('id')); + } + + #[Post('/admin/entries/{id}')] + public function entriesUpdate(): ResponseInterface + { + return new TestResponse(200, 'entries-update'); + } +} diff --git a/tests/Fixtures/RealisticPublicController.php b/tests/Fixtures/RealisticPublicController.php new file mode 100644 index 0000000..992b17b --- /dev/null +++ b/tests/Fixtures/RealisticPublicController.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; + +/** + * Public half of the brett-parson.com route model (9 routes). + * + * Mirrors the real public/index.php table so the integration suite pins + * the compatibility the stress test proved. + */ +final class RealisticPublicController +{ + #[Get('/')] + public function home(): ResponseInterface + { + return new TestResponse(200, 'home'); + } + + #[Get('/health')] + public function health(): ResponseInterface + { + return new TestResponse(200, 'health'); + } + + #[Get('/entries')] + public function entries(): ResponseInterface + { + return new TestResponse(200, 'entries'); + } + + #[Get('/entries/{slug}')] + public function entryShow(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'entry-show:' . $request->param('slug')); + } + + #[Get('/feed.xml')] + public function feed(): ResponseInterface + { + return new TestResponse(200, 'feed'); + } + + #[Get('/robots.txt')] + public function robots(): ResponseInterface + { + return new TestResponse(200, 'robots'); + } + + #[Get('/sitemap.xml')] + public function sitemap(): ResponseInterface + { + return new TestResponse(200, 'sitemap'); + } + + #[Get('/notes')] + public function notes(): ResponseInterface + { + return new TestResponse(200, 'notes'); + } + + #[Get('/notes/{id}')] + public function noteShow(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'note-show:' . $request->param('id')); + } +} diff --git a/tests/Fixtures/StatefulController.php b/tests/Fixtures/StatefulController.php new file mode 100644 index 0000000..bd09592 --- /dev/null +++ b/tests/Fixtures/StatefulController.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Attribute\Middleware; +use BrettParson\MiniRoute\Http\ResponseInterface; + +/** + * Route carrying method-level middleware, used with a shared + * StatefulMiddleware instance to probe resolver-singleton behavior. + */ +final class StatefulController +{ + #[Get('/stateful')] + #[Middleware(StatefulMiddleware::class)] + public function run(): ResponseInterface + { + return new TestResponse(200, 'stateful'); + } +} diff --git a/tests/Fixtures/StatefulMiddleware.php b/tests/Fixtures/StatefulMiddleware.php new file mode 100644 index 0000000..d374238 --- /dev/null +++ b/tests/Fixtures/StatefulMiddleware.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewareInterface; + +/** + * Deliberately stateful middleware. Probes the singleton-container leak: + * when a resolver returns the same instance per request, state accumulates + * across dispatches (usage contract; ADR work in miniroute-k4i.3). + */ +final class StatefulMiddleware implements MiddlewareInterface +{ + public int $count = 0; + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $this->count++; + + return $next($request); + } +} diff --git a/tests/Integration/RealisticRouteTableTest.php b/tests/Integration/RealisticRouteTableTest.php new file mode 100644 index 0000000..f93896c --- /dev/null +++ b/tests/Integration/RealisticRouteTableTest.php @@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Integration; + +use ArrayObject; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewareInterface; +use BrettParson\MiniRoute\Middleware\MiddlewarePipeline; +use BrettParson\MiniRoute\Routing\RouteLoader; +use BrettParson\MiniRoute\Routing\RouteNotFoundException; +use BrettParson\MiniRoute\Routing\Router; +use BrettParson\MiniRoute\Tests\Fixtures\AdminLoggingMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\CsrfLoggingMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\FakeResolver; +use BrettParson\MiniRoute\Tests\Fixtures\RealisticAdminController; +use BrettParson\MiniRoute\Tests\Fixtures\RealisticPublicController; +use BrettParson\MiniRoute\Tests\Fixtures\TestRequest; +use BrettParson\MiniRoute\Tests\Fixtures\TestResponse; +use PHPUnit\Framework\TestCase; + +/** + * Compatibility suite: the realistic brett-parson.com route table. + * + * Ported from the tmp-miniroute stress test so the design requirements it + * proved (22-route table, /admin group + POST-only CSRF group, literal + * preview beating {id}, param injection) stay pinned as PHPUnit coverage. + * Bead: miniroute-4dh.1. + */ +final class RealisticRouteTableTest extends TestCase +{ + private FakeResolver $resolver; + + private Router $router; + + private ArrayObject $log; + + protected function setUp(): void + { + $this->log = new ArrayObject(); + $this->resolver = new FakeResolver(); + $this->resolver->bind(RealisticPublicController::class, new RealisticPublicController()); + $this->resolver->bind(RealisticAdminController::class, new RealisticAdminController()); + $this->resolver->bind(AdminLoggingMiddleware::class, new AdminLoggingMiddleware($this->log)); + $this->resolver->bind(CsrfLoggingMiddleware::class, new CsrfLoggingMiddleware($this->log)); + + $this->router = new Router($this->resolver); + $this->router->group('/admin', [AdminLoggingMiddleware::class]); + $this->router->group('/admin', [CsrfLoggingMiddleware::class], ['POST']); + $this->router->registerController(RealisticPublicController::class); + $this->router->registerController(RealisticAdminController::class); + } + + public function testRouteTableCompilesToTwentyTwoRoutes(): void + { + $loaded = count((new RouteLoader())->load(RealisticPublicController::class)) + + count((new RouteLoader())->load(RealisticAdminController::class)); + + $this->assertSame(22, $loaded); + } + + public function testLiteralPreviewBeatsParameterizedId(): void + { + $this->assertSame( + 'entries-preview-literal', + $this->dispatch('POST', '/admin/entries/preview')->body(), + ); + } + + public function testInjectsSlugAndIdParameters(): void + { + $this->assertSame('entry-show:hello-world', $this->dispatch('GET', '/entries/hello-world')->body()); + $this->assertSame('note-show:42', $this->dispatch('GET', '/notes/42')->body()); + $this->assertSame('entries-edit:7', $this->dispatch('GET', '/admin/entries/7/edit')->body()); + $this->assertSame('notes-edit:9', $this->dispatch('GET', '/admin/notes/9/edit')->body()); + } + + public function testLiteralSegmentsWithDotsMatch(): void + { + $this->assertSame('feed', $this->dispatch('GET', '/feed.xml')->body()); + $this->assertSame('robots', $this->dispatch('GET', '/robots.txt')->body()); + $this->assertSame('sitemap', $this->dispatch('GET', '/sitemap.xml')->body()); + } + + public function testAdminGroupThenCsrfGroupOrderOnPost(): void + { + $this->dispatch('POST', '/admin/entries/preview'); + + $this->assertSame( + ['admin:before', 'csrf:before', 'csrf:after', 'admin:after'], + $this->log->getArrayCopy(), + ); + } + + public function testCsrfGroupSkippedForGet(): void + { + $this->dispatch('GET', '/admin/notes'); + + $this->assertSame(['admin:before', 'admin:after'], $this->log->getArrayCopy()); + } + + public function testNotFoundStillThrows(): void + { + $this->expectException(RouteNotFoundException::class); + + $this->dispatch('GET', '/missing'); + } + + public function testFiveLayerOnionOrder(): void + { + $order = new ArrayObject(); + $mk = static function (string $name) use ($order): MiddlewareInterface { + return new class ($name, $order) implements MiddlewareInterface { + /** @param ArrayObject<int, string> $order */ + public function __construct( + private readonly string $name, + private readonly ArrayObject $order, + ) { + } + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $this->order->append($this->name . ':before'); + $response = $next($request); + $this->order->append($this->name . ':after'); + + return $response; + } + }; + }; + + $core = static function (RequestInterface $request) use ($order): ResponseInterface { + $order->append('core'); + + return new TestResponse(200, 'ok'); + }; + + $pipeline = MiddlewarePipeline::compose([$mk('A'), $mk('B'), $mk('C'), $mk('D'), $mk('E')], $core); + $pipeline(new TestRequest('GET', '/')); + + $this->assertSame( + ['A:before', 'B:before', 'C:before', 'D:before', 'E:before', 'core', 'E:after', 'D:after', 'C:after', 'B:after', 'A:after'], + $order->getArrayCopy(), + ); + } + + private function dispatch(string $method, string $path): ResponseInterface + { + return $this->router->dispatch(new TestRequest($method, $path)); + } +} diff --git a/tests/Regression/KnownIssuesTest.php b/tests/Regression/KnownIssuesTest.php new file mode 100644 index 0000000..5a6c6f7 --- /dev/null +++ b/tests/Regression/KnownIssuesTest.php @@ -0,0 +1,158 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Regression; + +use BrettParson\MiniRoute\Routing\RouteLoader; +use BrettParson\MiniRoute\Routing\RouteNotFoundException; +use BrettParson\MiniRoute\Routing\Router; +use BrettParson\MiniRoute\Tests\Fixtures\DuplicateParamController; +use BrettParson\MiniRoute\Tests\Fixtures\FakeController; +use BrettParson\MiniRoute\Tests\Fixtures\FakeResolver; +use BrettParson\MiniRoute\Tests\Fixtures\MixedVisibilityController; +use BrettParson\MiniRoute\Tests\Fixtures\OnlyGetController; +use BrettParson\MiniRoute\Tests\Fixtures\StatefulController; +use BrettParson\MiniRoute\Tests\Fixtures\StatefulMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\TestRequest; +use PHPUnit\Framework\TestCase; + +/** + * Known-issue baselines: pin v0.1.0 behavior for every confirmed stress + * finding so the suite is green today and each case flips to the desired + * behavior when its v0.2.0 fix lands. + * + * Bead: miniroute-4dh.2. Fixes tracked in the v0.2.0 epic (miniroute-uhs). + */ +final class KnownIssuesTest extends TestCase +{ + private function routerWith(object $controller, string $class): Router + { + $resolver = new FakeResolver(); + $resolver->bind($class, $controller); + + return new Router($resolver); + } + + /** + * Baseline: duplicated parameter names produce a route that can never + * match, and emit a PHP warning on every match attempt. + * + * Desired (miniroute-uhs.1): registration throws + * RouteRegistrationException. Flip this test when the fix lands. + */ + public function testDuplicateParamNamesAreADeadRoute(): void + { + $router = $this->routerWith(new DuplicateParamController(), DuplicateParamController::class); + $router->registerController(DuplicateParamController::class); + + $warnings = []; + set_error_handler(static function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + + return true; + }); + + try { + $router->dispatch(new TestRequest('GET', '/dup/1/x/2')); + $matched = true; + } catch (RouteNotFoundException) { + $matched = false; + } finally { + restore_error_handler(); + } + + $this->assertFalse($matched, 'duplicate-param route must not match'); + $this->assertNotEmpty($warnings, 'matching a duplicate-param route emits a preg warning'); + } + + /** + * Baseline: a wrong method on an existing path is a 404, with no way to + * distinguish method-not-allowed. + * + * Desired (miniroute-uhs.2): MethodNotAllowedException carrying the + * allowed methods, so the app can render 405 + Allow. Flip this test. + */ + public function testWrongMethodOnExistingPathIsNotFound(): void + { + $router = $this->routerWith(new OnlyGetController(), OnlyGetController::class); + $router->registerController(OnlyGetController::class); + + $this->expectException(RouteNotFoundException::class); + + $router->dispatch(new TestRequest('POST', '/only')); + } + + /** + * Baseline: HEAD requests never map to GET handlers. + * + * Desired (miniroute-uhs.3): HEAD dispatches to the GET handler and the + * app's response layer suppresses the body. Flip this test. + */ + public function testHeadDoesNotMapToGet(): void + { + $router = $this->routerWith(new OnlyGetController(), OnlyGetController::class); + $router->registerController(OnlyGetController::class); + + $this->expectException(RouteNotFoundException::class); + + $router->dispatch(new TestRequest('HEAD', '/only')); + } + + /** + * Baseline: route attributes on non-public methods are silently skipped + * by the loader. + * + * Desired (miniroute-uhs.4): loading a controller with a route attribute + * on a non-public method throws RouteRegistrationException. Flip this test. + */ + public function testNonPublicMethodsWithRouteAttributesAreSkipped(): void + { + $routes = (new RouteLoader())->load(MixedVisibilityController::class); + + $this->assertCount(1, $routes); + $this->assertSame('/pub', $routes[0]->path); + } + + /** + * Baseline: a resolver returning the same middleware instance across + * requests accumulates state (singleton-container leak). This is a usage + * contract, not a kernel bug: middleware must be stateless or + * per-request scoped. ADR work tracked in miniroute-k4i.3. + */ + public function testSingletonMiddlewareAccumulatesStateAcrossRequests(): void + { + $stateful = new StatefulMiddleware(); + + $resolver = new FakeResolver(); + $resolver->bind(StatefulController::class, new StatefulController()); + $resolver->bind(StatefulMiddleware::class, $stateful); + + $router = new Router($resolver); + $router->registerController(StatefulController::class); + + $router->dispatch(new TestRequest('GET', '/stateful')); + $router->dispatch(new TestRequest('GET', '/stateful')); + + $this->assertSame(2, $stateful->count, 'state leaks across dispatches with a shared instance'); + } + + /** + * Baseline: {param} segments are greedy — any single segment matches, so + * a typo'd URL gets a 200 from the parameterized route, not a 404. + * + * Documented behavior, no kernel fix planned: controllers validate the + * captured value (the blog controller 404s on an unknown slug). Decision + * recorded in miniroute-k4i.3 (param constraints rejected). + */ + public function testParamRoutesAreGreedy(): void + { + $router = $this->routerWith(new FakeController(), FakeController::class); + $router->registerController(FakeController::class); + + $response = $router->dispatch(new TestRequest('GET', '/entries/typo-of-known-path')); + + $this->assertSame(200, $response->status()); + $this->assertSame('show:typo-of-known-path', $response->body()); + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php index b8d4d49..3279ede 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -8,10 +8,12 @@ use ArrayObject; use BrettParson\MiniRoute\Routing\RouteNotFoundException; use BrettParson\MiniRoute\Routing\RouteRegistrationException; use BrettParson\MiniRoute\Routing\Router; +use BrettParson\MiniRoute\Tests\Fixtures\CollidingController; use BrettParson\MiniRoute\Tests\Fixtures\FakeController; use BrettParson\MiniRoute\Tests\Fixtures\FakeMiddleware; use BrettParson\MiniRoute\Tests\Fixtures\FakeResolver; use BrettParson\MiniRoute\Tests\Fixtures\TestRequest; +use BrettParson\MiniRoute\Tests\Fixtures\TestResponse; use PHPUnit\Framework\TestCase; final class RouterTest extends TestCase @@ -78,4 +80,63 @@ final class RouterTest extends TestCase $this->expectException(RouteNotFoundException::class); $router->dispatch(new TestRequest('GET', '/missing')); } + + public function testNotFoundHandlerIsCalledWhenSet(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + $router->setNotFoundHandler(static fn (): TestResponse => new TestResponse(404, 'custom-nf')); + + $response = $router->dispatch(new TestRequest('GET', '/missing')); + + $this->assertSame(404, $response->status()); + $this->assertSame('custom-nf', $response->body()); + } + + public function testTrailingSlashIsNormalized(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $response = $router->dispatch(new TestRequest('GET', '/entries/')); + + $this->assertSame('index', $response->body()); + } + + public function testMethodIsCaseInsensitive(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $response = $router->dispatch(new TestRequest('get', '/entries')); + + $this->assertSame('index', $response->body()); + } + + public function testLeadingDoubleSlashDoesNotMatch(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(new TestRequest('GET', '//entries')); + } + + public function testPathsAreCaseSensitive(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(new TestRequest('GET', '/Entries')); + } + + public function testDuplicateRouteAcrossControllersThrows(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $this->expectException(RouteRegistrationException::class); + $router->registerController(CollidingController::class); + } } diff --git a/tests/bench/bench.php b/tests/bench/bench.php new file mode 100644 index 0000000..b7ae94e --- /dev/null +++ b/tests/bench/bench.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +/** + * Miniroute benchmark — registration and dispatch scaling. + * + * Ported from the tmp-miniroute stress test (section 5) so the performance + * characteristics stay measurable as the kernel evolves. Run manually: + * + * composer bench # or: php tests/bench/bench.php + * + * Timings are intentionally NOT PHPUnit assertions — wall-clock numbers are + * too flaky for CI. The v1.0.0 release (miniroute-18h.3) adds hard CI gates + * (register < 5ms for the full table, dispatch < 0.1ms). + * + * Expected shape: registration is super-linear in v0.1.0 (usort per add, + * ~770ms at 600 routes) and collapses once miniroute-uhs.5 (lazy sort) + * lands. Dispatch scales linearly in route count. + */ + +require dirname(__DIR__) . '/bootstrap.php'; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Routing\RouteNotFoundException; +use BrettParson\MiniRoute\Routing\Router; +use BrettParson\MiniRoute\Tests\Fixtures\AdminLoggingMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\CsrfLoggingMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\FakeResolver; +use BrettParson\MiniRoute\Tests\Fixtures\RealisticAdminController; +use BrettParson\MiniRoute\Tests\Fixtures\RealisticPublicController; +use BrettParson\MiniRoute\Tests\Fixtures\TestRequest; +use BrettParson\MiniRoute\Tests\Fixtures\TestResponse; + +/** @return ResponseInterface|null body, or null on no-match */ +function benchHit(Router $router, string $method, string $path): ?string +{ + try { + return $router->dispatch(new TestRequest($method, $path))->body(); + } catch (RouteNotFoundException) { + return null; + } +} + +function benchMs(float $start): float +{ + return (microtime(true) - $start) * 1000; +} + +echo "miniroute benchmark\n"; +echo str_repeat('-', 64) . "\n"; + +// ── Realistic table: registration + 2000 dispatches ──────────────────────── + +$resolver = new FakeResolver(); +$resolver->bind(RealisticPublicController::class, new RealisticPublicController()); +$resolver->bind(RealisticAdminController::class, new RealisticAdminController()); +$resolver->bind(AdminLoggingMiddleware::class, new AdminLoggingMiddleware(new ArrayObject())); +$resolver->bind(CsrfLoggingMiddleware::class, new CsrfLoggingMiddleware(new ArrayObject())); + +$real = new Router($resolver); +$real->group('/admin', [AdminLoggingMiddleware::class]); +$real->group('/admin', [CsrfLoggingMiddleware::class], ['POST']); + +$start = microtime(true); +$real->registerController(RealisticPublicController::class); +$real->registerController(RealisticAdminController::class); +$regMs = benchMs($start); + +$paths = ['/entries', '/entries/hello-world', '/notes/42', '/admin', '/admin/notes', '/admin/entries/preview', '/feed.xml', '/robots.txt', '/sitemap.xml']; +$start = microtime(true); +for ($i = 0; $i < 2000; $i++) { + benchHit($real, 'GET', $paths[$i % count($paths)]); +} +$dispatchMs = benchMs($start); + +printf("realistic (22 routes): register = %8.3fms | 2000 dispatches = %8.2fms (%0.5fms each)\n", $regMs, $dispatchMs, $dispatchMs / 2000); + +// ── Scaling: synthetic routes at 100 / 300 / 600 ─────────────────────────── + +printf("\n%-22s %-20s %s\n", 'routes', 'register', 'leaf dispatch (each)'); + +foreach ([100, 300, 600] as $n) { + $resolver = new FakeResolver(); + $bench = new Router($resolver); + + $start = microtime(true); + for ($i = 0; $i < $n; $i++) { + $class = 'BenchRoute' . $n . '_' . $i; + eval('final class ' . $class . ' { #[' . Get::class . '("/bench/' . $n . '/' . $i . '")] public function run(): ' . ResponseInterface::class . ' { return new ' . TestResponse::class . '(200, "b"); } }'); + $resolver->bind($class, new $class()); + $bench->registerController($class); + } + $regMs = benchMs($start); + + $start = microtime(true); + for ($i = 0; $i < 200; $i++) { + benchHit($bench, 'GET', '/bench/' . $n . '/' . ($n - 1)); + } + $dispatchMs = benchMs($start) / 200; + + printf("%-22d %8.1fms %s %0.5fms\n", $n, $regMs, str_repeat(' ', 11), $dispatchMs); +} + +echo str_repeat('-', 64) . "\n"; |
