Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
275
tests/Feature/Api/PublicApiContractTest.php
Normal file
275
tests/Feature/Api/PublicApiContractTest.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?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\Talk;
|
||||
use App\Models\Technology;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $spec;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
|
||||
$this->spec = (array) Yaml::parseFile(public_path('openapi.yaml'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
];
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
|
||||
$documented = $this->documentedKeys($endpoint['path']);
|
||||
|
||||
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($endpoint['path']), 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* The field names the specification promises for one endpoint's records.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function documentedKeys(string $path): array
|
||||
{
|
||||
return array_keys($this->recordSchema($path)['properties'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema of a single record, whichever way the endpoint wraps it.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function recordSchema(string $path): array
|
||||
{
|
||||
$schema = $this->resolve(
|
||||
$this->spec['paths'][$path]['get']['responses'][200]['content']['application/json']['schema'] ?? [],
|
||||
);
|
||||
|
||||
$data = $this->resolve($schema['properties']['data'] ?? []);
|
||||
|
||||
// A listing documents its records under `items`; a detail endpoint
|
||||
// describes them directly.
|
||||
if (($data['type'] ?? null) === 'array') {
|
||||
$data = $this->resolve($data['items'] ?? []);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented fields a detail endpoint must always answer with.
|
||||
*
|
||||
* Anything the specification marks `nullable` is left out of this half of
|
||||
* the check. That is the vocabulary this document already uses for a field
|
||||
* that may not be there — a member's `role` exists only while they are
|
||||
* nested under a project, and the schema says so. The other direction
|
||||
* stays strict for every field either way: whatever the API sends has to
|
||||
* be described, nullable or not.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function alwaysPresent(string $path): array
|
||||
{
|
||||
$record = $this->recordSchema($path);
|
||||
|
||||
return array_keys(array_filter(
|
||||
(array) ($record['properties'] ?? []),
|
||||
fn (array $property) => ($property['nullable'] ?? false) !== true,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow `$ref` and flatten `allOf`, which is how the specification says
|
||||
* "this, plus a couple of fields" without repeating a schema.
|
||||
*
|
||||
* @param array<string, mixed> $schema
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function resolve(array $schema): array
|
||||
{
|
||||
if (isset($schema['$ref'])) {
|
||||
$name = basename((string) $schema['$ref']);
|
||||
|
||||
return $this->resolve((array) ($this->spec['components']['schemas'][$name] ?? []));
|
||||
}
|
||||
|
||||
if (isset($schema['allOf'])) {
|
||||
$merged = ['type' => 'object', 'properties' => []];
|
||||
|
||||
foreach ((array) $schema['allOf'] as $part) {
|
||||
$resolved = $this->resolve((array) $part);
|
||||
$merged['properties'] = [
|
||||
...$merged['properties'],
|
||||
...($resolved['properties'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return [...$schema, ...$merged];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user