From cbde76e90373d881c9aa62b45a8f8d451284ff2e Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:26:55 -0300 Subject: [PATCH 1/7] =?UTF-8?q?Configura=20Scramble:=20agrupa=20doc=20por?= =?UTF-8?q?=20m=C3=B3dulo=20e=20protege=20fora=20de=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/Providers/AppServiceProvider.php | 41 +++- backend/config/scramble.php | 193 +++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 backend/config/scramble.php diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 6f63aff..856e0fd 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -4,7 +4,14 @@ namespace App\Providers; +use App\Models\User; +use Dedoc\Scramble\Scramble; +use Dedoc\Scramble\Support\RouteInfo; +use Illuminate\Routing\RouteRegistrar; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Str; class AppServiceProvider extends ServiceProvider { @@ -16,5 +23,37 @@ public function register(): void {} /** * Bootstrap any application services. */ - public function boot(): void {} + public function boot(): void + { + $this->registerApiRouteMacro(); + $this->registerApiDocsGroupingByModule(); + + Gate::define('viewApiDocs', fn (?User $user = null): bool => $user instanceof User); + } + + private function registerApiRouteMacro(): void + { + Route::macro('apiV1', function (?string $prefix = null): RouteRegistrar { + $path = 'api/v1'.($prefix ? "/{$prefix}" : ''); + + return Route::middleware('api')->prefix($path); + }); + } + + private function registerApiDocsGroupingByModule(): void + { + Scramble::resolveTagsUsing(function (RouteInfo $routeInfo): array { + $className = $routeInfo->className(); + + if ($className !== null && preg_match('/^PHPeste\\\\([^\\\\]+)\\\\/', $className, $matches) === 1) { + return [$matches[1]]; + } + + $default = $className !== null + ? Str::of(class_basename($className))->replace('Controller', '')->toString() + : null; + + return array_filter([$default]); + }); + } } diff --git a/backend/config/scramble.php b/backend/config/scramble.php new file mode 100644 index 0000000..c411fdc --- /dev/null +++ b/backend/config/scramble.php @@ -0,0 +1,193 @@ + [ + * 'include' => 'api', + * 'exclude' => ['api/internal'], + * ], + * + * Without *, patterns match path segments (api matches api and api/users, not apiary). + * With *, Str::is is used (e.g. api/v*). + * + * One static include → default server is /{include} and paths are stripped (/users). + * Multiple includes or wildcards → server defaults to / and paths stay full (/api/users). + * Override with `servers`, or use Scramble::registerApi() for separate bases. + */ + 'api_path' => 'api', + + /* + * Your API domain. By default, app domain is used. This is also a part of the default API routes + * matcher, so when implementing your own, make sure you use this config if needed. + */ + 'api_domain' => null, + + /* + * The path where your OpenAPI specification will be exported. + */ + 'export_path' => 'api.json', + + /* + * Cache configuration for the generated OpenAPI document. + * + * Use `scramble:cache` to warm the cache and `scramble:clear` to invalidate it. + */ + 'cache' => [ + 'key' => 'scramble.openapi', + 'store' => 'file', + ], + + /* + * OpenAPI version of the resulting document. + */ + 'openapi_version' => OpenApiVersion::V3_1, + + 'info' => [ + /* + * API version. + */ + 'version' => env('API_VERSION', '0.0.1'), + + /* + * Description rendered on the home page of the API documentation (`/docs/api`). + */ + 'description' => '', + ], + + 'ui' => [ + 'title' => null, + ], + + /* + * Load Scramble's development tools on documentation pages. An explicit + * SCRAMBLE_DEV_TOOLS value takes precedence over APP_DEBUG. + */ + 'dev_tools' => [ + 'enabled' => env('SCRAMBLE_DEV_TOOLS', env('APP_DEBUG', false)), + ], + + 'renderer' => 'elements', + + 'renderers' => [ + /* + * Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options + */ + 'elements' => [ + 'view' => 'scramble::docs', + 'theme' => 'light', + 'hideTryIt' => false, + 'hideSchemas' => false, + 'logo' => '', + 'tryItCredentialsPolicy' => 'include', + 'layout' => 'responsive', + 'router' => 'hash', + ], + /* + * Scalar API reference config options: https://scalar.com/products/api-references/configuration + */ + 'scalar' => [ + 'view' => 'scramble::scalar', + 'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference', + 'theme' => 'laravel', + 'proxyUrl' => 'https://proxy.scalar.com', + 'darkMode' => false, + 'showDeveloperTools' => 'never', + 'agent' => ['disabled' => true], + 'credentials' => 'include', + ], + 'fabric' => [ + 'view' => 'scramble::fabric', + ], + ], + + /* + * The list of servers of the API. By default, when `null`, server URL will be created from + * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you + * will need to specify the local server URL manually (if needed). + * + * Example of non-default config (final URLs are generated using Laravel `url` helper): + * + * ```php + * 'servers' => [ + * 'Live' => 'api', + * 'Prod' => 'https://scramble.dedoc.co/api', + * ], + * ``` + */ + 'servers' => null, + + /** + * Determines how Scramble stores the descriptions of enum cases. + * Available options: + * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. + * - 'extension' – Case descriptions are stored in the `x-enumDescriptions` enum schema extension. + * + * @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions + * - false - Case descriptions are ignored. + */ + 'enum_cases_description_strategy' => 'description', + + /** + * Determines how Scramble stores the names of enum cases. + * Available options: + * - 'names' – Case names are stored in the `x-enumNames` enum schema extension. + * - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension. + * - false - Case names are not stored. + */ + 'enum_cases_names_strategy' => false, + + /** + * When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated + * OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until + * OpenAPI 3.2 is released and query string structure can be described properly. + * + * For example, this nested validation rule describes the object with `bar` property: + * `['foo.bar' => ['required', 'int']]`. + * + * When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so: + * `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`. + * + * When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so: + * `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`. + */ + 'flatten_deep_query_parameters' => true, + + 'middleware' => [ + 'web', + RestrictedDocsAccess::class, + ], + + 'extensions' => [], + + /* + * Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route + * middleware. + * + * Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`. + * When at least one documented route uses middleware matching the configured patterns (by default + * `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are + * marked as public (`security: []`). + * + * Set to `null` explicitly to disable. If you already configure security manually via + * `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes. + * + * Customize with a class-string or [class, options]: + * + * 'security_strategy' => [ + * \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + * [ + * 'middleware' => ['auth', 'auth:*'], + * 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'), + * ], + * ], + */ + // 'security_strategy' => \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + 'security_strategy' => null, +]; From 2f6021d9957a604f1a94a55b0b1f0a4ac8728bf3 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:26:55 -0300 Subject: [PATCH 2/7] =?UTF-8?q?Adiciona=20macro=20Route::apiV1=20e=20rota?= =?UTF-8?q?=20/api/v1/ping=20de=20refer=C3=AAncia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Http/Controllers/Api/PingController.php | 25 +++++++++++++++++++ backend/bootstrap/app.php | 21 +++++++++++++++- backend/routes/api.php | 10 ++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 backend/app/Http/Controllers/Api/PingController.php create mode 100644 backend/routes/api.php diff --git a/backend/app/Http/Controllers/Api/PingController.php b/backend/app/Http/Controllers/Api/PingController.php new file mode 100644 index 0000000..064a790 --- /dev/null +++ b/backend/app/Http/Controllers/Api/PingController.php @@ -0,0 +1,25 @@ +json([ + 'status' => 'ok', + 'request_id' => request()->attributes->get('request_id'), + ]); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index eda1122..b8baa5d 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -2,9 +2,13 @@ declare(strict_types=1); +use App\Http\Middleware\AssignRequestId; +use App\Http\Middleware\EnsureIdempotencyKey; +use App\Support\Api\ApiError; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) @@ -12,10 +16,25 @@ web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', + then: function (): void { + require __DIR__.'/../routes/api.php'; + }, ) - ->withMiddleware(function (Middleware $middleware): void {}) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->append(AssignRequestId::class); + + $middleware->alias([ + 'idempotent' => EnsureIdempotencyKey::class, + ]); + }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( fn (Request $request): bool => $request->is('api/*') || $request->expectsJson(), ); + + $exceptions->render( + fn (Throwable $e, Request $request): ?JsonResponse => $request->is('api/*') + ? ApiError::render($e, $request) + : null, + ); })->create(); diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..2954ed2 --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,10 @@ +group(function (): void { + Route::get('/ping', PingController::class)->name('api.ping'); +}); From 45f01ddf24c034442b3c0d6d79a078e0066f74fe Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:26:55 -0300 Subject: [PATCH 3/7] =?UTF-8?q?Adiciona=20envelope=20de=20erro=20=C3=BAnic?= =?UTF-8?q?o=20da=20API=20(code,=20message,=20errors,=20request=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/Support/Api/ApiError.php | 73 ++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 backend/app/Support/Api/ApiError.php diff --git a/backend/app/Support/Api/ApiError.php b/backend/app/Support/Api/ApiError.php new file mode 100644 index 0000000..a05236f --- /dev/null +++ b/backend/app/Support/Api/ApiError.php @@ -0,0 +1,73 @@ +errors(), $request); + } + + [$status, $code, $message] = self::resolve($e); + + return self::json($status, $code, $message, null, $request); + } + + /** + * @param array|null $errors + */ + private static function json(int $status, string $code, string $message, ?array $errors, Request $request): JsonResponse + { + return response()->json([ + 'code' => $code, + 'message' => $message, + 'errors' => $errors, + 'request_id' => $request->attributes->get('request_id'), + ], $status); + } + + /** + * @return array{0: int, 1: string, 2: string} + */ + private static function resolve(Throwable $e): array + { + return match (true) { + $e instanceof AuthenticationException => [ + 401, 'unauthenticated', 'É preciso autenticar para acessar este recurso.', + ], + $e instanceof AccessDeniedHttpException => [ + 403, 'forbidden', 'Você não tem permissão para esta ação.', + ], + $e instanceof NotFoundHttpException => [ + 404, 'not_found', 'Recurso não encontrado.', + ], + $e instanceof MethodNotAllowedHttpException => [ + 405, 'method_not_allowed', 'Método HTTP não permitido para esta rota.', + ], + $e instanceof TooManyRequestsHttpException => [ + 429, 'too_many_requests', 'Muitas tentativas. Tente novamente mais tarde.', + ], + $e instanceof HttpExceptionInterface => [ + $e->getStatusCode(), 'http_error', $e->getMessage() ?: 'Erro ao processar a requisição.', + ], + default => [ + 500, 'server_error', config('app.debug') ? $e->getMessage() : 'Erro interno. Tente novamente mais tarde.', + ], + }; + } +} From fd497c04552392b905a97eff80f55d63c66e70db Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:26:55 -0300 Subject: [PATCH 4/7] Adiciona middlewares X-Request-Id e Idempotency-Key --- .../app/Http/Middleware/AssignRequestId.php | 32 ++++++++++++ .../Http/Middleware/EnsureIdempotencyKey.php | 51 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 backend/app/Http/Middleware/AssignRequestId.php create mode 100644 backend/app/Http/Middleware/EnsureIdempotencyKey.php diff --git a/backend/app/Http/Middleware/AssignRequestId.php b/backend/app/Http/Middleware/AssignRequestId.php new file mode 100644 index 0000000..58ff6a9 --- /dev/null +++ b/backend/app/Http/Middleware/AssignRequestId.php @@ -0,0 +1,32 @@ +header('X-Request-Id')) && $header !== '' + ? $header + : (string) Str::uuid(); + + $request->attributes->set('request_id', $requestId); + Log::withContext(['request_id' => $requestId]); + + $response = $next($request); + $response->headers->set('X-Request-Id', $requestId); + + return $response; + } +} diff --git a/backend/app/Http/Middleware/EnsureIdempotencyKey.php b/backend/app/Http/Middleware/EnsureIdempotencyKey.php new file mode 100644 index 0000000..4677bcf --- /dev/null +++ b/backend/app/Http/Middleware/EnsureIdempotencyKey.php @@ -0,0 +1,51 @@ +header('Idempotency-Key'); + + if (!is_string($key) || $key === '') { + return response()->json([ + 'code' => 'idempotency_key_required', + 'message' => 'O header Idempotency-Key é obrigatório para esta operação.', + 'errors' => null, + 'request_id' => $request->attributes->get('request_id'), + ], 400); + } + + $cacheKey = "idempotency:{$key}"; + + /** @var array{status: int, body: array}|null $cached */ + $cached = Cache::get($cacheKey); + + if ($cached !== null) { + return response()->json($cached['body'], $cached['status']); + } + + $response = $next($request); + + if ($response instanceof JsonResponse && $response->getStatusCode() < 500) { + Cache::put($cacheKey, [ + 'status' => $response->getStatusCode(), + 'body' => $response->getData(true), + ], now()->addDay()); + } + + return $response; + } +} From 3e3da275c9183d1cf14eb3a75e8b0a542152112e Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:26:55 -0300 Subject: [PATCH 5/7] Adiciona ApiResource base com helpers de dinheiro e data ISO8601 --- backend/app/Http/Resources/ApiResource.php | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 backend/app/Http/Resources/ApiResource.php diff --git a/backend/app/Http/Resources/ApiResource.php b/backend/app/Http/Resources/ApiResource.php new file mode 100644 index 0000000..99182a8 --- /dev/null +++ b/backend/app/Http/Resources/ApiResource.php @@ -0,0 +1,28 @@ + $cents, 'currency' => $currency]; + } + + protected function isoDate(?Carbon $date): ?string + { + return $date?->clone()->utc()->toIso8601ZuluString(); + } +} From c8f693c03defa16ae7f6d02f96b613c3c9c013af Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:26:55 -0300 Subject: [PATCH 6/7] =?UTF-8?q?Documenta=20as=20conven=C3=A7=C3=B5es=20da?= =?UTF-8?q?=20API=20em=20docs/api.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/api.md diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..455d966 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,65 @@ +# Convenções da API + +Admin, site público e app consomem a mesma API. Este documento é o contrato — qualquer endpoint novo segue isto, não reinventa. + +## Versionamento e rotas + +Toda rota de API vive sob `/api/v1`, registrada com a macro `Route::apiV1()` (definida em `app/Providers/AppServiceProvider.php`), nunca com `Route::prefix('api/v1')` escrito à mão: + +```php +// app-modules//routes/-routes.php +Route::apiV1('community')->group(function (): void { + Route::get('/editions', EditionsController::class)->name('community.editions.index'); +}); +``` + +Isso garante o mesmo middleware group (`api`) e o mesmo prefixo em todo módulo. Ver `routes/api.php` (`GET /api/v1/ping`) como referência mínima. + +## Envelope de erro + +Toda resposta de erro da API (`app/Support/Api/ApiError.php`) segue o mesmo formato, incluindo 404 e 500: + +```json +{ + "code": "validation_error", + "message": "Os dados enviados são inválidos.", + "errors": { "email": ["O campo email é obrigatório."] }, + "request_id": "20670a74-f702-4eae-8deb-53daaeff3cc1" +} +``` + +`code` por tipo de erro: `validation_error` (422), `unauthenticated` (401), `forbidden` (403), `not_found` (404), `method_not_allowed` (405), `too_many_requests` (429), `server_error` (500). `errors` só é preenchido em erro de validação; fora isso é `null`. + +## `X-Request-Id` + +Todo request (web ou API) ganha um `X-Request-Id` — o que vier no header da requisição é respeitado, senão a aplicação gera um UUID (`app/Http/Middleware/AssignRequestId.php`). O valor volta no header da resposta, entra em todo log da requisição e aparece no envelope de erro. + +## `Idempotency-Key` + +Endpoint que cria pedido ou cobrança usa o middleware `idempotent` (alias de `App\Http\Middleware\EnsureIdempotencyKey`): + +```php +Route::post('/orders', StoreOrderController::class)->middleware('idempotent'); +``` + +Sem o header `Idempotency-Key`, a requisição recebe 400. Com o header, a resposta da primeira chamada fica em cache por 24h; uma segunda chamada com a mesma chave recebe a resposta cacheada em vez de repetir o efeito colateral — é isso que impede dois ingressos quando o gateway reenvia o mesmo webhook. + +## Paginação + +- **Cursor** (`cursorPaginate()`): listas públicas ou potencialmente grandes (programação, propostas do CFP). Não permite pular página, mas escala sem `OFFSET`. +- **Página** (`paginate()`): telas administrativas (pedidos, participantes) onde navegar por número de página importa. + +As duas já serializam como `{data, links, meta}` pelo Resource padrão do Laravel — não precisa reimplementar isso, só escolher a estratégia certa por endpoint. + +## Resource padrão + +Toda API Resource estende `App\Http\Resources\ApiResource`, que traz: + +- `money(?int $cents, string $currency = 'BRL')` → `{"amount": 1990, "currency": "BRL"}`. Dinheiro é sempre inteiro em centavos, nunca float. +- `isoDate(?Carbon $date)` → string ISO 8601 em UTC (`2027-10-08T12:00:00Z`). + +## Documentação (Scramble) + +`GET /docs/api` (UI) e `GET /docs/api.json` (OpenAPI) são gerados automaticamente a partir das rotas e dos Form Requests/Resources — não se escreve YAML/JSON de OpenAPI à mão. Cada módulo vira um grupo na doc porque cada controller vive no namespace do próprio módulo (`PHPeste\\Http\Controllers`). + +Fora do ambiente `local`, a doc só é acessível a usuário autenticado (gate `viewApiDocs` em `AppServiceProvider`). From 1bf894ac043846d363f65b6378bc1d191ca34c10 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:27:03 -0300 Subject: [PATCH 7/7] Adiciona testes da B04: envelope de erro, ping, idempotency e cobertura na doc --- backend/phpunit.xml | 2 + backend/tests/Feature/.gitkeep | 0 .../tests/Feature/Api/DocsCoverageTest.php | 28 ++++++++++ .../tests/Feature/Api/IdempotencyKeyTest.php | 31 +++++++++++ backend/tests/Feature/Api/PingTest.php | 25 +++++++++ backend/tests/Unit/.gitkeep | 0 .../tests/Unit/Support/Api/ApiErrorTest.php | 51 +++++++++++++++++++ 7 files changed, 137 insertions(+) delete mode 100644 backend/tests/Feature/.gitkeep create mode 100644 backend/tests/Feature/Api/DocsCoverageTest.php create mode 100644 backend/tests/Feature/Api/IdempotencyKeyTest.php create mode 100644 backend/tests/Feature/Api/PingTest.php delete mode 100644 backend/tests/Unit/.gitkeep create mode 100644 backend/tests/Unit/Support/Api/ApiErrorTest.php diff --git a/backend/phpunit.xml b/backend/phpunit.xml index c4cef19..421c973 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -32,5 +32,7 @@ + + diff --git a/backend/tests/Feature/.gitkeep b/backend/tests/Feature/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/Feature/Api/DocsCoverageTest.php b/backend/tests/Feature/Api/DocsCoverageTest.php new file mode 100644 index 0000000..1cef2ec --- /dev/null +++ b/backend/tests/Feature/Api/DocsCoverageTest.php @@ -0,0 +1,28 @@ + 1, 'name' => 'Teste', 'email' => 'teste@phpeste.dev']); + + $response = $this->actingAs($user)->getJson('/docs/api.json'); + + $response->assertOk(); + + $documentedPaths = collect($response->json('paths'))->keys(); + + $apiV1Uris = collect(Route::getRoutes()) + ->map(fn ($route) => $route->uri()) + ->filter(fn (string $uri): bool => str_starts_with($uri, 'api/v1/')) + ->map(fn (string $uri): string => '/'.Str::after($uri, 'api/')); + + expect($apiV1Uris)->not->toBeEmpty(); + + foreach ($apiV1Uris as $uri) { + expect($documentedPaths)->toContain($uri); + } +}); diff --git a/backend/tests/Feature/Api/IdempotencyKeyTest.php b/backend/tests/Feature/Api/IdempotencyKeyTest.php new file mode 100644 index 0000000..efe4783 --- /dev/null +++ b/backend/tests/Feature/Api/IdempotencyKeyTest.php @@ -0,0 +1,31 @@ +post('/__test/idempotent', fn () => response()->json(['value' => random_int(1, 1_000_000)])); +}); + +it('exige o header Idempotency-Key', function (): void { + $this->postJson('/__test/idempotent') + ->assertStatus(400) + ->assertJson(['code' => 'idempotency_key_required']); +}); + +it('repete a mesma resposta pra mesma chave', function (): void { + $first = $this->postJson('/__test/idempotent', [], ['Idempotency-Key' => 'abc-123']); + $second = $this->postJson('/__test/idempotent', [], ['Idempotency-Key' => 'abc-123']); + + $first->assertOk(); + expect($second->json('value'))->toBe($first->json('value')); +}); + +it('gera uma resposta nova pra uma chave diferente', function (): void { + $first = $this->postJson('/__test/idempotent', [], ['Idempotency-Key' => 'key-1']); + $second = $this->postJson('/__test/idempotent', [], ['Idempotency-Key' => 'key-2']); + + expect($second->json('value'))->not->toBe($first->json('value')); +}); diff --git a/backend/tests/Feature/Api/PingTest.php b/backend/tests/Feature/Api/PingTest.php new file mode 100644 index 0000000..19f5dbc --- /dev/null +++ b/backend/tests/Feature/Api/PingTest.php @@ -0,0 +1,25 @@ +getJson('/api/v1/ping') + ->assertOk() + ->assertJson(['status' => 'ok']) + ->assertHeader('X-Request-Id'); +}); + +it('ecoa o X-Request-Id enviado pelo cliente', function (): void { + $this->withHeader('X-Request-Id', 'meu-id-de-teste') + ->getJson('/api/v1/ping') + ->assertOk() + ->assertHeader('X-Request-Id', 'meu-id-de-teste') + ->assertJson(['request_id' => 'meu-id-de-teste']); +}); + +it('responde com o envelope de erro em rota inexistente', function (): void { + $this->getJson('/api/v1/nao-existe') + ->assertStatus(404) + ->assertJsonStructure(['code', 'message', 'errors', 'request_id']) + ->assertJson(['code' => 'not_found']); +}); diff --git a/backend/tests/Unit/.gitkeep b/backend/tests/Unit/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/Unit/Support/Api/ApiErrorTest.php b/backend/tests/Unit/Support/Api/ApiErrorTest.php new file mode 100644 index 0000000..4fd47f6 --- /dev/null +++ b/backend/tests/Unit/Support/Api/ApiErrorTest.php @@ -0,0 +1,51 @@ + fn (): array => [ + new ValidationException(Validator::make([], ['name' => ['required']])), + 422, + 'validation_error', + ], + 'não autenticado' => fn (): array => [new AuthenticationException, 401, 'unauthenticated'], + 'sem permissão' => fn (): array => [new AccessDeniedHttpException, 403, 'forbidden'], + 'não encontrado' => fn (): array => [new NotFoundHttpException, 404, 'not_found'], + 'método não permitido' => fn (): array => [new MethodNotAllowedHttpException([]), 405, 'method_not_allowed'], + 'muitas tentativas' => fn (): array => [new TooManyRequestsHttpException, 429, 'too_many_requests'], + 'erro genérico' => fn (): array => [new RuntimeException('boom'), 500, 'server_error'], +]); + +it('monta o envelope certo pra cada tipo de erro', function (Throwable $exception, int $status, string $code): void { + $request = Request::create('/api/v1/ping'); + $request->attributes->set('request_id', 'test-request-id'); + + $response = ApiError::render($exception, $request); + + expect($response->getStatusCode())->toBe($status) + ->and($response->getData(true)) + ->toMatchArray(['code' => $code, 'request_id' => 'test-request-id']); +})->with('api errors'); + +it('só preenche errors em erro de validação', function (): void { + $request = Request::create('/api/v1/ping'); + + $validationResponse = ApiError::render( + new ValidationException(Validator::make([], ['name' => ['required']])), + $request, + ); + $notFoundResponse = ApiError::render(new NotFoundHttpException, $request); + + expect($validationResponse->getData(true)['errors'])->not->toBeNull() + ->and($notFoundResponse->getData(true)['errors'])->toBeNull(); +});