update
This commit is contained in:
179
tests/Feature/Api/AdminApiContractTest.php
Normal file
179
tests/Feature/Api/AdminApiContractTest.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Page;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
use Tests\VerifiesOpenApiContract;
|
||||
|
||||
/**
|
||||
* The administration API answers with what the specification says it does —
|
||||
* `PublicApiContractTest`'s other half.
|
||||
*
|
||||
* The frontend reads the public document from another repository, in a
|
||||
* framework not yet chosen; whoever writes it will never see this code, only
|
||||
* the specification. That case does not apply here — an admin API caller is
|
||||
* inside this codebase, or at least inside this organisation — but the
|
||||
* underlying failure is identical: a field renamed in a `Http\Resources\*`
|
||||
* class is a change nothing else in this suite notices, because every other
|
||||
* test exercises behaviour, not shape.
|
||||
*
|
||||
* Unlike the public listings, an admin index and its matching show endpoint
|
||||
* send the same full record — none of the four resources here has a
|
||||
* "lightweight for a listing" projection — so both are held to the stricter
|
||||
* rule throughout: everything documented and not `nullable` must be present.
|
||||
*/
|
||||
class AdminApiContractTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase, VerifiesOpenApiContract;
|
||||
|
||||
protected User $caller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->loadOpenApiSpec();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
// super_admin, so a permission gap never masquerades as a shape
|
||||
// mismatch: every request here must reach the resource, not a 403.
|
||||
$this->caller = User::factory()->create();
|
||||
$this->caller->syncRoles(['super_admin']);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($this->caller));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{path: string}>
|
||||
*/
|
||||
public static function paginatedEndpoints(): array
|
||||
{
|
||||
return [
|
||||
'pages' => ['path' => '/v1/pages'],
|
||||
'categories' => ['path' => '/v1/categories'],
|
||||
'users' => ['path' => '/v1/users'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_admin_record_answers_in_the_shape_it_documents(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$category = Category::factory()->create();
|
||||
|
||||
// One request, one documented path — the URL a real caller hits and
|
||||
// the path key that describes it, side by side rather than derived
|
||||
// from one another, because guessing the second from the first is
|
||||
// exactly the kind of cleverness that breaks quietly.
|
||||
$cases = [
|
||||
'pages' => ['/v1/pages', '/v1/pages'],
|
||||
'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'],
|
||||
'categories' => ['/v1/categories', '/v1/categories'],
|
||||
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
||||
'users' => ['/v1/users', '/v1/users'],
|
||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||
'me' => ['/v1/me', '/v1/me'],
|
||||
'roles' => ['/v1/roles', '/v1/roles'],
|
||||
];
|
||||
|
||||
$problems = [];
|
||||
|
||||
foreach ($cases as $name => [$url, $documentedPath]) {
|
||||
$response = $this->getJson('/api'.$url);
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
$problems[] = "{$name}: answered {$response->status()}";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = $response->json('data');
|
||||
$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($documentedPath);
|
||||
|
||||
foreach (array_diff(array_keys($record), $this->documentedKeys($schema)) as $undocumented) {
|
||||
$problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
foreach (array_diff($this->alwaysPresent($schema), array_keys($record)) as $missing) {
|
||||
$problems[] = "{$name}: documents `{$missing}`, which it does not send";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pagination envelope every listing here uses — `data`, `links`,
|
||||
* `meta` — checked the same way the record inside it is: nothing sent
|
||||
* that is not documented.
|
||||
*
|
||||
* Worth its own test rather than folding into the one above: the record
|
||||
* comparison already proves the endpoints work at all, and a failure here
|
||||
* points at a completely different place to fix — the collection schema,
|
||||
* not a `Resource` class.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_pagination_envelope_matches_what_is_documented(): void
|
||||
{
|
||||
Page::factory()->create();
|
||||
Category::factory()->create();
|
||||
|
||||
$problems = [];
|
||||
|
||||
foreach (self::paginatedEndpoints() as $name => $endpoint) {
|
||||
$response = $this->getJson('/api'.$endpoint['path'])->assertOk();
|
||||
|
||||
$envelope = $this->responseSchema($endpoint['path']);
|
||||
$documented = $this->documentedKeys($envelope);
|
||||
|
||||
foreach (array_diff(array_keys($response->json()), $documented) as $undocumented) {
|
||||
$problems[] = "{$name}: envelope sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
$metaSchema = $this->resolve($envelope['properties']['meta'] ?? []);
|
||||
$meta = (array) $response->json('meta');
|
||||
|
||||
foreach (array_diff(array_keys($meta), $this->documentedKeys($metaSchema)) as $undocumented) {
|
||||
$problems[] = "{$name}: meta sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
foreach (array_diff($this->alwaysPresent($metaSchema), array_keys($meta)) as $missing) {
|
||||
$problems[] = "{$name}: meta documents `{$missing}`, which it does not send";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $problems, "The pagination envelope and its specification disagree:\n".implode("\n", $problems));
|
||||
}
|
||||
|
||||
/**
|
||||
* `/v1/roles` is the one listing here Laravel never paginates —
|
||||
* `RoleController::index()` calls `->get()`, not `->paginate()` — so
|
||||
* proving it carries no stray `meta`/`links` is the other half of the
|
||||
* envelope test above.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_unpaginated_roles_listing_carries_no_pagination_envelope(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/roles')->assertOk();
|
||||
|
||||
$this->assertSame(['data'], array_keys($response->json()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user