Initial commit: Pireon Admin — headless admin panel and public API

Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\Yaml\Yaml;
use Tests\TestCase;
/**
* The browsable API documentation.
*
* The specification was always there as a file; what this covers is that it is
* readable, that it stays in step with the routes it claims to describe, and
* that nothing about it depends on somebody else's CDN.
*/
class ApiDocsTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function the_docs_page_opens_without_signing_in(): void
{
// The people who need it are writing the public frontend and have no
// reason to hold a panel account.
$this->get('/docs/api')
->assertOk()
->assertSee('swagger', escape: false);
}
#[Test]
public function it_points_at_the_installation_being_looked_at(): void
{
$html = (string) $this->get('/docs/api')->assertOk()->getContent();
// Not the canonical URL from the configuration: the same page is
// opened on a laptop, on staging and in production, and "try it out"
// has to reach whichever one it is.
$this->assertStringContainsString(json_encode(url('/api')), $html);
$this->assertStringContainsString(json_encode(asset('openapi.yaml')), $html);
}
#[Test]
public function it_carries_its_own_assets(): void
{
$html = (string) $this->get('/docs/api')->assertOk()->getContent();
// The docs have to work on a laptop with no internet, and a script tag
// pointing at somebody else's server is a dependency this application
// does not otherwise have.
$this->assertStringNotContainsString('cdn.', $html);
$this->assertStringNotContainsString('unpkg', $html);
foreach (['swagger-ui.css', 'swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js'] as $asset) {
$this->assertFileExists(public_path("vendor/swagger-ui/{$asset}"));
$this->assertStringContainsString("vendor/swagger-ui/{$asset}", $html);
}
}
#[Test]
public function the_specification_parses(): void
{
$spec = Yaml::parseFile(public_path('openapi.yaml'));
$this->assertIsArray($spec);
$this->assertArrayHasKey('paths', $spec);
$this->assertNotEmpty($spec['paths']);
}
/**
* Every path the specification advertises exists as a route.
*
* Documentation that describes an endpoint nobody implemented is worse
* than none: it is believed.
*/
#[Test]
public function every_documented_endpoint_is_routed(): void
{
$spec = Yaml::parseFile(public_path('openapi.yaml'));
$routes = collect(app('router')->getRoutes()->getRoutes())
->flatMap(fn ($route) => collect($route->methods())
->map(fn (string $method) => strtolower($method).' '.$route->uri()))
->all();
$missing = [];
foreach ($spec['paths'] as $path => $operations) {
// The specification is served under an `/api` base URL, and route
// parameters are named independently on either side.
$uri = 'api'.preg_replace('/\{[^}]+\}/', '{param}', $path);
// A path item may also carry `parameters`, `summary` and friends;
// only the HTTP methods describe an endpoint.
$methods = array_intersect(
array_keys($operations),
['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'],
);
foreach ($methods as $method) {
$normalised = collect($routes)
->map(fn (string $route) => preg_replace('/\{[^}]+\}/', '{param}', $route))
->contains(strtolower($method).' '.$uri);
if (! $normalised) {
$missing[] = strtoupper($method)." {$path}";
}
}
}
$this->assertSame([], $missing, "Documented but not routed:\n".implode("\n", $missing));
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The generated types, and the one thing that can go wrong with them.
*
* A type file is only worth having while it is true. Committed and then left
* behind by a change to the specification, it is worse than none: somebody
* writes a frontend against an editor that is confidently wrong.
*
* So the file is checked in — the people who need it should be able to read it
* without running anything — and `--check` is what keeps it honest.
*/
class ApiTypesTest extends TestCase
{
#[Test]
public function the_committed_types_match_the_specification(): void
{
$this->artisan('api:types --check')->assertExitCode(0);
}
#[Test]
public function it_describes_the_public_payloads(): void
{
$types = (string) file_get_contents(public_path('api-types.d.ts'));
// The record shapes.
$this->assertStringContainsString('export interface PublicProject {', $types);
$this->assertStringContainsString('export interface Image {', $types);
// And the map from an address to what it answers with, which is what
// makes a typed client possible without repeating any of this.
$this->assertStringContainsString("'/public/v1/projects': PublicProject[];", $types);
$this->assertStringContainsString("'/public/v1/projects/{slug}': PublicProject;", $types);
}
/**
* `nullable` is how this specification says "may not be there", and the
* contract test holds the API to the same reading. The types have to agree
* with both or they would be describing a third thing.
*/
#[Test]
public function a_nullable_field_is_optional_and_a_required_one_is_not(): void
{
$types = (string) file_get_contents(public_path('api-types.d.ts'));
$this->assertMatchesRegularExpression('/^ id: number;$/m', $types);
$this->assertMatchesRegularExpression('/^ tagline\?: string;$/m', $types);
}
/**
* Administration schemas are in the same document and deliberately not
* here: they are read by callers holding a token, not by the frontend, and
* a type file twice the size is one nobody reads.
*/
#[Test]
public function it_leaves_out_what_the_frontend_never_sees(): void
{
$types = (string) file_get_contents(public_path('api-types.d.ts'));
$this->assertStringNotContainsString('export interface User', $types);
$this->assertStringNotContainsString('export interface Role', $types);
$this->assertStringNotContainsString('PasswordGrant', $types);
}
#[Test]
public function generating_twice_produces_the_same_file(): void
{
$before = (string) file_get_contents(public_path('api-types.d.ts'));
$this->artisan('api:types')->assertExitCode(0);
// Anything unstable here — a hash map iterated in whatever order, a
// timestamp in the header — would show up as a diff on every run and
// teach everybody to ignore this file's changes.
$this->assertSame($before, (string) file_get_contents(public_path('api-types.d.ts')));
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\ContentView;
use App\Models\Page;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* What the frontend read, reported back.
*
* Two things need holding still. The first is that this stays a tally: one row
* per record, per language, per day, however many reports arrive. The second
* is what it refuses to collect — the moment a visitor could be reconstructed
* out of this table it has become something nobody agreed to run.
*/
class ContentViewsTest extends TestCase
{
use RefreshDatabase;
protected string $token = 'ingest-secret';
protected function setUp(): void
{
parent::setUp();
config(['site.telemetry.token' => $this->token]);
$this->withHeaders(['Accept-Language' => 'es']);
}
/**
* @param array<string, mixed> $payload
*/
protected function report(array $payload): TestResponse
{
return $this->withHeaders(['X-Ingest-Token' => $this->token])
->postJson('/api/public/v1/telemetry/views', $payload);
}
protected function page(string $slug = 'quienes-somos'): Page
{
return Page::factory()->create([
'slug' => ['es' => $slug, 'en' => "{$slug}-en", 'ca' => "{$slug}-ca"],
]);
}
#[Test]
public function it_counts_a_read(): void
{
$page = $this->page();
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])
->assertAccepted()
->assertJsonPath('data.recorded', 1)
->assertHeader('Cache-Control', 'no-store, private');
$this->assertDatabaseHas('content_views', [
'viewable_type' => $page->getMorphClass(),
'viewable_id' => $page->id,
'locale' => 'es',
'count' => 1,
]);
}
/**
* The property that keeps this from becoming a log.
*/
#[Test]
public function reports_for_the_same_day_add_up_in_one_row(): void
{
$this->page();
foreach (range(1, 4) as $ignored) {
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
}
$this->assertSame(1, ContentView::query()->count());
$this->assertSame(4, (int) ContentView::query()->first()->count);
}
#[Test]
public function a_batch_can_carry_its_own_counts(): void
{
$this->page();
Project::factory()->create(['slug' => ['es' => 'mi-web', 'en' => 'my-web', 'ca' => 'el-meu-web']]);
$this->report(['views' => [
['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es', 'count' => 12],
['type' => 'project', 'slug' => 'mi-web', 'locale' => 'es', 'count' => 3],
]])
->assertAccepted()
->assertJsonPath('data.recorded', 2);
$this->assertSame(15, (int) ContentView::query()->sum('count'));
}
/**
* The same record read in two languages is two rows: which language people
* read something in is most of what the figures are for.
*/
#[Test]
public function each_language_is_counted_separately(): void
{
$page = $this->page();
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
$this->report(['type' => 'page', 'slug' => 'quienes-somos-ca', 'locale' => 'ca'])->assertAccepted();
$this->assertSame(2, ContentView::query()->count());
$this->assertSame(2, $page->viewsSince(30));
}
/**
* A slug that no longer resolves is a 404, and the endpoint next door
* exists for those. Counting it here would attach a read to nothing.
*/
#[Test]
public function a_slug_that_resolves_to_nothing_is_not_counted(): void
{
$this->report(['type' => 'page', 'slug' => 'no-existe', 'locale' => 'es'])
->assertAccepted()
->assertJsonPath('data.received', 1)
->assertJsonPath('data.recorded', 0);
$this->assertSame(0, ContentView::query()->count());
}
#[Test]
public function it_refuses_without_the_secret(): void
{
$this->page();
$this->postJson('/api/public/v1/telemetry/views', ['type' => 'page', 'slug' => 'quienes-somos'])
->assertForbidden();
$this->assertSame(0, ContentView::query()->count());
}
#[Test]
public function it_rejects_what_it_cannot_use(): void
{
$this->report(['type' => 'invento', 'slug' => 'x'])
->assertUnprocessable()
->assertJsonValidationErrors('views.0.type');
// The frontend counts its own renders; it does not get to invent an
// audience of any size it likes.
$this->page();
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'count' => 999999])
->assertUnprocessable()
->assertJsonValidationErrors('views.0.count');
}
/**
* The whole privacy argument for this table, as a test: there is nowhere
* to put an address, a session or a time of day, so no future change can
* quietly start keeping one.
*/
#[Test]
public function nothing_about_a_visitor_is_stored(): void
{
$this->page();
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
$columns = array_keys((array) ContentView::query()->first()->getAttributes());
$this->assertSame([
'id', 'viewable_type', 'viewable_id', 'locale', 'day', 'count',
'created_at', 'updated_at',
], $columns);
}
#[Test]
public function the_listing_figure_comes_from_one_query(): void
{
$page = $this->page();
ContentView::record($page, 'es', 5);
ContentView::record($page, 'ca', 2);
$row = Page::query()->withRecentViews()->find($page->id);
$this->assertSame(7, (int) $row->recent_views);
}
#[Test]
public function figures_older_than_the_window_do_not_count_as_recent(): void
{
$page = $this->page();
$page->views()->create([
'locale' => 'es',
'day' => now()->subDays(90)->toDateString(),
'count' => 40,
]);
$this->assertSame(0, $page->viewsSince(30));
}
#[Test]
public function pruning_forgets_figures_nobody_compares_against(): void
{
$page = $this->page();
$page->views()->create(['locale' => 'es', 'day' => now()->subDays(500)->toDateString(), 'count' => 9]);
$page->views()->create(['locale' => 'es', 'day' => now()->subDays(10)->toDateString(), 'count' => 3]);
$this->artisan('content:prune')->assertExitCode(0);
$this->assertSame(1, ContentView::query()->count());
$this->assertSame(3, (int) ContentView::query()->first()->count);
}
}

View File

@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\PageStatus;
use App\Models\Category;
use App\Models\Page;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class PageApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function it_rejects_a_request_without_a_token(): void
{
$this->getJson('/api/v1/pages')->assertUnauthorized();
}
#[Test]
public function it_rejects_a_token_the_identity_server_does_not_recognise(): void
{
$this->fakePireonRejectingTokens();
$this->getJson('/api/v1/pages', ['Authorization' => 'Bearer revoked-token'])
->assertUnauthorized();
}
#[Test]
public function it_resolves_the_caller_and_their_local_permissions(): void
{
$user = $this->userWithRoles('editor');
$response = $this->getJson('/api/v1/me', $this->apiHeadersFor($user));
$response->assertOk()
->assertJsonPath('data.email', $user->email)
->assertJsonPath('data.roles.0', 'editor');
// Permissions come from this app, not from the identity server.
$this->assertContains('pages.create', $response->json('data.permissions'));
$this->assertNotContains('roles.create', $response->json('data.permissions'));
}
#[Test]
public function a_locally_deactivated_user_is_denied_even_with_a_valid_token(): void
{
$user = $this->userWithRoles('editor', ['is_active' => false]);
$this->getJson('/api/v1/pages', $this->apiHeadersFor($user))
->assertUnauthorized();
}
#[Test]
public function it_returns_content_in_the_requested_locale(): void
{
// No stored locale preference, so Accept-Language is free to decide
// when no explicit ?lang= is given.
$user = $this->userWithRoles('editor', ['locale' => null]);
Page::factory()->published()->create([
'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'],
'slug' => ['es' => 'hola', 'en' => 'hello', 'ca' => 'hola-ca'],
]);
$headers = $this->apiHeadersFor($user);
$this->getJson('/api/v1/pages?lang=en', $headers)
->assertOk()
->assertJsonPath('data.0.title', 'Hello')
->assertJsonPath('data.0.locale', 'en');
$this->getJson('/api/v1/pages?lang=ca', $headers)
->assertOk()
->assertJsonPath('data.0.title', 'Hola-ca');
// Accept-Language is honoured when no explicit ?lang= is given.
$this->getJson('/api/v1/pages', $headers + ['Accept-Language' => 'en-GB,en;q=0.9'])
->assertOk()
->assertJsonPath('data.0.title', 'Hello')
->assertHeader('Content-Language', 'en');
}
#[Test]
public function it_can_return_every_translation_on_request(): void
{
$user = $this->userWithRoles('editor');
Page::factory()->published()->create([
'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'],
]);
$this->getJson('/api/v1/pages?with=translations', $this->apiHeadersFor($user))
->assertOk()
->assertJsonPath('data.0.translations.title.es', 'Hola')
->assertJsonPath('data.0.translations.title.en', 'Hello')
->assertJsonPath('data.0.translations.title.ca', 'Hola-ca');
}
#[Test]
public function it_falls_back_to_the_default_locale_for_a_missing_translation(): void
{
$user = $this->userWithRoles('editor');
Page::factory()->published()->create([
'title' => ['es' => 'Solo español'],
'slug' => ['es' => 'solo-espanol'],
]);
$this->getJson('/api/v1/pages?lang=ca', $this->apiHeadersFor($user))
->assertOk()
->assertJsonPath('data.0.title', 'Solo español');
}
#[Test]
public function an_editor_can_create_a_page_with_several_translations(): void
{
$user = $this->userWithRoles('editor');
$category = Category::factory()->create();
$response = $this->postJson('/api/v1/pages', [
'title' => ['es' => 'Nueva página', 'en' => 'New page'],
'slug' => ['es' => 'nueva-pagina', 'en' => 'new-page'],
'category_id' => $category->id,
'status' => 'draft',
], $this->apiHeadersFor($user));
$response->assertCreated();
$page = Page::firstWhere('id', $response->json('data.id'));
$this->assertSame('Nueva página', $page->getTranslation('title', 'es'));
$this->assertSame('New page', $page->getTranslation('title', 'en'));
$this->assertSame($user->id, $page->author_id);
}
#[Test]
public function a_partial_update_does_not_wipe_other_languages(): void
{
$user = $this->userWithRoles('editor');
$page = Page::factory()->draft()->create([
'title' => ['es' => 'Original', 'en' => 'Original EN'],
'slug' => ['es' => 'original', 'en' => 'original-en'],
'author_id' => $user->id,
]);
$this->patchJson("/api/v1/pages/{$page->id}", [
'title' => ['ca' => 'Nou títol'],
], $this->apiHeadersFor($user))->assertOk();
$page->refresh();
$this->assertSame('Nou títol', $page->getTranslation('title', 'ca'));
$this->assertSame('Original', $page->getTranslation('title', 'es'));
$this->assertSame('Original EN', $page->getTranslation('title', 'en'));
}
#[Test]
public function it_rejects_a_slug_already_used_in_the_same_locale(): void
{
$user = $this->userWithRoles('editor');
Page::factory()->create(['slug' => ['es' => 'repetido']]);
$this->postJson('/api/v1/pages', [
'title' => ['es' => 'Otra'],
'slug' => ['es' => 'repetido'],
], $this->apiHeadersFor($user))
->assertUnprocessable()
->assertJsonValidationErrors('slug');
}
#[Test]
public function the_same_slug_is_allowed_in_a_different_locale(): void
{
$user = $this->userWithRoles('editor');
Page::factory()->create(['slug' => ['es' => 'compartido']]);
$this->postJson('/api/v1/pages', [
'title' => ['es' => 'Otra'],
'slug' => ['es' => 'distinto', 'en' => 'compartido'],
], $this->apiHeadersFor($user))->assertCreated();
}
#[Test]
public function it_rejects_an_unsupported_locale_key(): void
{
$user = $this->userWithRoles('editor');
$this->postJson('/api/v1/pages', [
'title' => ['es' => 'Título', 'de' => 'Titel'],
'slug' => ['es' => 'titulo'],
], $this->apiHeadersFor($user))
->assertUnprocessable()
->assertJsonValidationErrors('title');
}
#[Test]
public function a_viewer_cannot_create_a_page(): void
{
$user = $this->userWithRoles('viewer');
$this->postJson('/api/v1/pages', [
'title' => ['es' => 'No permitido'],
'slug' => ['es' => 'no-permitido'],
], $this->apiHeadersFor($user))->assertForbidden();
}
#[Test]
public function a_viewer_only_sees_published_pages(): void
{
$user = $this->userWithRoles('viewer');
Page::factory()->published()->create();
Page::factory()->draft()->create();
$response = $this->getJson('/api/v1/pages', $this->apiHeadersFor($user))->assertOk();
$this->assertCount(1, $response->json('data'));
$this->assertSame(PageStatus::Published->value, $response->json('data.0.status'));
}
#[Test]
public function publishing_requires_its_own_permission(): void
{
// An editor who may write content but has had publish revoked.
$user = $this->userWithRoles('editor');
$user->revokePermissionTo('pages.publish');
$user->roles()->first()->revokePermissionTo('pages.publish');
$this->postJson('/api/v1/pages', [
'title' => ['es' => 'Intento'],
'slug' => ['es' => 'intento'],
'status' => 'published',
], $this->apiHeadersFor($user))
->assertUnprocessable()
->assertJsonValidationErrors('status');
}
#[Test]
public function a_super_admin_bypasses_every_permission_check(): void
{
$user = $this->userWithRoles('super_admin');
$this->assertSame(0, $user->roles()->first()->permissions()->count());
$this->postJson('/api/v1/pages', [
'title' => ['es' => 'Puedo todo'],
'slug' => ['es' => 'puedo-todo'],
'status' => 'published',
], $this->apiHeadersFor($user))->assertCreated();
}
}

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Call;
use App\Models\Client;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Talk;
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;
/**
* The public API answers with what the specification says it answers with.
*
* `ApiDocsTest` already checks that every documented path is routed. This is
* the other half, and the one that matters more now: that the *shape* the
* frontend was promised is the shape it gets.
*
* The failure this exists to prevent has no symptom on this side. Renaming a
* field in a `PublicApi/*Resource` is a one-line change that passes every
* other test in this suite, ships, and breaks a page on another machine in
* another repository — written against a specification that still describes
* the old name. With the frontend framework still undecided, whoever writes it
* will be reading that document rather than this code, so it has to be true.
*/
class PublicApiContractTest extends TestCase
{
use RefreshDatabase;
/**
* @var array<string, mixed>
*/
protected array $spec;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
$this->spec = (array) Yaml::parseFile(public_path('openapi.yaml'));
}
/**
* Enough of everything for each endpoint to answer with a full record
* rather than an empty list, which would prove nothing at all.
*/
protected function seedTheSite(): void
{
$client = Client::factory()->create();
$project = Project::factory()->published()->create([
'client_id' => $client->id,
'title' => ['es' => 'Mi proyecto'],
'slug' => ['es' => 'mi-proyecto'],
]);
Page::factory()->published()->create([
'title' => ['es' => 'Quiénes somos'],
'slug' => ['es' => 'quienes-somos'],
]);
Member::factory()->create([
'name' => 'Ada Lovelace',
'slug' => 'ada-lovelace',
'is_public' => true,
]);
Call::factory()->create([
'project_id' => $project->id,
'title' => ['es' => 'Backend'],
'slug' => ['es' => 'backend'],
]);
Service::factory()->create([
'title' => ['es' => 'Desarrollo'],
'slug' => ['es' => 'desarrollo'],
]);
Testimonial::factory()->create();
Talk::factory()->create();
// Attached to the project on purpose: `/technologies` lists only what
// is actually in use, so a loose one would leave that endpoint with
// nothing to compare against.
$project->technologies()->attach(Technology::factory()->create());
}
/**
* Endpoint => whether it answers with one whole record.
*
* A detail endpoint returns the full shape, so every documented field has
* to be there. A listing legitimately leaves out the heavy ones — the
* specification says as much, field by field — so it is only held to the
* rule that everything it does send is documented.
*
* @return array<string, array{path: string, detail: bool}>
*/
public static function endpoints(): array
{
return [
'projects' => ['path' => '/public/v1/projects', 'detail' => false],
'project' => ['path' => '/public/v1/projects/{slug}', 'detail' => true],
'pages' => ['path' => '/public/v1/pages', 'detail' => false],
'page' => ['path' => '/public/v1/pages/{slug}', 'detail' => true],
'members' => ['path' => '/public/v1/members', 'detail' => false],
'member' => ['path' => '/public/v1/members/{slug}', 'detail' => true],
'calls' => ['path' => '/public/v1/calls', 'detail' => false],
'call' => ['path' => '/public/v1/calls/{slug}', 'detail' => true],
'services' => ['path' => '/public/v1/services', 'detail' => false],
'service' => ['path' => '/public/v1/services/{slug}', 'detail' => true],
'clients' => ['path' => '/public/v1/clients', 'detail' => false],
'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false],
'talks' => ['path' => '/public/v1/talks', 'detail' => false],
'technologies' => ['path' => '/public/v1/technologies', 'detail' => false],
];
}
/**
* The URLs the seeded records actually live at.
*
* @return array<string, string>
*/
protected function slugs(): array
{
return [
'/public/v1/projects/{slug}' => '/public/v1/projects/mi-proyecto',
'/public/v1/pages/{slug}' => '/public/v1/pages/quienes-somos',
'/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace',
'/public/v1/calls/{slug}' => '/public/v1/calls/backend',
'/public/v1/services/{slug}' => '/public/v1/services/desarrollo',
];
}
#[Test]
public function every_public_endpoint_answers_in_the_shape_it_documents(): void
{
$this->seedTheSite();
$problems = [];
foreach (self::endpoints() as $name => $endpoint) {
$url = $this->slugs()[$endpoint['path']] ?? $endpoint['path'];
$response = $this->getJson('/api'.$url);
if ($response->status() !== 200) {
$problems[] = "{$name}: answered {$response->status()}";
continue;
}
$payload = $response->json('data');
// A listing that came back empty would pass every check below
// without having compared anything.
$record = array_is_list((array) $payload) ? ($payload[0] ?? null) : $payload;
if (! is_array($record)) {
$problems[] = "{$name}: no record to compare against";
continue;
}
$documented = $this->documentedKeys($endpoint['path']);
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) {
$problems[] = "{$name}: documents `{$missing}`, which it does not send";
}
}
}
$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;
}
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Call;
use App\Models\Member;
use App\Models\Project;
use App\Models\Repository;
use App\Models\Technology;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The contract the public frontend depends on.
*/
class PublicApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Symfony's Request::create() injects "Accept-Language: en-us,en;q=0.5"
// by default, and the API honours content negotiation — so without
// this every test would silently be asking for English. Real callers
// send their own header or ?lang=; these tests say so explicitly.
$this->withHeaders(['Accept-Language' => 'es']);
}
#[Test]
public function it_serves_published_projects_without_any_token(): void
{
Project::factory()->published()->create();
Project::factory()->draft()->create();
$response = $this->getJson('/api/public/v1/projects')->assertOk();
$this->assertCount(1, $response->json('data'));
}
#[Test]
public function it_resolves_a_project_by_slug_within_the_active_locale(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'],
]);
$this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->assertJsonPath('data.title', 'Mi proyecto');
$this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca')
->assertOk()
->assertJsonPath('data.title', 'El meu projecte');
// A Catalan slug must not resolve while the Spanish locale is active:
// that would serve one URL under two addresses.
$this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound();
}
#[Test]
public function it_returns_hreflang_alternates_for_every_translated_locale(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Uno', 'en' => 'One', 'ca' => 'U'],
'slug' => ['es' => 'uno', 'en' => 'one', 'ca' => 'u'],
]);
$alternates = $this->getJson('/api/public/v1/projects/uno')
->assertOk()
->json('data.alternates');
// The default locale carries no prefix; the others do, each with its
// own translated path segment.
$this->assertSame('/proyectos/uno', $alternates['es']);
$this->assertSame('/en/projects/one', $alternates['en']);
$this->assertSame('/ca/projectes/u', $alternates['ca']);
}
#[Test]
public function a_locale_without_a_translation_gets_no_alternate(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Solo español'],
'slug' => ['es' => 'solo-espanol'],
]);
$alternates = $this->getJson('/api/public/v1/projects/solo-espanol')
->assertOk()
->json('data.alternates');
// Emitting an alternate here would advertise a URL that 404s.
$this->assertSame(['es'], array_keys($alternates));
}
#[Test]
public function listings_do_not_lazy_load_relations(): void
{
// Guards the N+1 that strict mode caught during development: the
// needs_help flag must come from a count, not from the relation.
$project = Project::factory()->published()->create(['needs_help' => false]);
Call::factory()->create(['project_id' => $project->id]);
$this->getJson('/api/public/v1/projects')
->assertOk()
->assertJsonPath('data.0.needs_help', true);
}
#[Test]
public function unpublished_content_is_hidden_unless_the_preview_secret_is_presented(): void
{
config()->set('site.preview_token', 'the-secret');
Project::factory()->published()->create();
Project::factory()->draft()->create();
$this->assertCount(1, $this->getJson('/api/public/v1/projects')->json('data'));
$this->assertCount(2, $this->getJson('/api/public/v1/projects', [
'X-Preview-Token' => 'the-secret',
])->json('data'));
$this->assertCount(1, $this->getJson('/api/public/v1/projects', [
'X-Preview-Token' => 'wrong',
])->json('data'));
}
#[Test]
public function an_unset_preview_secret_never_grants_access(): void
{
config()->set('site.preview_token', null);
Project::factory()->draft()->create();
$this->assertCount(0, $this->getJson('/api/public/v1/projects', [
'X-Preview-Token' => '',
])->json('data'));
}
#[Test]
public function preview_responses_are_never_cached(): void
{
config()->set('site.preview_token', 'the-secret');
$this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret'])
->assertHeader('Cache-Control', 'no-store, private');
}
#[Test]
public function public_responses_carry_caching_headers_and_answer_304(): void
{
Project::factory()->published()->create();
$response = $this->getJson('/api/public/v1/projects')->assertOk();
$etag = $response->headers->get('ETag');
$this->assertNotNull($etag);
$this->assertStringContainsString('s-maxage', $response->headers->get('Cache-Control'));
$this->assertStringContainsString('stale-while-revalidate', $response->headers->get('Cache-Control'));
// Language is negotiated, so a shared cache must vary on it.
$this->assertStringContainsString('Accept-Language', $response->headers->get('Vary'));
$this->getJson('/api/public/v1/projects', ['If-None-Match' => $etag])
->assertStatus(304);
}
#[Test]
public function an_open_call_states_the_time_it_asks_for(): void
{
Call::factory()->create([
'slug' => ['es' => 'frontend'],
'hours_per_week' => 4,
'duration_weeks' => 8,
'project_id' => null,
]);
$this->getJson('/api/public/v1/calls/frontend')
->assertOk()
->assertJsonPath('data.hours_per_week', 4)
->assertJsonPath('data.duration_weeks', 8)
->assertJsonPath('data.total_hours', 32);
}
#[Test]
public function a_call_never_leaks_an_unpublished_project(): void
{
$hidden = Project::factory()->draft()->create();
Call::factory()->create(['project_id' => $hidden->id]);
Call::factory()->create(['project_id' => null]);
$this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data'));
}
#[Test]
public function closed_calls_are_not_listed(): void
{
Call::factory()->create(['project_id' => null]);
Call::factory()->closed()->create(['project_id' => null]);
Call::factory()->create(['project_id' => null, 'closes_at' => now()->subDay()]);
$this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data'));
}
#[Test]
public function members_hide_the_non_public_and_the_departed(): void
{
Member::factory()->create(['name' => 'Visible']);
Member::factory()->hidden()->create();
Member::factory()->past()->create();
$data = $this->getJson('/api/public/v1/members')->assertOk()->json('data');
$this->assertCount(1, $data);
$this->assertSame('Visible', $data[0]['name']);
// Alumni are still reachable when the frontend asks for them.
$this->assertCount(2, $this->getJson('/api/public/v1/members?include_past=1')->json('data'));
}
#[Test]
public function the_radar_only_lists_placed_technologies(): void
{
Technology::factory()->count(3)->create();
Technology::factory()->count(2)->offRadar()->create();
$this->assertCount(3, $this->getJson('/api/public/v1/radar')->json('data'));
}
#[Test]
public function good_first_issues_exclude_claimed_and_hidden_ones(): void
{
$visible = Repository::factory()->create();
$hidden = Repository::factory()->hidden()->create();
$visible->issues()->create([
'number' => 1, 'title' => 'Free to take',
'html_url' => 'https://example.test/1',
'is_good_first_issue' => true, 'assignee' => null,
]);
$visible->issues()->create([
'number' => 2, 'title' => 'Already claimed',
'html_url' => 'https://example.test/2',
'is_good_first_issue' => true, 'assignee' => 'someone',
]);
$hidden->issues()->create([
'number' => 1, 'title' => 'In a hidden repo',
'html_url' => 'https://example.test/3',
'is_good_first_issue' => true, 'assignee' => null,
]);
$data = $this->getJson('/api/public/v1/good-first-issues')->assertOk()->json('data');
$this->assertCount(1, $data);
$this->assertSame('Free to take', $data[0]['title']);
}
#[Test]
public function the_slugs_endpoint_lists_every_public_url(): void
{
Project::factory()->published()->create(['slug' => ['es' => 'proyecto', 'ca' => 'projecte']]);
Member::factory()->create(['slug' => 'ada']);
$data = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data');
$this->assertSame('/proyectos/proyecto', $data['projects'][0]['alternates']['es']);
$this->assertSame('/ca/projectes/projecte', $data['projects'][0]['alternates']['ca']);
$this->assertSame('/equipo/ada', $data['members'][0]['alternates']['es']);
$this->assertSame('/ca/equip/ada', $data['members'][0]['alternates']['ca']);
}
#[Test]
public function the_site_endpoint_reports_locales_and_headline_numbers(): void
{
Project::factory()->published()->active()->create();
Member::factory()->create();
$this->getJson('/api/public/v1/site')
->assertOk()
->assertJsonPath('data.default_locale', 'es')
->assertJsonPath('data.stats.projects', 1)
->assertJsonPath('data.stats.active_projects', 1)
->assertJsonPath('data.stats.members', 1)
->assertJsonCount(3, 'data.locales');
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Media;
use App\Models\Member;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* What the frontend receives for an image.
*
* A path was never enough: rendering one properly server-side needs its size,
* narrower versions of it, and a description. All of that is known here, so
* all of it is sent.
*/
class PublicImagesTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
// These tests are about the shape of the response, not about GD.
Queue::fake();
}
protected function project(array $attributes = []): Project
{
return Project::factory()->published()->create([
'title' => ['es' => 'Mi proyecto'],
'slug' => ['es' => 'mi-proyecto'],
'cover_image' => 'projects/cover.jpg',
'cover_image_alt' => ['es' => 'Captura del panel de la asociación'],
...$attributes,
]);
}
protected function markProcessed(string $path = 'projects/cover.jpg'): Media
{
$media = Media::query()->where('path', $path)->sole();
$media->forceFill([
'mime' => 'image/jpeg',
'width' => 1600,
'height' => 900,
'placeholder' => 'data:image/webp;base64,UklGRhoAAABXRUJQ',
'variants' => [
['format' => 'webp', 'width' => 400, 'path' => 'projects/cover-400.webp', 'size' => 9000],
['format' => 'webp', 'width' => 800, 'path' => 'projects/cover-800.webp', 'size' => 30000],
],
'processed_at' => now(),
])->save();
return $media;
}
#[Test]
public function an_image_arrives_as_an_object_with_everything_needed_to_render_it(): void
{
$this->project();
$this->markProcessed();
$image = $this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->json('data.cover_image');
// Absolute, because the frontend is another application on another
// host and a path only this app can resolve is no use to it.
$this->assertStringStartsWith('http', $image['url']);
// The pair that stops the page reflowing as images load.
$this->assertSame(1600, $image['width']);
$this->assertSame(900, $image['height']);
$this->assertSame(1.7778, $image['aspect_ratio']);
$this->assertSame('Captura del panel de la asociación', $image['alt']);
$this->assertStringStartsWith('data:image/', $image['placeholder']);
$this->assertStringContainsString('400w', $image['srcset']['webp']);
}
#[Test]
public function the_description_follows_the_language_being_read(): void
{
$this->project([
'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'],
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
'cover_image_alt' => ['es' => 'Captura del panel', 'ca' => 'Captura del tauler'],
]);
$this->markProcessed();
$this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca')
->assertOk()
->assertJsonPath('data.cover_image.alt', 'Captura del tauler');
}
#[Test]
public function an_image_with_no_description_says_so_rather_than_omitting_it(): void
{
$this->project(['cover_image_alt' => null]);
$this->markProcessed();
// `alt=""` is how a page says "this is decorative". A missing key
// would leave the frontend guessing.
$this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->assertJsonPath('data.cover_image.alt', '');
}
#[Test]
public function an_image_still_renders_before_its_versions_are_ready(): void
{
$this->project();
$image = $this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->json('data.cover_image');
// Degrades into a plain <img> instead of a hole in the page.
$this->assertStringStartsWith('http', $image['url']);
$this->assertNull($image['width']);
$this->assertNull($image['srcset']);
}
#[Test]
public function a_record_without_an_image_returns_null(): void
{
$this->project(['cover_image' => null, 'cover_image_alt' => null]);
$this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->assertJsonPath('data.cover_image', null);
}
#[Test]
public function a_members_portrait_is_described_by_their_name(): void
{
Member::factory()->create([
'name' => 'Ada Lovelace',
'slug' => 'ada',
'avatar' => 'members/ada.jpg',
]);
// Nothing to type, so nothing to get wrong.
$this->getJson('/api/public/v1/members/ada')
->assertOk()
->assertJsonPath('data.avatar.alt', 'Ada Lovelace');
}
#[Test]
public function a_listing_does_not_cost_a_query_per_image(): void
{
Project::factory()->count(5)->published()->sequence(fn ($sequence) => [
'cover_image' => "projects/cover-{$sequence->index}.jpg",
])->create();
DB::enableQueryLog();
$this->getJson('/api/public/v1/projects')->assertOk();
$queries = collect(DB::getQueryLog())
->filter(fn (array $q) => str_contains($q['query'], 'from "media"'));
DB::disableQueryLog();
// One eager load for the whole page, not one lookup per row.
$this->assertLessThanOrEqual(1, $queries->count());
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Client;
use App\Models\Member;
use App\Models\Project;
use App\Models\Service;
use App\Models\Talk;
use App\Models\Testimonial;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The pieces a landing page is made of.
*
* Four collections, no "home page" payload and no ordering of sections
* anywhere: what gets composed, and in what order, is the frontend's business.
*/
class ShowcaseApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
#[Test]
public function it_lists_published_services_in_the_order_they_were_arranged(): void
{
Service::factory()->create(['title' => ['es' => 'Segundo'], 'slug' => ['es' => 'segundo'], 'position' => 2]);
Service::factory()->create(['title' => ['es' => 'Primero'], 'slug' => ['es' => 'primero'], 'position' => 1]);
Service::factory()->draft()->create(['title' => ['es' => 'Borrador'], 'slug' => ['es' => 'borrador']]);
$titles = $this->getJson('/api/public/v1/services')
->assertOk()
->json('data.*.title');
$this->assertSame(['Primero', 'Segundo'], $titles);
}
#[Test]
public function a_service_resolves_by_slug_within_its_language(): void
{
Service::factory()->create([
'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'],
'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'],
]);
$this->getJson('/api/public/v1/services/desarrollo')
->assertOk()
->assertJsonPath('data.title', 'Desarrollo');
$this->getJson('/api/public/v1/services/desenvolupament?lang=ca')
->assertOk()
->assertJsonPath('data.title', 'Desenvolupament');
// A Catalan slug must not answer in Spanish, exactly as everywhere
// else in this API.
$this->getJson('/api/public/v1/services/desenvolupament')->assertNotFound();
}
#[Test]
public function a_service_carries_the_work_that_backs_it(): void
{
$service = Service::factory()->create([
'title' => ['es' => 'Desarrollo'], 'slug' => ['es' => 'desarrollo'],
]);
$published = Project::factory()->published()->create([
'title' => ['es' => 'Web de la asociación'], 'slug' => ['es' => 'web'],
]);
$draft = Project::factory()->draft()->create([
'title' => ['es' => 'A medias'], 'slug' => ['es' => 'a-medias'],
]);
$service->projects()->attach([$published->id, $draft->id]);
$data = $this->getJson('/api/public/v1/services/desarrollo')->assertOk()->json('data');
// Without real work behind it a service is a promise — but an
// unpublished project is not something the public may see.
$this->assertCount(1, $data['projects']);
$this->assertSame('Web de la asociación', $data['projects'][0]['title']);
}
#[Test]
public function a_draft_service_is_reachable_only_with_the_preview_secret(): void
{
config()->set('site.preview_token', 'secreto');
Service::factory()->draft()->create([
'title' => ['es' => 'Sin publicar'], 'slug' => ['es' => 'sin-publicar'],
]);
$this->getJson('/api/public/v1/services/sin-publicar')->assertNotFound();
$this->withHeaders(['X-Preview-Token' => 'secreto'])
->getJson('/api/public/v1/services/sin-publicar')
->assertOk();
}
#[Test]
public function the_wall_of_logos_leaves_out_whoever_asked_to_be_left_out(): void
{
$shy = Client::factory()->private()->create(['name' => 'Discreta']);
$shown = Client::factory()->create(['name' => 'Visible']);
Project::factory()->published()->create(['client_id' => $shy->id]);
Project::factory()->published()->create(['client_id' => $shown->id]);
$names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name');
// Appearing on somebody else's website is their call, not ours.
$this->assertSame(['Visible'], $names);
}
#[Test]
public function a_client_with_nothing_published_is_not_a_logo_the_site_can_justify(): void
{
$withWork = Client::factory()->create(['name' => 'Con obra']);
Project::factory()->published()->create(['client_id' => $withWork->id]);
$withoutWork = Client::factory()->create(['name' => 'Sin obra']);
Project::factory()->draft()->create(['client_id' => $withoutWork->id]);
$names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name');
$this->assertSame(['Con obra'], $names);
// Unless the frontend asks for all of them anyway.
$all = $this->getJson('/api/public/v1/clients?with_projects=0')->assertOk()->json('data.*.name');
$this->assertCount(2, $all);
}
#[Test]
public function a_translated_quote_says_that_it_was_translated(): void
{
Testimonial::factory()->create([
'quote' => ['es' => 'Nos cambiaron la forma de trabajar', 'en' => 'They changed how we work'],
'original_locale' => 'es',
'author_name' => 'Marta',
]);
$this->getJson('/api/public/v1/testimonials')
->assertOk()
->assertJsonPath('data.0.is_original', true)
->assertJsonPath('data.0.original_locale', 'es');
// Read in English these are our words, not hers, and the frontend is
// told so it can say "translated from Spanish" rather than pretend.
$this->withHeaders(['Accept-Language' => 'en'])
->getJson('/api/public/v1/testimonials')
->assertOk()
->assertJsonPath('data.0.is_original', false)
->assertJsonPath('data.0.quote', 'They changed how we work');
}
#[Test]
public function testimonials_can_be_asked_for_by_what_they_vouch_for(): void
{
$client = Client::factory()->create(['name' => 'Asociación', 'slug' => 'asociacion']);
$service = Service::factory()->create([
'title' => ['es' => 'Formación'], 'slug' => ['es' => 'formacion'],
]);
Testimonial::factory()->create(['client_id' => $client->id, 'author_name' => 'De cliente']);
Testimonial::factory()->create(['service_id' => $service->id, 'author_name' => 'De servicio']);
Testimonial::factory()->create(['author_name' => 'Suelto']);
// Attached to something rather than to "the home page", so a page asks
// for the ones that belong on it.
$this->getJson('/api/public/v1/testimonials?client=asociacion')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.author.name', 'De cliente');
$this->getJson('/api/public/v1/testimonials?service=formacion')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.author.name', 'De servicio');
}
#[Test]
public function a_private_testimonial_never_leaves_the_panel(): void
{
Testimonial::factory()->private()->create(['author_name' => 'Sin permiso']);
$this->getJson('/api/public/v1/testimonials')
->assertOk()
->assertJsonCount(0, 'data');
}
#[Test]
public function talks_come_back_newest_first_with_their_speaker(): void
{
$member = Member::factory()->create(['name' => 'Ada', 'slug' => 'ada']);
Talk::factory()->create(['title' => 'La vieja', 'held_at' => now()->subYears(2), 'member_id' => $member->id]);
Talk::factory()->create(['title' => 'La reciente', 'held_at' => now()->subMonth(), 'member_id' => $member->id]);
Talk::factory()->create(['title' => 'Oculta', 'is_public' => false]);
$data = $this->getJson('/api/public/v1/talks')->assertOk()->json('data');
$this->assertSame(['La reciente', 'La vieja'], array_column($data, 'title'));
$this->assertSame('Ada', $data[0]['speaker']['name']);
}
#[Test]
public function a_talks_title_is_not_translated(): void
{
Talk::factory()->create([
'title' => 'Taming reactive Node.js',
'language' => 'en',
'summary' => ['es' => 'Sobre streams', 'en' => 'About streams'],
]);
// The title is the name of a talk given once, in one language;
// translating it would invent a talk that never happened.
$this->getJson('/api/public/v1/talks')
->assertOk()
->assertJsonPath('data.0.title', 'Taming reactive Node.js')
->assertJsonPath('data.0.language', 'en')
->assertJsonPath('data.0.summary', 'Sobre streams');
}
#[Test]
public function a_project_now_carries_a_client_rather_than_a_typed_name(): void
{
$client = Client::factory()->create(['name' => 'Cruz Roja', 'slug' => 'cruz-roja']);
Project::factory()->published()->create([
'title' => ['es' => 'Su web'], 'slug' => ['es' => 'su-web'],
'client_id' => $client->id,
]);
$data = $this->getJson('/api/public/v1/projects/su-web')->assertOk()->json('data');
$this->assertSame('Cruz Roja', $data['client']['name']);
$this->assertSame('cruz-roja', $data['client']['slug']);
// The old free-text pair is gone, not renamed.
$this->assertArrayNotHasKey('beneficiary', $data);
}
#[Test]
public function services_appear_in_the_url_list_the_frontend_builds_its_sitemap_from(): void
{
Service::factory()->create([
'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'],
'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'],
]);
Service::factory()->draft()->create(['slug' => ['es' => 'borrador']]);
$services = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data.services');
$this->assertCount(1, $services);
$this->assertSame('/servicios/desarrollo', $services[0]['alternates']['es']);
$this->assertSame('/ca/serveis/desenvolupament', $services[0]['alternates']['ca']);
}
}

View File

@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\ApplicationKind;
use App\Enums\ApplicationStatus;
use App\Models\Application;
use App\Models\Call;
use App\Models\User;
use App\Notifications\ApplicationReceived;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The only endpoints on the public API a stranger can write to.
*
* Everything here is about the two ways that can go wrong: a real person
* losing their message, and a bot filling the table. The tests are written so
* that a change which trades one for the other fails.
*/
class SubmissionApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Symfony's Request::create() injects "Accept-Language: en-us", and
// this API honours content negotiation. Ask for Spanish explicitly.
$this->withHeaders(['Accept-Language' => 'es']);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
protected function payload(array $overrides = []): array
{
return array_merge([
'name' => 'Ada Lovelace',
'email' => 'ada@example.org',
'message' => 'Tengo unas horas los fines de semana y me gustaría echar una mano.',
'hours_per_week' => 4,
'links' => ['github' => 'https://github.com/ada'],
'consent' => true,
], $overrides);
}
protected function openCall(): Call
{
return Call::factory()->create([
'project_id' => null,
'title' => ['es' => 'Backend', 'en' => 'Backend', 'ca' => 'Backend'],
'slug' => ['es' => 'backend', 'en' => 'backend', 'ca' => 'rerefons'],
'opens_at' => now()->subWeek(),
'closes_at' => now()->addWeek(),
]);
}
#[Test]
public function it_accepts_an_application_to_an_open_call(): void
{
$call = $this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
->assertCreated()
->assertJsonPath('data.status', 'new')
->assertJsonPath('data.duplicate', false)
->assertJsonStructure(['data' => ['id'], 'message']);
$application = Application::sole();
$this->assertSame($call->id, $application->call_id);
$this->assertSame(ApplicationKind::Contribute, $application->kind);
$this->assertSame(ApplicationStatus::New, $application->status);
$this->assertSame('ada@example.org', $application->email);
$this->assertSame(4, $application->hours_per_week);
// Snapshotted, so the record still reads correctly once the call is
// renamed or deleted.
$this->assertSame('Backend', $application->call_title);
}
#[Test]
public function it_refuses_an_application_to_a_call_that_is_not_open(): void
{
Call::factory()->closed()->create([
'project_id' => null,
'slug' => ['es' => 'cerrada'],
]);
// Taking somebody's time to write a message for a position that is
// already gone is worse than showing them no form at all.
$this->postJson('/api/public/v1/calls/cerrada/apply', $this->payload())
->assertNotFound();
$this->assertSame(0, Application::query()->count());
}
#[Test]
public function it_requires_the_fields_that_make_a_reply_possible(): void
{
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', [
'name' => 'A',
'email' => 'not-an-email',
'message' => 'hola',
])->assertUnprocessable()
->assertJsonValidationErrors(['name', 'email', 'message', 'consent']);
}
#[Test]
public function it_will_not_store_personal_data_without_consent(): void
{
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload(['consent' => false]))
->assertUnprocessable()
->assertJsonValidationErrors('consent');
$this->assertSame(0, Application::query()->count());
}
#[Test]
public function a_honeypot_submission_is_filed_as_spam_rather_than_dropped(): void
{
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
config('site.submissions.honeypot') => 'http://buy-cheap-things.example',
]))->assertCreated()
// The bot is told nothing: an obvious rejection is a hint to try
// again differently.
->assertJsonPath('data.status', 'new');
// Nothing is thrown away. The honeypot is a heuristic, and a browser
// extension can trip it for a real person.
$this->assertSame(ApplicationStatus::Spam, Application::sole()->status);
}
#[Test]
public function a_repeat_submission_is_treated_as_the_same_one(): void
{
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
->assertCreated();
// A double-clicked submit button is not a second person, and an error
// would only make the visitor try harder.
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
->assertOk()
->assertJsonPath('data.duplicate', true);
$this->assertSame(1, Application::query()->count());
}
#[Test]
public function the_same_person_may_still_write_about_something_else(): void
{
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
$this->postJson('/api/public/v1/contact', $this->payload())->assertCreated();
$this->assertSame(2, Application::query()->count());
}
#[Test]
public function the_general_form_distinguishes_offering_help_from_asking_for_it(): void
{
$this->postJson('/api/public/v1/contact', $this->payload([
'kind' => ApplicationKind::Request->value,
'organisation' => 'Asociación Vecinal',
'email' => 'hola@asociacion.example',
]))->assertCreated();
$application = Application::sole();
$this->assertSame(ApplicationKind::Request, $application->kind);
$this->assertSame('Asociación Vecinal', $application->organisation);
$this->assertNull($application->call_id);
}
#[Test]
public function an_organisation_asking_for_help_has_to_say_who_it_is(): void
{
$this->postJson('/api/public/v1/contact', $this->payload([
'kind' => ApplicationKind::Request->value,
]))->assertUnprocessable()
->assertJsonValidationErrors('organisation');
}
#[Test]
public function the_kind_cannot_be_chosen_when_answering_a_call(): void
{
$this->openCall();
// Applying to a call is always an offer of time; letting the payload
// say otherwise would put replies in the wrong queue.
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
'kind' => ApplicationKind::Request->value,
]))->assertUnprocessable()
->assertJsonValidationErrors('kind');
}
#[Test]
public function it_records_the_language_the_visitor_wrote_in(): void
{
$this->openCall();
// Reached through the Catalan URL of the same call — a slug only
// resolves inside its own language.
$this->postJson('/api/public/v1/calls/rerefons/apply?lang=ca', $this->payload())
->assertCreated();
// So the reply goes out in the same language they used.
$this->assertSame('ca', Application::sole()->locale);
}
#[Test]
public function it_keeps_a_hash_of_the_address_rather_than_the_address(): void
{
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
->assertCreated();
$application = Application::sole();
$this->assertNotNull($application->ip_hash);
$this->assertSame(64, strlen($application->ip_hash));
$this->assertStringNotContainsString('127.0.0.1', $application->ip_hash);
}
#[Test]
public function a_confirmation_is_never_cached(): void
{
$this->openCall();
$response = $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
->assertCreated();
// A shared cache holding one of these would hand it to the next
// visitor.
$this->assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
}
#[Test]
public function it_tells_the_people_who_can_read_the_inbox(): void
{
Notification::fake();
$this->seed(RolePermissionSeeder::class);
$editor = User::factory()->create()->syncRoles('editor')->fresh();
$viewer = User::factory()->create()->syncRoles('viewer')->fresh();
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
Notification::assertSentTo($editor, ApplicationReceived::class);
// A viewer has no business reading strangers' contact details, so
// they are not told about them either.
Notification::assertNotSentTo($viewer, ApplicationReceived::class);
}
#[Test]
public function spam_does_not_wake_anybody_up(): void
{
Notification::fake();
$this->seed(RolePermissionSeeder::class);
$editor = User::factory()->create()->syncRoles('editor')->fresh();
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
config('site.submissions.honeypot') => 'spam',
]))->assertCreated();
Notification::assertNotSentTo($editor, ApplicationReceived::class);
}
#[Test]
public function submissions_stop_while_the_public_site_is_in_maintenance(): void
{
$this->openCall();
Maintenance::enable(['es' => 'Volvemos en un rato']);
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
->assertServiceUnavailable()
->assertJsonPath('maintenance', true);
$this->assertSame(0, Application::query()->count());
}
#[Test]
public function the_forms_can_be_closed_without_a_deploy(): void
{
config()->set('site.submissions.enabled', false);
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
->assertServiceUnavailable()
->assertJsonPath('submissions_enabled', false);
// And the frontend learns it from /site, so it hides the form instead
// of rendering one that would be refused.
$this->getJson('/api/public/v1/site')
->assertOk()
->assertJsonPath('data.submissions_enabled', false);
}
#[Test]
public function one_address_cannot_flood_the_inbox(): void
{
config()->set('site.submissions.rate_limit', 2);
// Otherwise the second request would be answered as a duplicate and
// never reach the limiter.
config()->set('site.submissions.deduplicate_minutes', 0);
$this->openCall();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertTooManyRequests();
}
#[Test]
public function the_read_endpoints_stay_read_only(): void
{
$this->openCall();
// The write routes are explicit; nothing else on the public API
// acquired a verb along the way.
$this->postJson('/api/public/v1/projects')->assertMethodNotAllowed();
$this->postJson('/api/public/v1/calls')->assertMethodNotAllowed();
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\MissingUrlStatus;
use App\Models\MissingUrl;
use App\Models\Redirect;
use App\Support\Maintenance;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The frontend reporting what it could not resolve.
*
* The endpoint is small; what needs holding still is everything around it. It
* is a write endpoint on the public API, so it is gated by a secret and can
* never be open by accident. It is fed by strangers' traffic, so a scan must
* not be able to turn the table into a log. And what it collects is only worth
* collecting if it lines up exactly with what a redirect matches against.
*/
class TelemetryApiTest extends TestCase
{
use RefreshDatabase;
protected string $token = 'ingest-secret';
protected function setUp(): void
{
parent::setUp();
config(['site.telemetry.token' => $this->token]);
$this->withHeaders(['Accept-Language' => 'es']);
}
/**
* @param array<string, mixed> $payload
*/
protected function report(array $payload): TestResponse
{
return $this->withHeaders(['X-Ingest-Token' => $this->token])
->postJson('/api/public/v1/telemetry/not-found', $payload);
}
#[Test]
public function it_records_an_address_that_could_not_be_resolved(): void
{
$this->report([
'path' => '/proyectos/nombre-viejo',
'locale' => 'es',
'referrer' => 'https://example.net/enlace',
])
->assertAccepted()
->assertJsonPath('data.recorded', 1)
// A confirmation is nobody else's, and this one is not cacheable
// for the same reason a submission's is not.
->assertHeader('Cache-Control', 'no-store, private');
$this->assertDatabaseHas('missing_urls', [
'path' => '/proyectos/nombre-viejo',
'locale' => 'es',
'hits' => 1,
'referrer' => 'https://example.net/enlace',
'status' => MissingUrlStatus::Pending->value,
]);
}
/**
* The property that makes this a counter rather than a log: a crawler
* walking a dead section must cost one row, not one row per request.
*/
#[Test]
public function reporting_the_same_address_again_counts_instead_of_repeating(): void
{
foreach (range(1, 3) as $ignored) {
$this->report(['path' => '/equipo/quien-sea', 'locale' => 'es'])->assertAccepted();
}
$this->assertSame(1, MissingUrl::query()->count());
$this->assertSame(3, (int) MissingUrl::query()->first()->hits);
}
/**
* Paths are normalised exactly as redirects are. If these two ever
* disagree, the list fills with addresses that already have a rule.
*/
#[Test]
public function it_normalises_the_path_the_way_a_redirect_does(): void
{
$this->report(['path' => '/proyectos/algo/', 'locale' => 'es'])->assertAccepted();
$this->report(['path' => '/proyectos/algo?utm_source=twitter', 'locale' => 'es'])->assertAccepted();
$this->assertSame(1, MissingUrl::query()->count());
$this->assertSame('/proyectos/algo', MissingUrl::query()->first()->path);
}
#[Test]
public function it_accepts_a_batch(): void
{
$this->report(['hits' => [
['path' => '/uno', 'locale' => 'es'],
['path' => '/dos', 'locale' => 'en'],
['path' => '/tres'],
]])
->assertAccepted()
->assertJsonPath('data.recorded', 3);
$this->assertSame(3, MissingUrl::query()->count());
}
/**
* The locale is half of the unique key, so it can never be null — the
* negotiated one stands in when the frontend does not say.
*/
#[Test]
public function a_report_without_a_locale_falls_back_to_the_negotiated_one(): void
{
$this->withHeaders(['Accept-Language' => 'ca'])
->report(['path' => '/sense-idioma'])
->assertAccepted();
$this->assertDatabaseHas('missing_urls', ['path' => '/sense-idioma', 'locale' => 'ca']);
}
/**
* The same address in two languages is two problems: they are fixed by two
* different redirects, because the paths differ per language.
*/
#[Test]
public function the_same_path_in_two_languages_is_two_rows(): void
{
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
$this->assertSame(2, MissingUrl::query()->count());
}
#[Test]
public function it_drops_scans_for_things_that_were_never_here(): void
{
$this->report(['hits' => [
['path' => '/wp-admin/setup-config.php'],
['path' => '/.env'],
['path' => '/proyectos/de-verdad'],
]])
->assertAccepted()
->assertJsonPath('data.received', 3)
// Reported honestly: the frontend can tell its filter from ours.
->assertJsonPath('data.recorded', 1);
$this->assertSame(1, MissingUrl::query()->count());
$this->assertSame('/proyectos/de-verdad', MissingUrl::query()->first()->path);
}
/**
* A later sighting without a referrer is what a bot produces. It must not
* erase the referrer that told us who is linking to a dead address.
*/
#[Test]
public function a_later_sighting_never_erases_a_known_referrer(): void
{
$this->report(['path' => '/algo', 'locale' => 'es', 'referrer' => 'https://example.net/post'])
->assertAccepted();
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
$this->assertSame('https://example.net/post', MissingUrl::query()->first()->referrer);
}
#[Test]
public function it_refuses_without_the_secret(): void
{
$this->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
->assertForbidden();
$this->withHeaders(['X-Ingest-Token' => 'wrong'])
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
->assertForbidden();
$this->assertSame(0, MissingUrl::query()->count());
}
/**
* The rule the preview secret already follows: an unset secret disables
* the endpoint, it never opens it.
*/
#[Test]
public function an_unset_secret_grants_nobody_access(): void
{
config(['site.telemetry.token' => null]);
$this->withHeaders(['X-Ingest-Token' => ''])
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
->assertForbidden();
$this->assertSame(0, MissingUrl::query()->count());
}
#[Test]
public function it_rejects_what_it_cannot_use(): void
{
$this->report(['path' => 'https://otro-dominio.example/algo'])
->assertUnprocessable()
->assertJsonValidationErrors('hits.0.path');
$this->report(['hits' => []])->assertUnprocessable();
$this->report(['path' => '/algo', 'locale' => 'de'])
->assertUnprocessable()
->assertJsonValidationErrors('hits.0.locale');
}
#[Test]
public function it_caps_how_much_one_request_may_carry(): void
{
config(['site.telemetry.max_batch' => 2]);
$this->report(['hits' => [
['path' => '/uno'], ['path' => '/dos'], ['path' => '/tres'],
]])->assertUnprocessable();
}
/**
* Maintenance is about what visitors are shown. A 404 during it is still a
* 404 — and it is the most likely moment to produce the interesting ones.
*/
#[Test]
public function it_keeps_listening_while_the_site_is_down_for_maintenance(): void
{
app(Maintenance::class)->enable();
$this->report(['path' => '/durante-el-mantenimiento', 'locale' => 'es'])
->assertAccepted();
$this->assertDatabaseHas('missing_urls', ['path' => '/durante-el-mantenimiento']);
}
/**
* The loop this feature exists to close: a report becomes a redirect, and
* the report stops asking to be dealt with.
*/
#[Test]
public function writing_a_redirect_resolves_the_report_it_answers(): void
{
$this->report(['path' => '/proyectos/viejo', 'locale' => 'es'])->assertAccepted();
Redirect::create([
'from' => '/proyectos/viejo',
'to' => '/proyectos/nuevo',
'status' => 301,
'locale' => 'es',
'is_enabled' => true,
]);
$this->assertSame(
MissingUrlStatus::Resolved,
MissingUrl::query()->first()->status,
);
}
#[Test]
public function a_redirect_for_every_language_resolves_every_languages_report(): void
{
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
Redirect::create([
'from' => '/algo',
'to' => '/otra-cosa',
'status' => 301,
'locale' => null,
'is_enabled' => true,
]);
$this->assertSame(0, MissingUrl::query()->pending()->count());
}
/**
* A redirect that was written and then deleted leaves the address broken
* again. Being told about it once must not mean being told never again.
*/
#[Test]
public function an_address_seen_again_after_being_dealt_with_comes_back(): void
{
$url = MissingUrl::factory()->resolved()->create([
'path' => '/vuelve',
'locale' => 'es',
]);
$this->report(['path' => '/vuelve', 'locale' => 'es'])->assertAccepted();
$url->refresh();
$this->assertSame(MissingUrlStatus::Pending, $url->status);
$this->assertNull($url->resolved_at);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class TokenEndpointTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function it_exchanges_credentials_for_a_token_and_provisions_the_user(): void
{
$this->fakePireon();
$response = $this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'ada@example.com',
'password' => 'secret',
]);
$response->assertOk()
->assertJsonPath('access_token', 'fake-access-token')
->assertJsonPath('user.email', 'ada@example.com')
->assertJsonPath('user.roles.0', config('pireon.default_role'));
$this->assertDatabaseHas('users', ['oauth_id' => 4242]);
// The password grant must reach the identity server, never a local
// password column.
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token')
&& $request['grant_type'] === 'password');
}
#[Test]
public function it_does_not_leak_why_a_credential_was_rejected(): void
{
Http::fake([
"{$this->pireonServer}/oauth/token" => Http::response([
'error' => 'invalid_grant',
'error_description' => 'The user credentials were incorrect.',
], 400),
]);
$response = $this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'ada@example.com',
'password' => 'wrong',
]);
$response->assertStatus(422);
$this->assertStringNotContainsString('credentials were incorrect', $response->content());
}
#[Test]
public function it_refuses_an_account_awaiting_approval(): void
{
$this->fakePireon(identity: ['approved_at' => null]);
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'ada@example.com',
'password' => 'secret',
])
->assertForbidden()
->assertJsonPath('message', __('pireon.pending_approval'));
$this->assertDatabaseCount('users', 0);
}
#[Test]
public function the_password_grant_can_be_disabled(): void
{
config()->set('pireon.password_grant_enabled', false);
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'ada@example.com',
'password' => 'secret',
])->assertStatus(422);
}
#[Test]
public function it_validates_the_grant_type(): void
{
$this->postJson('/api/v1/auth/token', ['grant_type' => 'implicit'])
->assertUnprocessable()
->assertJsonValidationErrors('grant_type');
}
#[Test]
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
{
$user = User::factory()->create();
$user->syncRoles(['viewer']);
$headers = $this->apiHeadersFor($user);
$this->getJson('/api/v1/me', $headers)->assertOk();
$this->getJson('/api/v1/me', $headers)->assertOk();
$this->getJson('/api/v1/me', $headers)->assertOk();
// Introspection is cached, so three requests cost one upstream call.
Http::assertSentCount(1);
}
}