Fed by the records themselves, delivered only to public HTTPS addresses checked at every attempt, retried, and switched off after repeated failures. A submitter's details travel only to webhooks that opted into them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
12 KiB
PHP
283 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Models\ApiToken;
|
|
use App\Models\Application;
|
|
use App\Models\Call;
|
|
use App\Models\Category;
|
|
use App\Models\Client;
|
|
use App\Models\Media;
|
|
use App\Models\Member;
|
|
use App\Models\MissingUrl;
|
|
use App\Models\Page;
|
|
use App\Models\Project;
|
|
use App\Models\Redirect;
|
|
use App\Models\Repository;
|
|
use App\Models\Service;
|
|
use App\Models\Setting;
|
|
use App\Models\Talk;
|
|
use App\Models\Technology;
|
|
use App\Models\Testimonial;
|
|
use App\Models\User;
|
|
use App\Models\Webhook;
|
|
use App\Support\Maintenance;
|
|
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 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'],
|
|
'projects' => ['path' => '/v1/projects'],
|
|
'members' => ['path' => '/v1/members'],
|
|
'services' => ['path' => '/v1/services'],
|
|
'clients' => ['path' => '/v1/clients'],
|
|
'testimonials' => ['path' => '/v1/testimonials'],
|
|
'talks' => ['path' => '/v1/talks'],
|
|
'calls' => ['path' => '/v1/calls'],
|
|
'applications' => ['path' => '/v1/applications'],
|
|
'redirects' => ['path' => '/v1/redirects'],
|
|
'missing-urls' => ['path' => '/v1/missing-urls'],
|
|
'repositories' => ['path' => '/v1/repositories'],
|
|
'media' => ['path' => '/v1/media'],
|
|
'tokens' => ['path' => '/v1/tokens'],
|
|
'webhooks' => ['path' => '/v1/webhooks'],
|
|
];
|
|
}
|
|
|
|
#[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();
|
|
$project = Project::factory()->create();
|
|
$member = Member::factory()->create();
|
|
$service = Service::factory()->create();
|
|
$client = Client::factory()->create();
|
|
$testimonial = Testimonial::factory()->create();
|
|
$talk = Talk::factory()->create();
|
|
$call = Call::factory()->create();
|
|
$application = Application::factory()->for($call)->create();
|
|
$redirect = Redirect::factory()->create();
|
|
$missingUrl = MissingUrl::factory()->create();
|
|
$repository = Repository::factory()->create();
|
|
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
|
$media = Media::factory()->create();
|
|
$page->comment('Primera nota', $this->caller);
|
|
ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay());
|
|
$webhook = Webhook::factory()->create(['is_active' => false]);
|
|
$webhook->deliveries()->create(['uuid' => 'contract', 'event' => 'ping', 'payload' => []]);
|
|
|
|
// 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}'],
|
|
'projects' => ['/v1/projects', '/v1/projects'],
|
|
'project' => ["/v1/projects/{$project->id}", '/v1/projects/{project}'],
|
|
'members' => ['/v1/members', '/v1/members'],
|
|
'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'],
|
|
'services' => ['/v1/services', '/v1/services'],
|
|
'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'],
|
|
'clients' => ['/v1/clients', '/v1/clients'],
|
|
'client' => ["/v1/clients/{$client->id}", '/v1/clients/{client}'],
|
|
'testimonials' => ['/v1/testimonials', '/v1/testimonials'],
|
|
'testimonial' => ["/v1/testimonials/{$testimonial->id}", '/v1/testimonials/{testimonial}'],
|
|
'talks' => ['/v1/talks', '/v1/talks'],
|
|
'talk' => ["/v1/talks/{$talk->id}", '/v1/talks/{talk}'],
|
|
'calls' => ['/v1/calls', '/v1/calls'],
|
|
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
|
|
'applications' => ['/v1/applications', '/v1/applications'],
|
|
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
|
|
'redirects' => ['/v1/redirects', '/v1/redirects'],
|
|
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
|
|
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],
|
|
'missing-url' => ["/v1/missing-urls/{$missingUrl->id}", '/v1/missing-urls/{missing_url}'],
|
|
'repositories' => ['/v1/repositories', '/v1/repositories'],
|
|
'repository' => ["/v1/repositories/{$repository->id}", '/v1/repositories/{repository}'],
|
|
'settings' => ['/v1/settings', '/v1/settings'],
|
|
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
|
|
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
|
|
'media' => ['/v1/media', '/v1/media'],
|
|
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
|
|
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
|
|
'tokens' => ['/v1/tokens', '/v1/tokens'],
|
|
'webhooks' => ['/v1/webhooks', '/v1/webhooks'],
|
|
'webhook' => ["/v1/webhooks/{$webhook->id}", '/v1/webhooks/{webhook}'],
|
|
'deliveries' => ["/v1/webhooks/{$webhook->id}/deliveries", '/v1/webhooks/{webhook}/deliveries'],
|
|
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
|
|
'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()));
|
|
}
|
|
}
|