Files
landing-api/tests/Feature/Api/PublicApiContractTest.php
Josep Piguillem 9f970808ca
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tag blog posts and sign them with a member
Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:01:18 +02:00

191 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Call;
use App\Models\Client;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Tag;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
use Tests\VerifiesOpenApiContract;
/**
* The public API answers with what the specification says it answers with.
*
* `ApiDocsTest` already checks that every documented path is routed. This is
* the other half, and the one that matters more now: that the *shape* the
* frontend was promised is the shape it gets.
*
* The failure this exists to prevent has no symptom on this side. Renaming a
* field in a `PublicApi/*Resource` is a one-line change that passes every
* other test in this suite, ships, and breaks a page on another machine in
* another repository — written against a specification that still describes
* the old name. With the frontend framework still undecided, whoever writes it
* will be reading that document rather than this code, so it has to be true.
*/
class PublicApiContractTest extends TestCase
{
use RefreshDatabase, VerifiesOpenApiContract;
protected function setUp(): void
{
parent::setUp();
$this->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 = Page::factory()->published()->create([
'title' => ['es' => 'Quiénes somos'],
'slug' => ['es' => 'quienes-somos'],
'byline_member_id' => Member::factory()->create(['is_public' => true])->id,
]);
$page->tags()->attach(Tag::factory()->create());
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<string, array{path: string, detail: bool}>
*/
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<string, string>
*/
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));
}
}