withHeaders(['Accept-Language' => 'es']); $this->loadOpenApiSpec(); } /** * Enough of everything for each endpoint to answer with a full record * rather than an empty list, which would prove nothing at all. */ protected function seedTheSite(): void { $client = Client::factory()->create(); $project = Project::factory()->published()->create([ 'client_id' => $client->id, 'title' => ['es' => 'Mi proyecto'], 'slug' => ['es' => 'mi-proyecto'], ]); Page::factory()->published()->create([ 'title' => ['es' => 'Quiénes somos'], 'slug' => ['es' => 'quienes-somos'], ]); Member::factory()->create([ 'name' => 'Ada Lovelace', 'slug' => 'ada-lovelace', 'is_public' => true, ]); Call::factory()->create([ 'project_id' => $project->id, 'title' => ['es' => 'Backend'], 'slug' => ['es' => 'backend'], ]); Service::factory()->create([ 'title' => ['es' => 'Desarrollo'], 'slug' => ['es' => 'desarrollo'], ]); Testimonial::factory()->create(); Talk::factory()->create(); // Attached to the project on purpose: `/technologies` lists only what // is actually in use, so a loose one would leave that endpoint with // nothing to compare against. $project->technologies()->attach(Technology::factory()->create()); } /** * Endpoint => whether it answers with one whole record. * * A detail endpoint returns the full shape, so every documented field has * to be there. A listing legitimately leaves out the heavy ones — the * specification says as much, field by field — so it is only held to the * rule that everything it does send is documented. * * @return array */ public static function endpoints(): array { return [ 'projects' => ['path' => '/public/v1/projects', 'detail' => false], 'project' => ['path' => '/public/v1/projects/{slug}', 'detail' => true], 'pages' => ['path' => '/public/v1/pages', 'detail' => false], 'page' => ['path' => '/public/v1/pages/{slug}', 'detail' => true], 'members' => ['path' => '/public/v1/members', 'detail' => false], 'member' => ['path' => '/public/v1/members/{slug}', 'detail' => true], 'calls' => ['path' => '/public/v1/calls', 'detail' => false], 'call' => ['path' => '/public/v1/calls/{slug}', 'detail' => true], 'services' => ['path' => '/public/v1/services', 'detail' => false], 'service' => ['path' => '/public/v1/services/{slug}', 'detail' => true], 'clients' => ['path' => '/public/v1/clients', 'detail' => false], 'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false], 'talks' => ['path' => '/public/v1/talks', 'detail' => false], 'technologies' => ['path' => '/public/v1/technologies', 'detail' => false], 'search' => ['path' => '/public/v1/search', 'detail' => true], ]; } /** * The URLs the seeded records actually live at. * * @return array */ protected function slugs(): array { return [ '/public/v1/projects/{slug}' => '/public/v1/projects/mi-proyecto', '/public/v1/pages/{slug}' => '/public/v1/pages/quienes-somos', '/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace', '/public/v1/calls/{slug}' => '/public/v1/calls/backend', '/public/v1/services/{slug}' => '/public/v1/services/desarrollo', '/public/v1/search' => '/public/v1/search?lang=es&q=proyecto', ]; } #[Test] public function every_public_endpoint_answers_in_the_shape_it_documents(): void { $this->seedTheSite(); $problems = []; foreach (self::endpoints() as $name => $endpoint) { $url = $this->slugs()[$endpoint['path']] ?? $endpoint['path']; $response = $this->getJson('/api'.$url); if ($response->status() !== 200) { $problems[] = "{$name}: answered {$response->status()}"; continue; } $payload = $response->json('data'); // A listing that came back empty would pass every check below // without having compared anything. $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($endpoint['path']); $documented = $this->documentedKeys($schema); foreach (array_diff(array_keys($record), $documented) as $undocumented) { $problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe"; } if ($endpoint['detail']) { foreach (array_diff($this->alwaysPresent($schema), array_keys($record)) as $missing) { $problems[] = "{$name}: documents `{$missing}`, which it does not send"; } } } $this->assertSame([], $problems, "The public API and its specification disagree:\n".implode("\n", $problems)); } }