From 8effecf2fe3a8094d7673427df73c9b851e71f55 Mon Sep 17 00:00:00 2001 From: rtcoder Date: Wed, 17 Jun 2026 13:21:57 +0200 Subject: [PATCH] Split API core tests by feature --- README.md | 2 + REFACTORING.md | 1 + docs/index.html | 4 +- tests/ApiCoreTest.php | 569 +------------------------- tests/Feature/AppDispatchTest.php | 76 ++++ tests/Feature/MiddlewareTest.php | 104 +++++ tests/Feature/ModuleTest.php | 48 +++ tests/Feature/RequestResponseTest.php | 35 ++ tests/Feature/RoutingTest.php | 51 +++ tests/Feature/ValidationTest.php | 81 ++++ tests/Fixtures/TestControllers.php | 170 ++++++++ tests/Support/TestSupport.php | 47 +++ 12 files changed, 627 insertions(+), 561 deletions(-) create mode 100644 tests/Feature/AppDispatchTest.php create mode 100644 tests/Feature/MiddlewareTest.php create mode 100644 tests/Feature/ModuleTest.php create mode 100644 tests/Feature/RequestResponseTest.php create mode 100644 tests/Feature/RoutingTest.php create mode 100644 tests/Feature/ValidationTest.php create mode 100644 tests/Fixtures/TestControllers.php create mode 100644 tests/Support/TestSupport.php diff --git a/README.md b/README.md index f31dcbe..46c5633 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,8 @@ Run the lightweight API core test suite: composer test ``` +The test runner lives in `tests/ApiCoreTest.php`. Shared helpers and fixtures live in `tests/Support` and `tests/Fixtures`, while feature test files live in `tests/Feature`. + ## Release Process Every pull request must have exactly one version label, for example `v0.6`. diff --git a/REFACTORING.md b/REFACTORING.md index 54b2fdc..9498e97 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -37,6 +37,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] GitHub API workflow with PHP 8.3 checks. - [x] PR version label validation. - [x] Release workflow using PR summary as release notes. +- [x] Split API core tests into runner, support, fixtures, and feature files. ## Modular Monolith Direction diff --git a/docs/index.html b/docs/index.html index 4f3b310..7b93dbb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -410,10 +410,12 @@

Errors

Testing

-

The current lightweight test suite is in tests/ApiCoreTest.php.

+

The current lightweight test runner is tests/ApiCoreTest.php.

composer test
+

Shared assertions, request helpers, and emitters live in tests/Support. Test-only controllers, DTOs, middleware, and auth fixtures live in tests/Fixtures. Feature test files live in tests/Feature.

+

The API workflow also validates Composer configuration, dumps autoload files, lints PHP files, runs the API tests, and verifies the route list command.

diff --git a/tests/ApiCoreTest.php b/tests/ApiCoreTest.php index ad4e645..b256c17 100644 --- a/tests/ApiCoreTest.php +++ b/tests/ApiCoreTest.php @@ -1,574 +1,23 @@ statusCode = $response->getStatusCode(); - $this->headers = $response->getHeaders(); - $this->content = $response->getContent(); - } -} - -#[RoutePrefix('/test')] -final class TestAttributeController extends Controller -{ - #[Get('/api/{argument}')] - public function api(#[PathParam] ?string $argument = null, #[QueryParam('include')] ?string $include = null): JsonResponse - { - $arguments = []; - if ($argument !== null) { - $arguments[] = $argument; - } - - return $this->json([ - 'data' => [ - 'arguments' => $arguments, - 'include' => $include, - 'routeParams' => $this->request->getRouteParams(), - ], - ]); - } - - #[Post('/created')] - #[Status(201)] - #[Header('X-Test', 'created')] - public function created(#[Body('name')] string $name): array - { - return [ - 'name' => $name, - 'created' => true, - ]; - } -} - -final class HeaderMiddleware implements MiddlewareInterface -{ - public function handle(Request $request, callable $next): Response - { - $response = $next($request); - - return new Response( - $response->getContent(), - $response->getStatusCode(), - $response->getHeaders() + ['X-Middleware' => 'class'] - ); - } -} - -final class AutowiredGreetingService -{ - public function message(): string - { - return 'autowired'; - } -} - -final class AutowiredConsumer -{ - public function __construct(public readonly AutowiredGreetingService $service) - { - } -} - -#[RoutePrefix('/autowired')] -final class AutowiredController extends Controller -{ - public function __construct(private readonly AutowiredGreetingService $service) - { - } - - #[Get('/service')] - public function service(): JsonResponse - { - return $this->json([ - 'message' => $this->service->message(), - 'path' => $this->getRequest()->getPath(), - ]); - } -} - -final class CreateUserDto extends RequestDto -{ - public function __construct( - public readonly string $email, - public readonly int $age - ) { - } - - public static function rules(): array - { - return [ - 'email' => 'required|string|email', - 'age' => 'required|int|min:18', - ]; - } -} - -#[RoutePrefix('/dto')] -final class DtoController extends Controller -{ - #[Post('/users')] - public function create(#[BodyDto] CreateUserDto $dto): array - { - return [ - 'email' => $dto->email, - 'age' => $dto->age, - ]; - } - - #[Post('/implicit')] - public function implicit(CreateUserDto $dto): array - { - return [ - 'email' => $dto->email, - 'age' => $dto->age, - ]; - } -} - -#[RoutePrefix('/auth')] -final class AuthenticatedController extends Controller -{ - #[Get('/me')] - public function me(Request $request): array - { - /** @var AuthenticatedUser|null $user */ - $user = $request->getAttribute(AuthenticatedUser::class); - - return [ - 'id' => $user?->id, - ]; - } -} - -final class HeaderAuthenticator implements AuthenticatorInterface -{ - public function authenticate(Request $request): ?AuthenticatedUser - { - return $request->getHeader('Authorization') === 'Bearer token' - ? new AuthenticatedUser('user-1') - : null; - } -} - -final class AllowAuthorizer implements AuthorizerInterface -{ - public function authorize(AuthenticatedUser $user, Request $request, ?string $ability = null): bool - { - return $ability === 'view'; - } -} - -function assertSameValue(mixed $expected, mixed $actual, string $message): void -{ - if ($expected !== $actual) { - throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true)); - } -} - -function assertArrayHasKeyValue(string $key, mixed $expected, array $actual, string $message): void -{ - if (!array_key_exists($key, $actual) || $actual[$key] !== $expected) { - throw new RuntimeException($message . "\nArray: " . var_export($actual, true)); - } -} - -function makeRequest(string $method, string $uri, string $body = '', array $query = []): Request -{ - return new Request( - [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $uri, - 'HTTP_AUTHORIZATION' => 'Bearer token', - ], - $query, - [], - $body - ); +foreach (glob(__DIR__ . '/Fixtures/*.php') ?: [] as $fixtureFile) { + require_once $fixtureFile; } $tests = []; -$tests['router matches route params'] = function (): void { - $router = new Router(); - $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); - - $match = $router->match(makeRequest('GET', '/test/api/example')); - - assertSameValue(['argument' => 'example'], $match->getParameters(), 'Route parameters should be extracted.'); -}; - -$tests['attribute loader registers controller routes'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [TestAttributeController::class]); - - $routes = array_map( - static fn (Route $route): string => $route->getMethod() . ' ' . $route->getPath(), - $router->getRoutes() - ); - - assertSameValue( - [ - 'GET /test/api/{argument}', - 'POST /test/created', - ], - $routes, - 'Attribute loader should register all test controller routes.' - ); -}; - -$tests['router returns 405 with Allow header'] = function (): void { - $router = new Router(); - $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); - - try { - $router->match(makeRequest('POST', '/test/api/example')); - } catch (HttpError $error) { - assertSameValue(405, $error->getStatusCode(), 'Wrong method should return 405.'); - assertArrayHasKeyValue('Allow', 'GET', $error->getHeaders(), '405 should expose allowed methods.'); - return; - } - - throw new RuntimeException('Expected HttpError was not thrown.'); -}; - -$tests['request parses json and headers'] = function (): void { - $request = makeRequest('POST', '/test/created', '{"name":"Shift"}'); - - assertSameValue(['name' => 'Shift'], $request->getJson(), 'JSON body should parse.'); - assertSameValue('Shift', $request->input('name'), 'Input should read JSON body.'); - assertSameValue('Bearer token', $request->getHeader('Authorization'), 'Header should be available.'); -}; - -$tests['request rejects malformed json'] = function (): void { - $request = makeRequest('POST', '/test/created', '{bad'); - - try { - $request->getJson(); - } catch (HttpError $error) { - assertSameValue(400, $error->getStatusCode(), 'Malformed JSON should return 400.'); - return; - } - - throw new RuntimeException('Expected malformed JSON error was not thrown.'); -}; - -$tests['json response encodes payload'] = function (): void { - $response = JsonResponse::ok(['status' => 'ok']); - - assertSameValue(200, $response->getStatusCode(), 'JSON response should default to 200.'); - assertArrayHasKeyValue('Content-Type', 'application/json', $response->getHeaders(), 'JSON response should set content type.'); - assertSameValue('{"status":"ok"}', $response->getContent(), 'JSON response should encode payload.'); -}; - -$tests['app dispatches route to controller'] = function (): void { - $router = new Router(); - $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(200, $emitter->statusCode, 'App should emit successful status.'); - assertSameValue('demo', $payload['data']['routeParams']['argument'] ?? null, 'App should pass route params to controller.'); -}; - -$tests['app autowires controller constructor dependencies'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [AutowiredController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('GET', '/autowired/service'), $router, $emitter); - $app->getContainer()->singleton(AutowiredGreetingService::class, AutowiredGreetingService::class); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(200, $emitter->statusCode, 'Autowired controller should emit successful status.'); - assertSameValue('autowired', $payload['message'] ?? null, 'Controller dependency should be injected from the container.'); - assertSameValue('/autowired/service', $payload['path'] ?? null, 'Autowired controller should retain base controller context.'); -}; - -$tests['service container makes classes with typed dependencies'] = function (): void { - $container = new ServiceContainer(); - $container->singleton(AutowiredGreetingService::class, AutowiredGreetingService::class); - - $consumer = $container->make(AutowiredConsumer::class); - - assertSameValue('autowired', $consumer->service->message(), 'Container should autowire typed constructor dependencies.'); -}; - -$tests['app binds path and query params from attributes'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [TestAttributeController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('GET', '/test/api/demo', '', ['include' => 'details']), $router, $emitter); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue('demo', $payload['data']['arguments'][0] ?? null, 'PathParam should bind route value.'); - assertSameValue('details', $payload['data']['include'] ?? null, 'QueryParam should bind query value.'); -}; - -$tests['app applies status header and body attributes'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [TestAttributeController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('POST', '/test/created', '{"name":"Shift"}'), $router, $emitter); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(201, $emitter->statusCode, 'Status attribute should override response status.'); - assertArrayHasKeyValue('X-Test', 'created', $emitter->headers, 'Header attribute should add response header.'); - assertSameValue('Shift', $payload['name'] ?? null, 'Body attribute should bind JSON body key.'); -}; - -$tests['app runs middleware around controller dispatch'] = function (): void { - $router = new Router(); - $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); - $emitter = new CapturingEmitter(); - $events = []; - - $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); - $app->middleware(function (Request $request, callable $next) use (&$events): Response { - $events[] = 'before'; - $response = $next($request); - $events[] = 'after'; - - return new Response( - $response->getContent(), - $response->getStatusCode(), - $response->getHeaders() + ['X-Middleware' => 'callable'] - ); - }); - $app->start(); - - assertSameValue(['before', 'after'], $events, 'Middleware should wrap controller dispatch.'); - assertArrayHasKeyValue('X-Middleware', 'callable', $emitter->headers, 'Middleware should be able to modify response headers.'); -}; - -$tests['app supports middleware classes'] = function (): void { - $router = new Router(); - $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); - $app->middleware(HeaderMiddleware::class); - $app->start(); - - assertArrayHasKeyValue('X-Middleware', 'class', $emitter->headers, 'Middleware class should be resolved and executed.'); -}; - -$tests['middleware can short-circuit request handling'] = function (): void { - $router = new Router(); - $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); - $app->middleware(static fn (Request $request, callable $next): Response => JsonResponse::error('Blocked', 403)); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(403, $emitter->statusCode, 'Middleware should be able to short-circuit the request.'); - assertSameValue('Blocked', $payload['error']['message'] ?? null, 'Short-circuit response should be emitted.'); -}; - -$tests['validator returns validated typed data'] = function (): void { - $validated = (new Validator())->validate( - ['email' => 'dev@example.com', 'age' => '21', 'active' => 'true'], - [ - 'email' => 'required|email', - 'age' => 'required|int|min:18', - 'active' => 'bool', - ] - ); +foreach (glob(__DIR__ . '/Feature/*Test.php') ?: [] as $testFile) { + $loadedTests = require $testFile; - assertSameValue('dev@example.com', $validated['email'], 'Validator should keep valid email.'); - assertSameValue(21, $validated['age'], 'Validator should cast integers.'); - assertSameValue(true, $validated['active'], 'Validator should cast booleans.'); -}; - -$tests['validator throws validation exception'] = function (): void { - try { - (new Validator())->validate(['email' => 'bad'], ['email' => 'required|email', 'age' => 'required|int']); - } catch (ValidationException $exception) { - assertSameValue(422, $exception->getStatusCode(), 'Validation errors should use HTTP 422.'); - assertSameValue(true, isset($exception->getErrors()['email']), 'Validation errors should include invalid fields.'); - assertSameValue(true, isset($exception->getErrors()['age']), 'Validation errors should include missing required fields.'); - return; + if (!is_array($loadedTests)) { + throw new RuntimeException("Test file {$testFile} must return an array."); } - throw new RuntimeException('Expected validation exception was not thrown.'); -}; - -$tests['app binds body dto parameters'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [DtoController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('POST', '/dto/users', '{"email":"dev@example.com","age":"22"}'), $router, $emitter); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(200, $emitter->statusCode, 'Valid DTO payload should pass.'); - assertSameValue('dev@example.com', $payload['email'] ?? null, 'DTO should expose validated email.'); - assertSameValue(22, $payload['age'] ?? null, 'DTO should expose cast age.'); -}; - -$tests['app auto-binds request dto parameters by type'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [DtoController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('POST', '/dto/implicit', '{"email":"dev@example.com","age":"22"}'), $router, $emitter); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(200, $emitter->statusCode, 'Implicit DTO payload should pass.'); - assertSameValue(22, $payload['age'] ?? null, 'Implicit DTO should be bound by type.'); -}; - -$tests['app emits validation errors as json'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [DtoController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('POST', '/dto/users', '{"email":"bad","age":15}'), $router, $emitter); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(422, $emitter->statusCode, 'Invalid DTO payload should return 422.'); - assertSameValue('Validation failed', $payload['error']['message'] ?? null, 'Validation response should include message.'); - assertSameValue(true, isset($payload['error']['context']['errors']['email']), 'Validation response should include field errors.'); -}; - -$tests['cors middleware handles preflight requests'] = function (): void { - $router = new Router(); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('OPTIONS', '/anything'), $router, $emitter); - $app->middleware(new CorsMiddleware()); - $app->start(); - - assertSameValue(204, $emitter->statusCode, 'CORS preflight should short-circuit with 204.'); - assertArrayHasKeyValue('Access-Control-Allow-Origin', '*', $emitter->headers, 'CORS should expose allowed origin.'); -}; - -$tests['auth middleware authenticates request user'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [AuthenticatedController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(makeRequest('GET', '/auth/me'), $router, $emitter); - $app->middleware(new AuthMiddleware(new HeaderAuthenticator())); - $app->middleware(new AuthorizationMiddleware(new AllowAuthorizer(), 'view')); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(200, $emitter->statusCode, 'Authenticated request should continue.'); - assertSameValue('user-1', $payload['id'] ?? null, 'Authenticated user should be stored on the request.'); -}; - -$tests['auth middleware rejects unauthenticated requests'] = function (): void { - $router = new Router(); - (new AttributeRouteLoader())->load($router, [AuthenticatedController::class]); - $emitter = new CapturingEmitter(); - - $app = new App(new Request(['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/auth/me']), $router, $emitter); - $app->middleware(new AuthMiddleware(new HeaderAuthenticator())); - $app->start(); - - assertSameValue(401, $emitter->statusCode, 'Unauthenticated request should return 401.'); -}; - -$tests['module loader registers services and routes'] = function (): void { - $loader = (new ModuleLoader())->load(); - $router = new Router(); - $container = new ServiceContainer(); - - $loader->registerServices($container); - $loader->registerRoutes($router); - $loader->boot($container); - - assertSameValue(true, $container->has(HealthService::class), 'Health module service should be registered.'); - assertSameValue(true, $container->resolve('health.booted'), 'Health module boot hook should run.'); - assertSameValue(true, $loader->getConfig('health')['enabled'] ?? null, 'Health module config file should load.'); - assertSameValue('health', $loader->getConfig('health')['module'] ?? null, 'Health module config method should merge.'); - - $routes = array_map( - static fn (Route $route): string => $route->getMethod() . ' ' . $route->getPath(), - $router->getRoutes() - ); - - assertSameValue(['GET /health'], $routes, 'Health module route should be registered.'); -}; - -$tests['app dispatches module controller with service'] = function (): void { - $loader = (new ModuleLoader())->load(); - $router = new Router(); - $emitter = new CapturingEmitter(); - $app = new App(makeRequest('GET', '/health'), $router, $emitter); - - $loader->registerServices($app->getContainer()); - $loader->registerRoutes($router); - $app->start(); - - $payload = json_decode($emitter->content, true); - - assertSameValue(200, $emitter->statusCode, 'Module route should emit successful status.'); - assertSameValue('health', $payload['module'] ?? null, 'Module controller should resolve its service.'); -}; + $tests = array_merge($tests, $loadedTests); +} $failed = 0; diff --git a/tests/Feature/AppDispatchTest.php b/tests/Feature/AppDispatchTest.php new file mode 100644 index 0000000..05575ac --- /dev/null +++ b/tests/Feature/AppDispatchTest.php @@ -0,0 +1,76 @@ + function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'App should emit successful status.'); + assertSameValue('demo', $payload['data']['routeParams']['argument'] ?? null, 'App should pass route params to controller.'); + }, + + 'app autowires controller constructor dependencies' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [AutowiredController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/autowired/service'), $router, $emitter); + $app->getContainer()->singleton(AutowiredGreetingService::class, AutowiredGreetingService::class); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'Autowired controller should emit successful status.'); + assertSameValue('autowired', $payload['message'] ?? null, 'Controller dependency should be injected from the container.'); + assertSameValue('/autowired/service', $payload['path'] ?? null, 'Autowired controller should retain base controller context.'); + }, + + 'service container makes classes with typed dependencies' => function (): void { + $container = new ServiceContainer(); + $container->singleton(AutowiredGreetingService::class, AutowiredGreetingService::class); + + $consumer = $container->make(AutowiredConsumer::class); + + assertSameValue('autowired', $consumer->service->message(), 'Container should autowire typed constructor dependencies.'); + }, + + 'app binds path and query params from attributes' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [TestAttributeController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/test/api/demo', '', ['include' => 'details']), $router, $emitter); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue('demo', $payload['data']['arguments'][0] ?? null, 'PathParam should bind route value.'); + assertSameValue('details', $payload['data']['include'] ?? null, 'QueryParam should bind query value.'); + }, + + 'app applies status header and body attributes' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [TestAttributeController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('POST', '/test/created', '{"name":"Shift"}'), $router, $emitter); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(201, $emitter->statusCode, 'Status attribute should override response status.'); + assertArrayHasKeyValue('X-Test', 'created', $emitter->headers, 'Header attribute should add response header.'); + assertSameValue('Shift', $payload['name'] ?? null, 'Body attribute should bind JSON body key.'); + }, +]; diff --git a/tests/Feature/MiddlewareTest.php b/tests/Feature/MiddlewareTest.php new file mode 100644 index 0000000..7c98a94 --- /dev/null +++ b/tests/Feature/MiddlewareTest.php @@ -0,0 +1,104 @@ + function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + $emitter = new CapturingEmitter(); + $events = []; + + $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); + $app->middleware(function (Request $request, callable $next) use (&$events): Response { + $events[] = 'before'; + $response = $next($request); + $events[] = 'after'; + + return new Response( + $response->getContent(), + $response->getStatusCode(), + $response->getHeaders() + ['X-Middleware' => 'callable'] + ); + }); + $app->start(); + + assertSameValue(['before', 'after'], $events, 'Middleware should wrap controller dispatch.'); + assertArrayHasKeyValue('X-Middleware', 'callable', $emitter->headers, 'Middleware should be able to modify response headers.'); + }, + + 'app supports middleware classes' => function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); + $app->middleware(HeaderMiddleware::class); + $app->start(); + + assertArrayHasKeyValue('X-Middleware', 'class', $emitter->headers, 'Middleware class should be resolved and executed.'); + }, + + 'middleware can short-circuit request handling' => function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); + $app->middleware(static fn (Request $request, callable $next): Response => JsonResponse::error('Blocked', 403)); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(403, $emitter->statusCode, 'Middleware should be able to short-circuit the request.'); + assertSameValue('Blocked', $payload['error']['message'] ?? null, 'Short-circuit response should be emitted.'); + }, + + 'cors middleware handles preflight requests' => function (): void { + $router = new Router(); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('OPTIONS', '/anything'), $router, $emitter); + $app->middleware(new CorsMiddleware()); + $app->start(); + + assertSameValue(204, $emitter->statusCode, 'CORS preflight should short-circuit with 204.'); + assertArrayHasKeyValue('Access-Control-Allow-Origin', '*', $emitter->headers, 'CORS should expose allowed origin.'); + }, + + 'auth middleware authenticates request user' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [AuthenticatedController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/auth/me'), $router, $emitter); + $app->middleware(new AuthMiddleware(new HeaderAuthenticator())); + $app->middleware(new AuthorizationMiddleware(new AllowAuthorizer(), 'view')); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'Authenticated request should continue.'); + assertSameValue('user-1', $payload['id'] ?? null, 'Authenticated user should be stored on the request.'); + }, + + 'auth middleware rejects unauthenticated requests' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [AuthenticatedController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(new Request(['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/auth/me']), $router, $emitter); + $app->middleware(new AuthMiddleware(new HeaderAuthenticator())); + $app->start(); + + assertSameValue(401, $emitter->statusCode, 'Unauthenticated request should return 401.'); + }, +]; diff --git a/tests/Feature/ModuleTest.php b/tests/Feature/ModuleTest.php new file mode 100644 index 0000000..1f4a160 --- /dev/null +++ b/tests/Feature/ModuleTest.php @@ -0,0 +1,48 @@ + function (): void { + $loader = (new ModuleLoader())->load(); + $router = new Router(); + $container = new ServiceContainer(); + + $loader->registerServices($container); + $loader->registerRoutes($router); + $loader->boot($container); + + assertSameValue(true, $container->has(HealthService::class), 'Health module service should be registered.'); + assertSameValue(true, $container->resolve('health.booted'), 'Health module boot hook should run.'); + assertSameValue(true, $loader->getConfig('health')['enabled'] ?? null, 'Health module config file should load.'); + assertSameValue('health', $loader->getConfig('health')['module'] ?? null, 'Health module config method should merge.'); + + $routes = array_map( + static fn (Route $route): string => $route->getMethod() . ' ' . $route->getPath(), + $router->getRoutes() + ); + + assertSameValue(['GET /health'], $routes, 'Health module route should be registered.'); + }, + + 'app dispatches module controller with service' => function (): void { + $loader = (new ModuleLoader())->load(); + $router = new Router(); + $emitter = new CapturingEmitter(); + $app = new App(makeRequest('GET', '/health'), $router, $emitter); + + $loader->registerServices($app->getContainer()); + $loader->registerRoutes($router); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'Module route should emit successful status.'); + assertSameValue('health', $payload['module'] ?? null, 'Module controller should resolve its service.'); + }, +]; diff --git a/tests/Feature/RequestResponseTest.php b/tests/Feature/RequestResponseTest.php new file mode 100644 index 0000000..9a7b16d --- /dev/null +++ b/tests/Feature/RequestResponseTest.php @@ -0,0 +1,35 @@ + function (): void { + $request = makeRequest('POST', '/test/created', '{"name":"Shift"}'); + + assertSameValue(['name' => 'Shift'], $request->getJson(), 'JSON body should parse.'); + assertSameValue('Shift', $request->input('name'), 'Input should read JSON body.'); + assertSameValue('Bearer token', $request->getHeader('Authorization'), 'Header should be available.'); + }, + + 'request rejects malformed json' => function (): void { + $request = makeRequest('POST', '/test/created', '{bad'); + + try { + $request->getJson(); + } catch (HttpError $error) { + assertSameValue(400, $error->getStatusCode(), 'Malformed JSON should return 400.'); + return; + } + + throw new RuntimeException('Expected malformed JSON error was not thrown.'); + }, + + 'json response encodes payload' => function (): void { + $response = JsonResponse::ok(['status' => 'ok']); + + assertSameValue(200, $response->getStatusCode(), 'JSON response should default to 200.'); + assertArrayHasKeyValue('Content-Type', 'application/json', $response->getHeaders(), 'JSON response should set content type.'); + assertSameValue('{"status":"ok"}', $response->getContent(), 'JSON response should encode payload.'); + }, +]; diff --git a/tests/Feature/RoutingTest.php b/tests/Feature/RoutingTest.php new file mode 100644 index 0000000..0076759 --- /dev/null +++ b/tests/Feature/RoutingTest.php @@ -0,0 +1,51 @@ + function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + + $match = $router->match(makeRequest('GET', '/test/api/example')); + + assertSameValue(['argument' => 'example'], $match->getParameters(), 'Route parameters should be extracted.'); + }, + + 'attribute loader registers controller routes' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [TestAttributeController::class]); + + $routes = array_map( + static fn (Route $route): string => $route->getMethod() . ' ' . $route->getPath(), + $router->getRoutes() + ); + + assertSameValue( + [ + 'GET /test/api/{argument}', + 'POST /test/created', + ], + $routes, + 'Attribute loader should register all test controller routes.' + ); + }, + + 'router returns 405 with Allow header' => function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + + try { + $router->match(makeRequest('POST', '/test/api/example')); + } catch (HttpError $error) { + assertSameValue(405, $error->getStatusCode(), 'Wrong method should return 405.'); + assertArrayHasKeyValue('Allow', 'GET', $error->getHeaders(), '405 should expose allowed methods.'); + return; + } + + throw new RuntimeException('Expected HttpError was not thrown.'); + }, +]; diff --git a/tests/Feature/ValidationTest.php b/tests/Feature/ValidationTest.php new file mode 100644 index 0000000..3489e65 --- /dev/null +++ b/tests/Feature/ValidationTest.php @@ -0,0 +1,81 @@ + function (): void { + $validated = (new Validator())->validate( + ['email' => 'dev@example.com', 'age' => '21', 'active' => 'true'], + [ + 'email' => 'required|email', + 'age' => 'required|int|min:18', + 'active' => 'bool', + ] + ); + + assertSameValue('dev@example.com', $validated['email'], 'Validator should keep valid email.'); + assertSameValue(21, $validated['age'], 'Validator should cast integers.'); + assertSameValue(true, $validated['active'], 'Validator should cast booleans.'); + }, + + 'validator throws validation exception' => function (): void { + try { + (new Validator())->validate(['email' => 'bad'], ['email' => 'required|email', 'age' => 'required|int']); + } catch (ValidationException $exception) { + assertSameValue(422, $exception->getStatusCode(), 'Validation errors should use HTTP 422.'); + assertSameValue(true, isset($exception->getErrors()['email']), 'Validation errors should include invalid fields.'); + assertSameValue(true, isset($exception->getErrors()['age']), 'Validation errors should include missing required fields.'); + return; + } + + throw new RuntimeException('Expected validation exception was not thrown.'); + }, + + 'app binds body dto parameters' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [DtoController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('POST', '/dto/users', '{"email":"dev@example.com","age":"22"}'), $router, $emitter); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'Valid DTO payload should pass.'); + assertSameValue('dev@example.com', $payload['email'] ?? null, 'DTO should expose validated email.'); + assertSameValue(22, $payload['age'] ?? null, 'DTO should expose cast age.'); + }, + + 'app auto-binds request dto parameters by type' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [DtoController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('POST', '/dto/implicit', '{"email":"dev@example.com","age":"22"}'), $router, $emitter); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'Implicit DTO payload should pass.'); + assertSameValue(22, $payload['age'] ?? null, 'Implicit DTO should be bound by type.'); + }, + + 'app emits validation errors as json' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [DtoController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('POST', '/dto/users', '{"email":"bad","age":15}'), $router, $emitter); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(422, $emitter->statusCode, 'Invalid DTO payload should return 422.'); + assertSameValue('Validation failed', $payload['error']['message'] ?? null, 'Validation response should include message.'); + assertSameValue(true, isset($payload['error']['context']['errors']['email']), 'Validation response should include field errors.'); + }, +]; diff --git a/tests/Fixtures/TestControllers.php b/tests/Fixtures/TestControllers.php new file mode 100644 index 0000000..6b1f7fd --- /dev/null +++ b/tests/Fixtures/TestControllers.php @@ -0,0 +1,170 @@ +json([ + 'data' => [ + 'arguments' => $arguments, + 'include' => $include, + 'routeParams' => $this->request->getRouteParams(), + ], + ]); + } + + #[Post('/created')] + #[Status(201)] + #[Header('X-Test', 'created')] + public function created(#[Body('name')] string $name): array + { + return [ + 'name' => $name, + 'created' => true, + ]; + } +} + +final class HeaderMiddleware implements MiddlewareInterface +{ + public function handle(Request $request, callable $next): Response + { + $response = $next($request); + + return new Response( + $response->getContent(), + $response->getStatusCode(), + $response->getHeaders() + ['X-Middleware' => 'class'] + ); + } +} + +final class AutowiredGreetingService +{ + public function message(): string + { + return 'autowired'; + } +} + +final class AutowiredConsumer +{ + public function __construct(public readonly AutowiredGreetingService $service) + { + } +} + +#[RoutePrefix('/autowired')] +final class AutowiredController extends Controller +{ + public function __construct(private readonly AutowiredGreetingService $service) + { + } + + #[Get('/service')] + public function service(): JsonResponse + { + return $this->json([ + 'message' => $this->service->message(), + 'path' => $this->getRequest()->getPath(), + ]); + } +} + +final class CreateUserDto extends RequestDto +{ + public function __construct( + public readonly string $email, + public readonly int $age + ) { + } + + public static function rules(): array + { + return [ + 'email' => 'required|string|email', + 'age' => 'required|int|min:18', + ]; + } +} + +#[RoutePrefix('/dto')] +final class DtoController extends Controller +{ + #[Post('/users')] + public function create(#[BodyDto] CreateUserDto $dto): array + { + return [ + 'email' => $dto->email, + 'age' => $dto->age, + ]; + } + + #[Post('/implicit')] + public function implicit(CreateUserDto $dto): array + { + return [ + 'email' => $dto->email, + 'age' => $dto->age, + ]; + } +} + +#[RoutePrefix('/auth')] +final class AuthenticatedController extends Controller +{ + #[Get('/me')] + public function me(Request $request): array + { + /** @var AuthenticatedUser|null $user */ + $user = $request->getAttribute(AuthenticatedUser::class); + + return [ + 'id' => $user?->id, + ]; + } +} + +final class HeaderAuthenticator implements AuthenticatorInterface +{ + public function authenticate(Request $request): ?AuthenticatedUser + { + return $request->getHeader('Authorization') === 'Bearer token' + ? new AuthenticatedUser('user-1') + : null; + } +} + +final class AllowAuthorizer implements AuthorizerInterface +{ + public function authorize(AuthenticatedUser $user, Request $request, ?string $ability = null): bool + { + return $ability === 'view'; + } +} diff --git a/tests/Support/TestSupport.php b/tests/Support/TestSupport.php new file mode 100644 index 0000000..063b195 --- /dev/null +++ b/tests/Support/TestSupport.php @@ -0,0 +1,47 @@ +statusCode = $response->getStatusCode(); + $this->headers = $response->getHeaders(); + $this->content = $response->getContent(); + } +} + +function assertSameValue(mixed $expected, mixed $actual, string $message): void +{ + if ($expected !== $actual) { + throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true)); + } +} + +function assertArrayHasKeyValue(string $key, mixed $expected, array $actual, string $message): void +{ + if (!array_key_exists($key, $actual) || $actual[$key] !== $expected) { + throw new RuntimeException($message . "\nArray: " . var_export($actual, true)); + } +} + +function makeRequest(string $method, string $uri, string $body = '', array $query = []): Request +{ + return new Request( + [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $uri, + 'HTTP_AUTHORIZATION' => 'Bearer token', + ], + $query, + [], + $body + ); +}