From d039a5d62a9ac6ababae70d9d96422ca59f2b2d4 Mon Sep 17 00:00:00 2001 From: Brett Parson Date: Fri, 21 Aug 2026 13:55:37 -0500 Subject: Scaffold miniroute routing/middleware kernel --- tests/Fixtures/ClassLevelController.php | 18 ++++++++ tests/Fixtures/FakeController.php | 33 ++++++++++++++ tests/Fixtures/FakeMiddleware.php | 30 ++++++++++++ tests/Fixtures/FakeResolver.php | 28 ++++++++++++ tests/Fixtures/TestRequest.php | 40 ++++++++++++++++ tests/Fixtures/TestResponse.php | 43 +++++++++++++++++ tests/MiddlewarePipelineTest.php | 38 ++++++++++++++++ tests/RouteLoaderTest.php | 57 +++++++++++++++++++++++ tests/RouteMatcherTest.php | 68 +++++++++++++++++++++++++++ tests/RouterTest.php | 81 +++++++++++++++++++++++++++++++++ tests/bootstrap.php | 36 +++++++++++++++ 11 files changed, 472 insertions(+) create mode 100644 tests/Fixtures/ClassLevelController.php create mode 100644 tests/Fixtures/FakeController.php create mode 100644 tests/Fixtures/FakeMiddleware.php create mode 100644 tests/Fixtures/FakeResolver.php create mode 100644 tests/Fixtures/TestRequest.php create mode 100644 tests/Fixtures/TestResponse.php create mode 100644 tests/MiddlewarePipelineTest.php create mode 100644 tests/RouteLoaderTest.php create mode 100644 tests/RouteMatcherTest.php create mode 100644 tests/RouterTest.php create mode 100644 tests/bootstrap.php (limited to 'tests') diff --git a/tests/Fixtures/ClassLevelController.php b/tests/Fixtures/ClassLevelController.php new file mode 100644 index 0000000..88ef6e6 --- /dev/null +++ b/tests/Fixtures/ClassLevelController.php @@ -0,0 +1,18 @@ +param('slug')); + } + + #[Post('/entries')] + #[Middleware(FakeMiddleware::class)] + public function store(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'store'); + } +} diff --git a/tests/Fixtures/FakeMiddleware.php b/tests/Fixtures/FakeMiddleware.php new file mode 100644 index 0000000..d684e7c --- /dev/null +++ b/tests/Fixtures/FakeMiddleware.php @@ -0,0 +1,30 @@ +|null */ + private ?ArrayObject $log; + + public function __construct( + private readonly string $name = 'fake', + ?ArrayObject $log = null, + ) { + $this->log = $log; + } + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $this->log?->append($this->name); + + return $next($request); + } +} diff --git a/tests/Fixtures/FakeResolver.php b/tests/Fixtures/FakeResolver.php new file mode 100644 index 0000000..af6ec1f --- /dev/null +++ b/tests/Fixtures/FakeResolver.php @@ -0,0 +1,28 @@ + */ + private array $bindings = []; + + public function bind(string $class, object $instance): void + { + $this->bindings[$class] = $instance; + } + + public function resolve(string $class): object + { + if (!array_key_exists($class, $this->bindings)) { + throw new RuntimeException("No binding for {$class}"); + } + + return $this->bindings[$class]; + } +} diff --git a/tests/Fixtures/TestRequest.php b/tests/Fixtures/TestRequest.php new file mode 100644 index 0000000..c449610 --- /dev/null +++ b/tests/Fixtures/TestRequest.php @@ -0,0 +1,40 @@ + $params + */ + public function __construct( + private readonly string $method, + private readonly string $path, + private readonly array $params = [], + ) { + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function param(string $name, string $default = ''): string + { + return $this->params[$name] ?? $default; + } + + public function withParams(array $params): RequestInterface + { + return new self($this->method, $this->path, $params); + } +} diff --git a/tests/Fixtures/TestResponse.php b/tests/Fixtures/TestResponse.php new file mode 100644 index 0000000..25a0307 --- /dev/null +++ b/tests/Fixtures/TestResponse.php @@ -0,0 +1,43 @@ + $headers + */ + public function __construct( + private readonly int $status, + private readonly string $body, + private readonly array $headers = [], + ) { + } + + public function status(): int + { + return $this->status; + } + + public function body(): string + { + return $this->body; + } + + public function withHeader(string $name, string $value): ResponseInterface + { + return new self($this->status, $this->body, array_merge($this->headers, [$name => $value])); + } + + /** + * @return array + */ + public function headers(): array + { + return $this->headers; + } +} diff --git a/tests/MiddlewarePipelineTest.php b/tests/MiddlewarePipelineTest.php new file mode 100644 index 0000000..107dce3 --- /dev/null +++ b/tests/MiddlewarePipelineTest.php @@ -0,0 +1,38 @@ +append('core'); + + return new TestResponse(200, 'ok'); + }; + + $pipeline = MiddlewarePipeline::compose([$outer, $inner], $core); + + $response = $pipeline(new TestRequest('GET', '/')); + + $this->assertSame(['outer', 'inner', 'core'], $log->getArrayCopy()); + $this->assertSame(200, $response->status()); + } +} diff --git a/tests/RouteLoaderTest.php b/tests/RouteLoaderTest.php new file mode 100644 index 0000000..c20f58b --- /dev/null +++ b/tests/RouteLoaderTest.php @@ -0,0 +1,57 @@ +load(FakeController::class); + + $indexed = []; + + foreach ($routes as $route) { + $indexed[$route->method . ' ' . $route->path] = $route; + } + + $this->assertCount(3, $routes); + + $this->assertSame('GET', $indexed['GET /entries']->method); + $this->assertSame('index', $indexed['GET /entries']->controllerMethod); + $this->assertSame([], $indexed['GET /entries']->middleware); + + $this->assertSame('show', $indexed['GET /entries/{slug}']->controllerMethod); + } + + public function testMethodMiddlewareIsAttached(): void + { + $routes = (new RouteLoader())->load(FakeController::class); + + $post = null; + + foreach ($routes as $route) { + if ($route->method === 'POST') { + $post = $route; + } + } + + $this->assertNotNull($post); + $this->assertSame([FakeMiddleware::class], $post->middleware); + } + + public function testClassLevelMiddlewareIsInheritedByMethods(): void + { + $routes = (new RouteLoader())->load(ClassLevelController::class); + + $this->assertCount(1, $routes); + $this->assertSame([FakeMiddleware::class], $routes[0]->middleware); + } +} diff --git a/tests/RouteMatcherTest.php b/tests/RouteMatcherTest.php new file mode 100644 index 0000000..5181c50 --- /dev/null +++ b/tests/RouteMatcherTest.php @@ -0,0 +1,68 @@ +matcher = new RouteMatcher(); + } + + private function route(string $method, string $path): RouteDef + { + return new RouteDef($method, $path, 'C', 'm'); + } + + public function testMatchesExactPath(): void + { + $route = $this->route('GET', '/entries'); + + $this->assertTrue($this->matcher->matches($route, 'get', '/entries')); + $this->assertFalse($this->matcher->matches($route, 'POST', '/entries')); + $this->assertFalse($this->matcher->matches($route, 'GET', '/entries/1')); + } + + public function testMatchesParameterizedPath(): void + { + $route = $this->route('GET', '/entries/{slug}'); + + $this->assertTrue($this->matcher->matches($route, 'GET', '/entries/hello-world')); + $this->assertFalse($this->matcher->matches($route, 'GET', '/entries/hello/world')); + } + + public function testExtractsNamedParameters(): void + { + $route = $this->route('GET', '/entries/{slug}'); + + $this->assertSame( + ['slug' => 'hello-world'], + $this->matcher->extractParams($route, '/entries/hello-world'), + ); + } + + public function testLiteralBeatsParameter(): void + { + $literal = $this->route('GET', '/entries/archive'); + $param = $this->route('GET', '/entries/{slug}'); + + $this->assertLessThan(0, $this->matcher->compare($literal, $param)); + $this->assertGreaterThan(0, $this->matcher->compare($param, $literal)); + } + + public function testExactBeatsParameterAtSamePosition(): void + { + $exact = $this->route('POST', '/admin/entries/preview'); + $param = $this->route('POST', '/admin/entries/{id}'); + + $this->assertLessThan(0, $this->matcher->compare($exact, $param)); + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php new file mode 100644 index 0000000..b8d4d49 --- /dev/null +++ b/tests/RouterTest.php @@ -0,0 +1,81 @@ +bind(FakeController::class, new FakeController()); + + $log = new ArrayObject(); + $resolver->bind(FakeMiddleware::class, new FakeMiddleware('m', $log)); + + return [new Router($resolver), $resolver, $log]; + } + + public function testDispatchesToController(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $response = $router->dispatch(new TestRequest('GET', '/entries')); + + $this->assertSame(200, $response->status()); + $this->assertSame('index', $response->body()); + } + + public function testInjectsRouteParameters(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $response = $router->dispatch(new TestRequest('GET', '/entries/hello-world')); + + $this->assertSame('show:hello-world', $response->body()); + } + + public function testAppliesRouteMiddleware(): void + { + [$router, , $log] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $router->dispatch(new TestRequest('POST', '/entries')); + + $this->assertSame(['m'], $log->getArrayCopy()); + } + + public function testDuplicateRouteThrows(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $this->expectException(RouteRegistrationException::class); + $router->registerController(FakeController::class); + } + + public function testNotFoundThrowsWithoutHandler(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(new TestRequest('GET', '/missing')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..f49f712 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,36 @@ + __DIR__ . '/', + 'BrettParson\\MiniRoute\\' => dirname(__DIR__) . '/src/', + ]; + + foreach ($prefixes as $prefix => $baseDir) { + if (!str_starts_with($class, $prefix)) { + continue; + } + + $relative = substr($class, strlen($prefix)); + $file = $baseDir . str_replace('\\', '/', $relative) . '.php'; + + if (is_file($file)) { + require $file; + + return; + } + } + }); +} -- cgit v1.2.3