Files
landing-api/tests/VerifiesOpenApiContract.php
Josep c8ba8d7b14
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
update
2026-08-21 09:14:51 +02:00

132 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests;
use Symfony\Component\Yaml\Yaml;
/**
* Compares an actual JSON response against what `public/openapi.yaml` says
* it should look like.
*
* Shared between the public and the admin contract tests because the
* question is the same one twice: does the shape the document promises match
* the shape the API sends. What differs between the two is everything
* *around* the question — how a caller authenticates, what a listing is
* allowed to leave out — not the comparison itself.
*/
trait VerifiesOpenApiContract
{
/**
* @var array<string, mixed>
*/
protected array $spec;
protected function loadOpenApiSpec(): void
{
$this->spec = (array) Yaml::parseFile(public_path('openapi.yaml'));
}
/**
* The field names a schema promises, one level deep — enough for a flat
* comparison against `array_keys()` on a decoded JSON object.
*
* @return array<int, string>
*/
protected function documentedKeys(array $schema): array
{
return array_keys($this->resolve($schema)['properties'] ?? []);
}
/**
* The documented fields that must always be present.
*
* 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, whether because the value itself can be
* null or because it only appears down one particular route. The other
* direction stays strict regardless: whatever is actually sent has to be
* described, nullable or not.
*
* @return array<int, string>
*/
protected function alwaysPresent(array $schema): array
{
$resolved = $this->resolve($schema);
return array_keys(array_filter(
(array) ($resolved['properties'] ?? []),
fn (array $property) => ($property['nullable'] ?? false) !== true,
));
}
/**
* The schema of one record inside a `{"data": ...}` envelope, whichever
* way the endpoint wraps it — a bare object for a detail endpoint, or the
* `items` of an array for a listing.
*
* @return array<string, mixed>
*/
protected function recordSchema(string $path, string $method = 'get', int $status = 200): array
{
$schema = $this->resolve(
$this->spec['paths'][$path][$method]['responses'][$status]['content']['application/json']['schema'] ?? [],
);
$data = $this->resolve($schema['properties']['data'] ?? []);
if (($data['type'] ?? null) === 'array') {
$data = $this->resolve($data['items'] ?? []);
}
return $data;
}
/**
* Everything documented for one path's response, `data` included — for
* comparing against the envelope as a whole rather than only the record
* inside it.
*
* @return array<string, mixed>
*/
protected function responseSchema(string $path, string $method = 'get', int $status = 200): array
{
return $this->resolve(
$this->spec['paths'][$path][$method]['responses'][$status]['content']['application/json']['schema'] ?? [],
);
}
/**
* 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;
}
}