Files
landing-api/tests/Feature/Api/AdminApiContractTest.php
Josep Piguillem ac546eba2b
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
Manage technologies through the API, with SVG logos
/api/v1/technologies is a full CRUD with the panel's own rules: name and
slug are plain strings, the prose is translatable, `radar_ring: null`
takes one off the radar, and TechnologyPolicy still refuses to delete
one a project, member or call uses. The MCP server picks the tools up
from openapi.yaml on its own.

A logo is an SVG, so POST /v1/media now accepts one — sanitized with
enshrined/svg-sanitize (scripts, event handlers and remote references
removed), and served by Caddy under a Content-Security-Policy that
would stop a script regardless. `for` (pages or technologies) says what
an upload is for, which picks the directory the panel uses for it and
the permission it takes.

Creating a technology without a category answered 500: the column's
default never reached the model, so the response read a null enum. The
controller now states the default, as the panel does.

Beside a file, the MCP upload tool no longer demands the JSON body's
`url`: the body then carries the upload's other fields, such as `for`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:49:00 +02:00

203 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Category;
use App\Models\Page;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
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'],
'technologies' => ['path' => '/v1/technologies'],
];
}
#[Test]
public function every_admin_record_answers_in_the_shape_it_documents(): void
{
$page = Page::factory()->create();
$category = Category::factory()->create();
$technology = Technology::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}'],
'technologies' => ['/v1/technologies', '/v1/technologies'],
'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'],
'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));
}
#[Test]
public function an_upload_answers_in_the_shape_it_documents(): void
{
Storage::fake((string) config('media.disk'));
$response = $this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('hero.jpg', 64, 48),
], ['Accept' => 'application/json'])->assertCreated();
$record = $response->json('data');
$schema = $this->recordSchema('/v1/media', 'post', 201);
$this->assertSame([], array_values(array_diff(array_keys($record), $this->documentedKeys($schema))), 'Sent but not documented');
$this->assertSame([], array_values(array_diff($this->alwaysPresent($schema), array_keys($record))), 'Documented but not sent');
}
/**
* 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()));
}
}