diff options
| author | Brett Parson <brett@brett-parson.com> | 2026-08-21 13:55:37 -0500 |
|---|---|---|
| committer | Brett Parson <brett@brett-parson.com> | 2026-08-21 14:00:11 -0500 |
| commit | d039a5d62a9ac6ababae70d9d96422ca59f2b2d4 (patch) | |
| tree | f0db4750a571e8040a5da02cc7190079c0d62914 /tests | |
| parent | 0de77a6334cee1f6e15f7e0fd35602514560be33 (diff) | |
| download | miniroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.tar.gz miniroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.zip | |
Scaffold miniroute routing/middleware kernel
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/Fixtures/ClassLevelController.php | 18 | ||||
| -rw-r--r-- | tests/Fixtures/FakeController.php | 33 | ||||
| -rw-r--r-- | tests/Fixtures/FakeMiddleware.php | 30 | ||||
| -rw-r--r-- | tests/Fixtures/FakeResolver.php | 28 | ||||
| -rw-r--r-- | tests/Fixtures/TestRequest.php | 40 | ||||
| -rw-r--r-- | tests/Fixtures/TestResponse.php | 43 | ||||
| -rw-r--r-- | tests/MiddlewarePipelineTest.php | 38 | ||||
| -rw-r--r-- | tests/RouteLoaderTest.php | 57 | ||||
| -rw-r--r-- | tests/RouteMatcherTest.php | 68 | ||||
| -rw-r--r-- | tests/RouterTest.php | 81 | ||||
| -rw-r--r-- | tests/bootstrap.php | 36 |
11 files changed, 472 insertions, 0 deletions
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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Attribute\Middleware; + +#[Middleware(FakeMiddleware::class)] +final class ClassLevelController +{ + #[Get('/admin')] + public function dashboard(): string + { + return 'dashboard'; + } +} diff --git a/tests/Fixtures/FakeController.php b/tests/Fixtures/FakeController.php new file mode 100644 index 0000000..3b476ae --- /dev/null +++ b/tests/Fixtures/FakeController.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Attribute\Middleware; +use BrettParson\MiniRoute\Attribute\Post; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; + +final class FakeController +{ + #[Get('/entries')] + public function index(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'index'); + } + + #[Get('/entries/{slug}')] + public function show(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'show:' . $request->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 @@ +<?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; + +final class FakeMiddleware implements MiddlewareInterface +{ + /** @var ArrayObject<int, string>|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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Controller\ControllerResolverInterface; +use RuntimeException; + +final class FakeResolver implements ControllerResolverInterface +{ + /** @var array<string, object> */ + 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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Http\RequestInterface; + +final class TestRequest implements RequestInterface +{ + /** + * @param array<string, string> $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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Http\ResponseInterface; + +final class TestResponse implements ResponseInterface +{ + /** + * @param array<string, string> $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<string, string> + */ + 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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use ArrayObject; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewarePipeline; +use BrettParson\MiniRoute\Tests\Fixtures\FakeMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\TestRequest; +use BrettParson\MiniRoute\Tests\Fixtures\TestResponse; +use PHPUnit\Framework\TestCase; + +final class MiddlewarePipelineTest extends TestCase +{ + public function testMiddlewareRunsOutermostFirstAndCoreLast(): void + { + $log = new ArrayObject(); + + $outer = new FakeMiddleware('outer', $log); + $inner = new FakeMiddleware('inner', $log); + + $core = static function (RequestInterface $request) use ($log): ResponseInterface { + $log->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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use BrettParson\MiniRoute\Routing\RouteLoader; +use BrettParson\MiniRoute\Tests\Fixtures\ClassLevelController; +use BrettParson\MiniRoute\Tests\Fixtures\FakeController; +use BrettParson\MiniRoute\Tests\Fixtures\FakeMiddleware; +use PHPUnit\Framework\TestCase; + +final class RouteLoaderTest extends TestCase +{ + public function testLoadsRouteAttributesFromControllerMethods(): void + { + $routes = (new RouteLoader())->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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use BrettParson\MiniRoute\Routing\RouteDef; +use BrettParson\MiniRoute\Routing\RouteMatcher; +use PHPUnit\Framework\TestCase; + +final class RouteMatcherTest extends TestCase +{ + private RouteMatcher $matcher; + + protected function setUp(): void + { + $this->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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use ArrayObject; +use BrettParson\MiniRoute\Routing\RouteNotFoundException; +use BrettParson\MiniRoute\Routing\RouteRegistrationException; +use BrettParson\MiniRoute\Routing\Router; +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 PHPUnit\Framework\TestCase; + +final class RouterTest extends TestCase +{ + /** + * @return array{Router, FakeResolver, ArrayObject} + */ + private function makeRouter(): array + { + $resolver = new FakeResolver(); + $resolver->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 @@ +<?php + +declare(strict_types=1); + +/** + * Test bootstrap — registers a small PSR-4 autoloader so the library and its + * test fixtures can be loaded without requiring a Composer install. If + * vendor/autoload.php exists (Composer installed), prefer it. + */ +$vendorAutoload = dirname(__DIR__) . '/vendor/autoload.php'; + +if (is_file($vendorAutoload)) { + require $vendorAutoload; +} else { + spl_autoload_register(static function (string $class): void { + $prefixes = [ + 'BrettParson\\MiniRoute\\Tests\\' => __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; + } + } + }); +} |
