loadOpenApiSpec(); $this->seed(RolePermissionSeeder::class); // super_admin, so a permission gap never masquerades as a shape // mismatch: every request here must reach the resource, not a 403. $this->caller = User::factory()->create(); $this->caller->syncRoles(['super_admin']); $this->withHeaders($this->apiHeadersFor($this->caller)); } /** * @return array */ public static function paginatedEndpoints(): array { return [ 'pages' => ['path' => '/v1/pages'], 'categories' => ['path' => '/v1/categories'], 'users' => ['path' => '/v1/users'], ]; } #[Test] public function every_admin_record_answers_in_the_shape_it_documents(): void { $page = Page::factory()->create(); $category = Category::factory()->create(); // One request, one documented path — the URL a real caller hits and // the path key that describes it, side by side rather than derived // from one another, because guessing the second from the first is // exactly the kind of cleverness that breaks quietly. $cases = [ 'pages' => ['/v1/pages', '/v1/pages'], 'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'], 'categories' => ['/v1/categories', '/v1/categories'], 'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'], 'users' => ['/v1/users', '/v1/users'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'me' => ['/v1/me', '/v1/me'], 'roles' => ['/v1/roles', '/v1/roles'], ]; $problems = []; foreach ($cases as $name => [$url, $documentedPath]) { $response = $this->getJson('/api'.$url); if ($response->status() !== 200) { $problems[] = "{$name}: answered {$response->status()}"; continue; } $payload = $response->json('data'); $record = array_is_list((array) $payload) ? ($payload[0] ?? null) : $payload; if (! is_array($record)) { $problems[] = "{$name}: no record to compare against"; continue; } $schema = $this->recordSchema($documentedPath); foreach (array_diff(array_keys($record), $this->documentedKeys($schema)) as $undocumented) { $problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe"; } foreach (array_diff($this->alwaysPresent($schema), array_keys($record)) as $missing) { $problems[] = "{$name}: documents `{$missing}`, which it does not send"; } } $this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems)); } #[Test] public function an_upload_answers_in_the_shape_it_documents(): void { Storage::fake((string) config('media.disk')); $response = $this->post('/api/v1/media', [ 'file' => UploadedFile::fake()->image('hero.jpg', 64, 48), ], ['Accept' => 'application/json'])->assertCreated(); $record = $response->json('data'); $schema = $this->recordSchema('/v1/media', 'post', 201); $this->assertSame([], array_values(array_diff(array_keys($record), $this->documentedKeys($schema))), 'Sent but not documented'); $this->assertSame([], array_values(array_diff($this->alwaysPresent($schema), array_keys($record))), 'Documented but not sent'); } /** * The pagination envelope every listing here uses — `data`, `links`, * `meta` — checked the same way the record inside it is: nothing sent * that is not documented. * * Worth its own test rather than folding into the one above: the record * comparison already proves the endpoints work at all, and a failure here * points at a completely different place to fix — the collection schema, * not a `Resource` class. */ #[Test] public function the_pagination_envelope_matches_what_is_documented(): void { Page::factory()->create(); Category::factory()->create(); $problems = []; foreach (self::paginatedEndpoints() as $name => $endpoint) { $response = $this->getJson('/api'.$endpoint['path'])->assertOk(); $envelope = $this->responseSchema($endpoint['path']); $documented = $this->documentedKeys($envelope); foreach (array_diff(array_keys($response->json()), $documented) as $undocumented) { $problems[] = "{$name}: envelope sends `{$undocumented}`, which the specification does not describe"; } $metaSchema = $this->resolve($envelope['properties']['meta'] ?? []); $meta = (array) $response->json('meta'); foreach (array_diff(array_keys($meta), $this->documentedKeys($metaSchema)) as $undocumented) { $problems[] = "{$name}: meta sends `{$undocumented}`, which the specification does not describe"; } foreach (array_diff($this->alwaysPresent($metaSchema), array_keys($meta)) as $missing) { $problems[] = "{$name}: meta documents `{$missing}`, which it does not send"; } } $this->assertSame([], $problems, "The pagination envelope and its specification disagree:\n".implode("\n", $problems)); } /** * `/v1/roles` is the one listing here Laravel never paginates — * `RoleController::index()` calls `->get()`, not `->paginate()` — so * proving it carries no stray `meta`/`links` is the other half of the * envelope test above. */ #[Test] public function the_unpaginated_roles_listing_carries_no_pagination_envelope(): void { $response = $this->getJson('/api/v1/roles')->assertOk(); $this->assertSame(['data'], array_keys($response->json())); } }