update
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled

This commit is contained in:
2026-08-21 09:14:51 +02:00
parent 618d3a56ad
commit c8ba8d7b14
5 changed files with 388 additions and 105 deletions

View File

@@ -15,8 +15,8 @@ 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;
use Tests\VerifiesOpenApiContract;
/**
* The public API answers with what the specification says it answers with.
@@ -34,12 +34,7 @@ use Tests\TestCase;
*/
class PublicApiContractTest extends TestCase
{
use RefreshDatabase;
/**
* @var array<string, mixed>
*/
protected array $spec;
use RefreshDatabase, VerifiesOpenApiContract;
protected function setUp(): void
{
@@ -47,7 +42,7 @@ class PublicApiContractTest extends TestCase
$this->withHeaders(['Accept-Language' => 'es']);
$this->spec = (array) Yaml::parseFile(public_path('openapi.yaml'));
$this->loadOpenApiSpec();
}
/**
@@ -171,14 +166,15 @@ class PublicApiContractTest extends TestCase
continue;
}
$documented = $this->documentedKeys($endpoint['path']);
$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($endpoint['path']), array_keys($record)) as $missing) {
foreach (array_diff($this->alwaysPresent($schema), array_keys($record)) as $missing) {
$problems[] = "{$name}: documents `{$missing}`, which it does not send";
}
}
@@ -186,90 +182,4 @@ class PublicApiContractTest extends TestCase
$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;
}
}