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'], 'technologies' => ['path' => '/v1/technologies'], 'projects' => ['path' => '/v1/projects'], 'members' => ['path' => '/v1/members'], 'services' => ['path' => '/v1/services'], 'clients' => ['path' => '/v1/clients'], 'testimonials' => ['path' => '/v1/testimonials'], 'talks' => ['path' => '/v1/talks'], 'calls' => ['path' => '/v1/calls'], 'applications' => ['path' => '/v1/applications'], 'redirects' => ['path' => '/v1/redirects'], 'missing-urls' => ['path' => '/v1/missing-urls'], 'repositories' => ['path' => '/v1/repositories'], 'media' => ['path' => '/v1/media'], ]; } #[Test] public function every_admin_record_answers_in_the_shape_it_documents(): void { $page = Page::factory()->create(); $category = Category::factory()->create(); $technology = Technology::factory()->create(); $project = Project::factory()->create(); $member = Member::factory()->create(); $service = Service::factory()->create(); $client = Client::factory()->create(); $testimonial = Testimonial::factory()->create(); $talk = Talk::factory()->create(); $call = Call::factory()->create(); $application = Application::factory()->for($call)->create(); $redirect = Redirect::factory()->create(); $missingUrl = MissingUrl::factory()->create(); $repository = Repository::factory()->create(); Setting::set(Maintenance::KEY, ['enabled' => false]); $media = Media::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}'], 'technologies' => ['/v1/technologies', '/v1/technologies'], 'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'], 'projects' => ['/v1/projects', '/v1/projects'], 'project' => ["/v1/projects/{$project->id}", '/v1/projects/{project}'], 'members' => ['/v1/members', '/v1/members'], 'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'], 'services' => ['/v1/services', '/v1/services'], 'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'], 'clients' => ['/v1/clients', '/v1/clients'], 'client' => ["/v1/clients/{$client->id}", '/v1/clients/{client}'], 'testimonials' => ['/v1/testimonials', '/v1/testimonials'], 'testimonial' => ["/v1/testimonials/{$testimonial->id}", '/v1/testimonials/{testimonial}'], 'talks' => ['/v1/talks', '/v1/talks'], 'talk' => ["/v1/talks/{$talk->id}", '/v1/talks/{talk}'], 'calls' => ['/v1/calls', '/v1/calls'], 'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'], 'applications' => ['/v1/applications', '/v1/applications'], 'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'], 'redirects' => ['/v1/redirects', '/v1/redirects'], 'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'], 'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'], 'missing-url' => ["/v1/missing-urls/{$missingUrl->id}", '/v1/missing-urls/{missing_url}'], 'repositories' => ['/v1/repositories', '/v1/repositories'], 'repository' => ["/v1/repositories/{$repository->id}", '/v1/repositories/{repository}'], 'settings' => ['/v1/settings', '/v1/settings'], 'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'], 'maintenance' => ['/v1/maintenance', '/v1/maintenance'], 'media' => ['/v1/media', '/v1/media'], 'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'], '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())); } }