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);
}
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Filament\Pages\Auth\PireonLogin;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Signing in with an e-mail and a password.
*
* The identity server still decides whether the credentials are right — there
* are no local passwords — but it does so out of sight: nobody signing in is
* sent anywhere or told that another server exists.
*/
class PasswordLoginTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
config()->set('pireon.password_grant_enabled', true);
}
#[Test]
public function correct_credentials_sign_somebody_in(): void
{
$this->fakePireon();
Livewire::test(PireonLogin::class)
->fillForm([
'email' => 'ada@example.com',
'password' => 'correcta',
'remember' => true,
])
->call('authenticate')
->assertHasNoFormErrors();
$this->assertTrue(Auth::check());
// The local account is provisioned on the way in, so the very first
// page already has roles to authorise against.
$user = User::query()->where('email', 'ada@example.com')->sole();
$this->assertSame(Auth::id(), $user->id);
$this->assertSame(4242, $user->oauth_id);
}
#[Test]
public function the_password_goes_to_the_identity_server_and_nowhere_else(): void
{
$this->fakePireon();
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 's3cr3t'])
->call('authenticate');
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token')
&& $request['grant_type'] === 'password'
&& $request['password'] === 's3cr3t');
// It is never written down: the local row has no usable password.
$user = User::query()->where('email', 'ada@example.com')->sole();
$this->assertNotSame('s3cr3t', $user->password);
}
#[Test]
public function wrong_credentials_say_the_same_thing_as_an_unknown_address(): void
{
Http::fake([
"{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401),
]);
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'quien@example.com', 'password' => 'mala'])
->call('authenticate')
->assertHasFormErrors(['email']);
// The identity server distinguishes the two; repeating that here would
// turn the form into a way of finding out who has an account.
$this->assertFalse(Auth::check());
}
#[Test]
public function an_identity_server_that_breaks_is_not_blamed_on_the_password(): void
{
// What a Passport server answers when it cannot sign a token at all —
// missing keys, say. It has judged nothing about these credentials.
Http::fake([
"{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500),
]);
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate')
->assertHasFormErrors(['email' => __('pireon.server_unavailable')]);
// Saying "those credentials are wrong" here sends somebody to check a
// password that was never the problem.
$this->assertFalse(Auth::check());
}
#[Test]
public function an_identity_server_that_cannot_be_reached_says_so_too(): void
{
Http::fake(fn () => throw new ConnectionException('Connection refused'));
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate')
->assertHasFormErrors(['email' => __('pireon.server_unavailable')]);
}
#[Test]
public function the_api_token_endpoint_makes_the_same_distinction(): void
{
Http::fake([
"{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500),
]);
// 503, not 401: nothing was decided about these credentials.
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'ada@example.com',
'password' => 'correcta',
])->assertStatus(503);
}
#[Test]
public function a_deactivated_account_is_told_why(): void
{
$this->fakePireon(['is_active' => false]);
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate')
->assertHasFormErrors(['email' => __('pireon.inactive')]);
// Said only once the password was right, so it reveals nothing to
// somebody guessing.
$this->assertFalse(Auth::check());
}
#[Test]
public function an_account_still_awaiting_approval_is_told_so(): void
{
$this->fakePireon(['approved_at' => null]);
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate')
->assertHasFormErrors(['email' => __('pireon.pending_approval')]);
$this->assertFalse(Auth::check());
}
#[Test]
public function a_valid_account_with_no_role_here_is_still_refused(): void
{
// Nothing grants the default role in this test, so the user arrives
// with no way into the panel.
config()->set('pireon.default_role', null);
$this->fakePireon();
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate')
->assertHasFormErrors(['email' => __('pireon.no_permission')]);
// Being a valid account upstream is not the same as being allowed in
// here: roles are this application's business.
$this->assertFalse(Auth::check());
}
#[Test]
public function it_says_so_plainly_when_password_sign_in_is_switched_off(): void
{
config()->set('pireon.password_grant_enabled', false);
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate')
->assertHasFormErrors(['email' => __('pireon.password_grant_disabled')]);
// A misconfiguration is an operator's problem, not a visitor's, so it
// does not masquerade as "those credentials are incorrect".
Http::assertNothingSent();
}
#[Test]
public function it_stops_after_five_attempts(): void
{
Http::fake([
"{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401),
]);
$component = Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'mala']);
for ($attempt = 0; $attempt < 5; $attempt++) {
$component->call('authenticate')->assertHasFormErrors(['email']);
}
// The sixth is refused here rather than spending the identity
// server's own allowance.
Http::fake();
$component->call('authenticate');
Http::assertNothingSent();
}
#[Test]
public function the_session_id_is_rotated_on_the_way_in(): void
{
$this->fakePireon();
$before = session()->getId();
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate');
// A session fixated before signing in must not survive now that it
// carries an identity.
$this->assertNotSame($before, session()->getId());
}
#[Test]
public function the_users_language_follows_them_into_the_panel(): void
{
$this->fakePireon();
// A pre-existing account: the default role is only granted to brand
// new identities, so this one needs one of its own to get in.
User::factory()
->create(['email' => 'ada@example.com', 'oauth_id' => 4242, 'locale' => 'ca'])
->assignRole('viewer');
Livewire::test(PireonLogin::class)
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
->call('authenticate');
$this->assertSame('ca', session(config('locales.session_key')));
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class PireonLoginTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function it_redirects_to_the_identity_server_with_a_pkce_challenge(): void
{
$response = $this->get(route('auth.redirect'));
$response->assertRedirectContains($this->pireonServer.'/oauth/authorize');
$query = [];
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
$this->assertSame('code', $query['response_type']);
$this->assertSame('test-client-id', $query['client_id']);
$this->assertSame('S256', $query['code_challenge_method']);
$this->assertNotEmpty($query['code_challenge']);
// The verifier itself must stay server-side; only its hash travels.
$verifier = session('pireon.code_verifier');
$this->assertNotEmpty($verifier);
$this->assertStringNotContainsString($verifier, (string) $response->headers->get('Location'));
$expectedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$this->assertSame($expectedChallenge, $query['code_challenge']);
}
#[Test]
public function it_signs_the_user_in_and_provisions_a_local_account(): void
{
$this->fakePireon();
$response = $this
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state']));
$response->assertRedirect();
$user = User::where('oauth_id', 4242)->first();
$this->assertNotNull($user);
$this->assertSame('ada@example.com', $user->email);
$this->assertAuthenticatedAs($user);
// New identities get the least-privileged role, never the app_roles
// the identity server reported.
$this->assertTrue($user->hasRole(config('pireon.default_role')));
$this->assertFalse($user->hasRole('admin'));
$this->assertSame(['user'], $user->remote_roles);
}
#[Test]
public function it_rejects_a_callback_whose_state_does_not_match(): void
{
$this->fakePireon();
$response = $this
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'attacker-state']));
$response->assertRedirect(route('login'));
$this->assertGuest();
$this->assertDatabaseCount('users', 0);
}
#[Test]
public function it_refuses_an_account_still_awaiting_approval(): void
{
$this->fakePireon(identity: ['approved_at' => null]);
$response = $this
->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
$response->assertRedirect(route('login'));
$response->assertSessionHas('error', __('pireon.pending_approval'));
$this->assertGuest();
}
#[Test]
public function it_refuses_a_deactivated_account(): void
{
$this->fakePireon(identity: ['is_active' => false]);
$response = $this
->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
$response->assertSessionHas('error', __('pireon.inactive'));
$this->assertGuest();
}
#[Test]
public function it_adopts_a_pre_existing_local_account_with_the_same_email(): void
{
$existing = User::factory()->unlinked()->create(['email' => 'ada@example.com']);
$existing->assignRole('admin');
$this->fakePireon();
$this->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
$this->assertDatabaseCount('users', 1);
$existing->refresh();
$this->assertSame(4242, $existing->oauth_id);
// Roles granted locally survive the first real sign-in.
$this->assertTrue($existing->hasRole('admin'));
}
#[Test]
public function signing_out_clears_the_stored_oauth_token(): void
{
$user = $this->userWithRoles('viewer');
$this->actingAs($user)
->withSession(['pireon.token' => ['access_token' => 'abc']])
->post(route('logout'))
->assertRedirect(route('login'));
$this->assertGuest();
$this->assertNull(session('pireon.token'));
}
}

View File

@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Git;
use App\Enums\GitProvider;
use App\Models\Repository;
use App\Services\Git\GitException;
use App\Services\Git\GitManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The collective hosts its own Gitea, so this is the driver that matters.
*/
class GiteaSyncTest extends TestCase
{
use RefreshDatabase;
protected string $instance = 'https://git.pireon.test';
protected function setUp(): void
{
parent::setUp();
config()->set('git.providers.gitea.url', $this->instance);
config()->set('git.providers.gitea.token', 'a-token');
}
/**
* @param array<string, mixed> $repo
* @param array<int, array<string, mixed>> $issues
* @param array<int, array<string, mixed>> $commits
*/
protected function fakeGitea(array $repo = [], array $issues = [], array $commits = []): void
{
Http::fake([
"{$this->instance}/api/v1/repos/*/languages" => Http::response(['PHP' => 90000, 'Blade' => 12000]),
"{$this->instance}/api/v1/repos/*/topics" => Http::response(['topics' => ['laravel', 'comunidad']]),
"{$this->instance}/api/v1/repos/*/issues*" => Http::response($issues),
"{$this->instance}/api/v1/repos/*/commits*" => Http::response($commits ?: [
['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]],
['author' => ['login' => 'grace'], 'commit' => ['author' => ['email' => 'grace@x.test']]],
['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]],
]),
"{$this->instance}/api/v1/repos/*" => Http::response([
'full_name' => 'colectivo/web',
'html_url' => "{$this->instance}/colectivo/web",
'description' => 'La web del colectivo',
'website' => 'https://colectivo.test',
'default_branch' => 'main',
'language' => 'PHP',
// Gitea's counter names differ from GitHub's.
'stars_count' => 42,
'forks_count' => 3,
'watchers_count' => 7,
'open_issues_count' => 5,
'archived' => false,
'fork' => false,
'updated_at' => '2026-08-01T10:00:00Z',
'created_at' => '2024-01-01T00:00:00Z',
...$repo,
]),
]);
}
#[Test]
public function it_reads_giteas_counter_names(): void
{
$this->fakeGitea();
$repository = Repository::factory()->create([
'provider' => GitProvider::Gitea,
'full_name' => 'colectivo/web',
'stars' => 0,
]);
app(GitManager::class)->sync($repository);
$repository->refresh();
$this->assertSame(42, $repository->stars);
$this->assertSame(3, $repository->forks);
$this->assertSame(7, $repository->watchers);
$this->assertSame(5, $repository->open_issues_count);
$this->assertSame('PHP', $repository->primary_language);
$this->assertSame(['laravel', 'comunidad'], $repository->topics);
$this->assertSame('https://colectivo.test', $repository->homepage);
$this->assertNull($repository->sync_error);
}
#[Test]
public function it_authenticates_with_giteas_token_scheme(): void
{
$this->fakeGitea();
app(GitManager::class)->sync(
Repository::factory()->create(['provider' => GitProvider::Gitea, 'full_name' => 'colectivo/web'])
);
// Gitea expects "token <t>", not "Bearer <t>".
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'token a-token'));
}
#[Test]
public function it_asks_gitea_to_exclude_pull_requests(): void
{
$this->fakeGitea();
app(GitManager::class)->sync(
Repository::factory()->create(['provider' => GitProvider::Gitea, 'full_name' => 'colectivo/web'])
);
// Unlike GitHub, Gitea can filter PRs server-side.
Http::assertSent(fn ($request) => ! str_contains($request->url(), '/issues')
|| str_contains($request->url(), 'type=issues'));
}
#[Test]
public function it_mirrors_only_newcomer_friendly_issues(): void
{
$this->fakeGitea(issues: [
[
'number' => 1, 'title' => 'Buena para empezar', 'html_url' => "{$this->instance}/x/1",
'labels' => [['name' => 'good first issue']], 'comments' => 2,
'user' => ['login' => 'ada'], 'assignees' => [],
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
],
[
'number' => 2, 'title' => 'Se busca ayuda', 'html_url' => "{$this->instance}/x/2",
'labels' => [['name' => 'Se busca ayuda']], 'comments' => 0,
'user' => ['login' => 'ada'],
// Gitea reports assignees as a list.
'assignees' => [['login' => 'grace']],
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
],
[
'number' => 3, 'title' => 'Refactor interno', 'html_url' => "{$this->instance}/x/3",
'labels' => [['name' => 'chore']], 'comments' => 0,
'user' => ['login' => 'ada'], 'assignees' => [],
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
],
]);
$repository = Repository::factory()->create([
'provider' => GitProvider::Gitea,
'full_name' => 'colectivo/web',
]);
app(GitManager::class)->sync($repository);
$this->assertSame(2, $repository->issues()->count());
$this->assertSame(1, $repository->goodFirstIssues()->count());
// The Spanish label spelling from config/git.php matches too.
$claimed = $repository->issues()->where('number', 2)->first();
$this->assertTrue($claimed->is_help_wanted);
$this->assertSame('grace', $claimed->assignee);
$this->assertFalse($claimed->isUnclaimed());
}
#[Test]
public function the_contributor_count_is_derived_from_distinct_commit_authors(): void
{
$this->fakeGitea();
$repository = Repository::factory()->create([
'provider' => GitProvider::Gitea,
'full_name' => 'colectivo/web',
]);
app(GitManager::class)->sync($repository);
// Gitea has no contributors endpoint: three commits by two people.
$this->assertSame(2, $repository->fresh()->contributors_count);
}
#[Test]
public function commit_authors_without_an_account_still_count_once(): void
{
$this->fakeGitea(commits: [
['commit' => ['author' => ['email' => 'sin-cuenta@x.test']]],
['commit' => ['author' => ['email' => 'Sin-Cuenta@x.test']]],
['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]],
]);
$repository = Repository::factory()->create([
'provider' => GitProvider::Gitea,
'full_name' => 'colectivo/web',
]);
app(GitManager::class)->sync($repository);
$this->assertSame(2, $repository->fresh()->contributors_count);
}
#[Test]
public function a_private_repository_without_a_token_reports_the_real_cause(): void
{
Http::fake(["{$this->instance}/api/v1/repos/*" => Http::response([], 403)]);
$repository = Repository::factory()->create([
'provider' => GitProvider::Gitea,
'full_name' => 'colectivo/privado',
]);
try {
app(GitManager::class)->sync($repository);
$this->fail('Expected a GitException.');
} catch (GitException $e) {
$this->assertSame(403, $e->status);
$this->assertStringContainsString('GITEA_TOKEN', $e->getMessage());
}
}
#[Test]
public function an_instance_that_is_down_is_treated_as_transient(): void
{
Http::fake(["{$this->instance}/api/v1/repos/*" => Http::response([], 502)]);
$repository = Repository::factory()->create([
'provider' => GitProvider::Gitea,
'full_name' => 'colectivo/web',
]);
try {
app(GitManager::class)->sync($repository);
$this->fail('Expected a GitException.');
} catch (GitException $e) {
// Self-hosted instances restart; the job should retry, not give up.
$this->assertTrue($e->isTransient);
}
}
#[Test]
public function without_an_instance_url_gitea_is_not_offered_as_a_provider(): void
{
config()->set('git.providers.gitea.url', null);
// A driver with nowhere to point is worse than no driver: it would
// only produce failures later.
$this->assertFalse(GitProvider::Gitea->isSupported());
$repository = Repository::factory()->create(['provider' => GitProvider::Gitea]);
$this->expectException(GitException::class);
$this->expectExceptionMessage('GITEA_URL');
app(GitManager::class)->sync($repository);
}
#[Test]
public function a_bare_name_is_qualified_with_the_configured_org(): void
{
config()->set('git.providers.gitea.default_owner', 'colectivo');
$this->fakeGitea();
$repository = Repository::factory()->create([
'provider' => GitProvider::Gitea,
'full_name' => 'web',
]);
app(GitManager::class)->sync($repository);
$this->assertSame('colectivo/web', $repository->fresh()->full_name);
Http::assertSent(fn ($r) => str_contains($r->url(), '/repos/colectivo/web'));
}
#[Test]
public function gitea_is_the_default_provider(): void
{
$this->assertSame(GitProvider::Gitea, GitProvider::default());
$this->assertSame('gitea', Repository::factory()->create()->provider->value);
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Git;
use App\Enums\GitProvider;
use App\Models\Repository;
use App\Services\Git\GitException;
use App\Services\Git\GitManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The GitHub driver is kept for mirrors and the odd repository hosted
* elsewhere; Gitea is the collective's own instance (see GiteaSyncTest).
* These tests also stand as the proof that the provider interface really is
* substitutable.
*/
class RepositorySyncTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Gitea is the default, so GitHub has to be asked for explicitly.
$this->githubDefaults = ['provider' => GitProvider::GitHub];
}
/** @var array<string, mixed> */
protected array $githubDefaults = [];
/**
* @param array<string, mixed> $repo
* @param array<int, array<string, mixed>> $issues
*/
protected function fakeGitHub(array $repo = [], array $issues = []): void
{
Http::fake([
'api.github.com/repos/*/languages' => Http::response(['PHP' => 90000, 'Blade' => 12000]),
'api.github.com/repos/*/contributors*' => Http::response([['login' => 'a']], 200, [
// GitHub reports the total through the Link header's last page.
'Link' => '<https://api.github.com/repositories/1/contributors?per_page=1&page=7>; rel="last"',
]),
'api.github.com/repos/*/issues*' => Http::response($issues),
'api.github.com/repos/*' => Http::response([
'full_name' => 'colectivo/web',
'html_url' => 'https://github.com/colectivo/web',
'description' => 'La web del colectivo',
'default_branch' => 'main',
'language' => 'PHP',
'license' => ['spdx_id' => 'AGPL-3.0'],
'topics' => ['laravel', 'community'],
'stargazers_count' => 128,
'forks_count' => 9,
'subscribers_count' => 5,
'open_issues_count' => 12,
'archived' => false,
'fork' => false,
'pushed_at' => '2026-08-01T10:00:00Z',
'created_at' => '2024-01-01T00:00:00Z',
...$repo,
]),
]);
}
#[Test]
public function it_mirrors_the_repository_stats(): void
{
$this->fakeGitHub();
$repository = Repository::factory()->create([
...$this->githubDefaults,
'full_name' => 'colectivo/web',
'stars' => 0,
'last_synced_at' => null,
]);
app(GitManager::class)->sync($repository);
$repository->refresh();
$this->assertSame(128, $repository->stars);
$this->assertSame('PHP', $repository->primary_language);
$this->assertSame('AGPL-3.0', $repository->license);
$this->assertSame(['laravel', 'community'], $repository->topics);
$this->assertSame(['PHP' => 90000, 'Blade' => 12000], $repository->languages);
// Contributor count is read from the Link header's last page rather
// than by paging through every contributor.
$this->assertSame(7, $repository->contributors_count);
$this->assertNotNull($repository->last_synced_at);
$this->assertNull($repository->sync_error);
}
#[Test]
public function it_only_mirrors_newcomer_friendly_issues(): void
{
$this->fakeGitHub(issues: [
[
'number' => 1, 'title' => 'Good one', 'html_url' => 'https://x/1',
'labels' => [['name' => 'good first issue']], 'comments' => 2,
'user' => ['login' => 'ada'], 'assignee' => null,
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
],
[
'number' => 2, 'title' => 'Help please', 'html_url' => 'https://x/2',
'labels' => [['name' => 'Help Wanted']], 'comments' => 0,
'user' => ['login' => 'ada'], 'assignee' => ['login' => 'bob'],
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
],
// Ordinary issue: noise for a newcomer, so it is not mirrored.
[
'number' => 3, 'title' => 'Refactor internals', 'html_url' => 'https://x/3',
'labels' => [['name' => 'chore']], 'comments' => 0,
'user' => ['login' => 'ada'], 'assignee' => null,
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
],
// A pull request is not something anyone picks up.
[
'number' => 4, 'title' => 'A PR', 'html_url' => 'https://x/4',
'labels' => [['name' => 'good first issue']], 'pull_request' => ['url' => 'x'],
'user' => ['login' => 'ada'], 'created_at' => '2026-07-01T00:00:00Z',
'updated_at' => '2026-07-02T00:00:00Z',
],
]);
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
app(GitManager::class)->sync($repository);
$this->assertSame(2, $repository->issues()->count());
$this->assertSame(1, $repository->goodFirstIssues()->count());
// Label matching is case-insensitive, per config/git.php.
$helpWanted = $repository->issues()->where('number', 2)->first();
$this->assertTrue($helpWanted->is_help_wanted);
$this->assertSame('bob', $helpWanted->assignee);
$this->assertFalse($helpWanted->isUnclaimed());
}
#[Test]
public function issues_closed_upstream_disappear_here(): void
{
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
$repository->issues()->create([
'number' => 99, 'title' => 'Closed since', 'html_url' => 'https://x/99',
'is_good_first_issue' => true,
]);
$this->fakeGitHub(issues: []);
app(GitManager::class)->sync($repository);
// Advertising an issue that is actually closed wastes a would-be
// contributor's time, so the mirror is replaced, not merged.
$this->assertSame(0, $repository->issues()->count());
}
#[Test]
public function a_missing_repository_is_recorded_and_not_retried(): void
{
Http::fake([
'api.github.com/*' => Http::response(['message' => 'Not Found'], 404),
]);
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']);
try {
app(GitManager::class)->sync($repository);
$this->fail('Expected a GitException.');
} catch (GitException $e) {
$this->assertSame(404, $e->status);
$this->assertFalse($e->isTransient);
}
$repository->refresh();
$this->assertStringContainsString('does not exist', $repository->sync_error);
$this->assertSame(1, $repository->sync_failures);
}
#[Test]
public function repeated_failures_flag_the_repository_in_the_panel(): void
{
Http::fake(['api.github.com/*' => Http::response([], 404)]);
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']);
foreach (range(1, 3) as $ignored) {
rescue(fn () => app(GitManager::class)->sync($repository));
}
$this->assertSame(3, $repository->fresh()->sync_failures);
$this->assertTrue(Repository::query()->failing()->exists());
}
#[Test]
public function a_rate_limit_is_reported_as_transient(): void
{
Http::fake([
'api.github.com/*' => Http::response(['message' => 'rate limit'], 403, [
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (string) (time() + 120),
]),
]);
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
try {
app(GitManager::class)->sync($repository);
$this->fail('Expected a GitException.');
} catch (GitException $e) {
// Transient means the job should back off and retry, not give up.
$this->assertTrue($e->isTransient);
$this->assertSame(429, $e->status);
$this->assertStringContainsString('GITHUB_TOKEN', $e->getMessage());
}
}
#[Test]
public function a_successful_sync_clears_a_previous_error(): void
{
$repository = Repository::factory()->failing()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
$this->fakeGitHub();
app(GitManager::class)->sync($repository);
$repository->refresh();
$this->assertNull($repository->sync_error);
$this->assertSame(0, $repository->sync_failures);
}
#[Test]
public function synced_columns_cannot_be_mass_assigned(): void
{
// A hand-typed star count is a lie waiting to happen, so the sync
// columns are deliberately absent from the fillable list.
$repository = new Repository;
$repository->fill(['full_name' => 'colectivo/web', 'stars' => 9999]);
$this->assertSame('colectivo/web', $repository->full_name);
$this->assertNull($repository->stars);
}
#[Test]
public function a_bare_name_is_qualified_with_the_configured_org(): void
{
config()->set('git.providers.github.default_owner', 'colectivo');
$this->fakeGitHub();
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'web']);
app(GitManager::class)->sync($repository);
// The provider's own answer wins, so the record self-corrects.
$this->assertSame('colectivo/web', $repository->fresh()->full_name);
Http::assertSent(fn ($request) => str_contains($request->url(), '/repos/colectivo/web'));
}
#[Test]
public function an_unsupported_provider_fails_clearly(): void
{
$repository = Repository::factory()->create(['provider' => 'gitlab']);
$this->expectException(GitException::class);
$this->expectExceptionMessage('No driver is registered for the [gitlab] provider');
app(GitManager::class)->sync($repository);
}
}

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Media;
use App\Jobs\ProcessMedia;
use App\Models\Media;
use App\Models\Project;
use App\Services\Media\ImageProcessor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* What an image has to know about itself before a server-rendered page can
* lay it out properly: its size, narrower versions of it, and something to
* paint while it loads.
*/
class ImageProcessingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake(config('media.disk'));
}
/**
* A real encoded image, because the whole point of this code is what it
* does to actual pixels.
*/
protected function putImage(string $path, int $width = 1600, int $height = 900): Media
{
$image = imagecreatetruecolor($width, $height);
imagefill($image, 0, 0, imagecolorallocate($image, 30, 90, 180));
ob_start();
imagejpeg($image, null, 90);
$contents = (string) ob_get_clean();
imagedestroy($image);
Storage::disk(config('media.disk'))->put($path, $contents);
return Media::query()->create(['disk' => config('media.disk'), 'path' => $path]);
}
#[Test]
public function it_records_the_dimensions_a_page_needs_to_reserve_the_box(): void
{
$media = $this->putImage('projects/cover.jpg', 1600, 900);
app(ImageProcessor::class)->process($media);
$media->refresh();
// Without these the page reflows as each image arrives.
$this->assertSame(1600, $media->width);
$this->assertSame(900, $media->height);
$this->assertSame(1.7778, $media->aspectRatio());
$this->assertSame('image/jpeg', $media->mime);
$this->assertNotNull($media->processed_at);
$this->assertNull($media->error);
}
#[Test]
public function it_derives_narrower_versions_in_a_modern_format(): void
{
$media = $this->putImage('projects/cover.jpg', 1600, 900);
app(ImageProcessor::class)->process($media);
$media->refresh();
$disk = Storage::disk(config('media.disk'));
$this->assertNotEmpty($media->variants);
foreach ($media->variants as $variant) {
$this->assertTrue($disk->exists($variant['path']), "missing {$variant['path']}");
$this->assertLessThan(1600, $variant['width']);
}
// WebP is the one format every supported PHP can write; AVIF is
// produced too wherever the runtime can encode it.
$this->assertContains('webp', array_column($media->variants, 'format'));
}
#[Test]
public function it_never_scales_an_image_up(): void
{
$media = $this->putImage('projects/small.jpg', 500, 500);
app(ImageProcessor::class)->process($media);
$widths = array_column($media->refresh()->variants, 'width');
// A 500px original must not be republished as a blurry "1600px"
// version; only the widths below it are real.
$this->assertSame([400], array_values(array_unique($widths)));
}
#[Test]
public function it_builds_a_srcset_the_frontend_can_use_verbatim(): void
{
$media = $this->putImage('projects/cover.jpg', 1600, 900);
app(ImageProcessor::class)->process($media);
$srcset = $media->refresh()->srcset();
$this->assertArrayHasKey('webp', $srcset);
$this->assertStringContainsString('400w', $srcset['webp']);
$this->assertStringContainsString('800w', $srcset['webp']);
// Absolute, because the frontend is a different application on a
// different host.
$this->assertStringStartsWith('http', $srcset['webp']);
}
#[Test]
public function it_produces_a_placeholder_small_enough_to_inline(): void
{
$media = $this->putImage('projects/cover.jpg');
app(ImageProcessor::class)->process($media);
$placeholder = $media->refresh()->placeholder;
$this->assertStringStartsWith('data:image/', $placeholder);
// It travels inside every JSON response mentioning the image, so its
// size is part of the contract, not an implementation detail.
$this->assertLessThan(2048, strlen($placeholder));
}
#[Test]
public function reprocessing_replaces_the_previous_versions_rather_than_piling_up(): void
{
$media = $this->putImage('projects/cover.jpg');
app(ImageProcessor::class)->process($media);
$first = $media->refresh()->variants;
app(ImageProcessor::class)->process($media->refresh());
$second = $media->refresh()->variants;
$this->assertCount(count($first), $second);
$disk = Storage::disk(config('media.disk'));
foreach ($second as $variant) {
$this->assertTrue($disk->exists($variant['path']));
}
}
#[Test]
public function a_vector_is_copied_through_untouched(): void
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 120"></svg>';
Storage::disk(config('media.disk'))->put('projects/logo.svg', $svg);
$media = Media::query()->create(['disk' => config('media.disk'), 'path' => 'projects/logo.svg']);
app(ImageProcessor::class)->process($media);
$media->refresh();
// Resampling a vector loses the point of it, but the frontend still
// needs a size to reserve the box — so the viewBox is read.
$this->assertSame(240, $media->width);
$this->assertSame(120, $media->height);
$this->assertSame([], $media->variants);
}
#[Test]
public function a_missing_file_is_recorded_rather_than_thrown(): void
{
$media = Media::query()->create([
'disk' => config('media.disk'),
'path' => 'projects/gone.jpg',
]);
app(ImageProcessor::class)->process($media);
$media->refresh();
// The upload keeps working as a plain image; a broken derivation must
// not take a page down with it.
$this->assertNotNull($media->error);
$this->assertNotNull($media->processed_at);
}
#[Test]
public function saving_an_image_path_registers_it_and_queues_the_work(): void
{
Queue::fake();
$project = Project::factory()->create(['cover_image' => 'projects/new.jpg']);
$media = Media::query()->where('path', 'projects/new.jpg')->sole();
$this->assertSame(config('media.disk'), $media->disk);
Queue::assertPushed(ProcessMedia::class);
// An unrelated save must not queue the same work again.
Queue::fake();
$project->update(['is_featured' => true]);
Queue::assertNotPushed(ProcessMedia::class);
}
#[Test]
public function two_records_pointing_at_one_upload_share_a_single_row(): void
{
Queue::fake();
Project::factory()->create(['cover_image' => 'projects/shared.jpg']);
Project::factory()->create(['cover_image' => 'projects/shared.jpg']);
// The file is the identity, so the second one costs no extra
// processing and no second set of derived versions.
$this->assertSame(1, Media::query()->where('path', 'projects/shared.jpg')->count());
Queue::assertPushed(ProcessMedia::class, 1);
}
#[Test]
public function the_command_picks_up_whatever_the_queue_did_not(): void
{
Queue::fake();
$this->putImage('projects/pending.jpg');
$this->assertSame(1, Media::query()->pending()->count());
$this->artisan('media:process')->assertSuccessful();
// The scheduler runs this, so a worker that is down costs a delay
// rather than an image that stays half-done forever.
$this->assertSame(0, Media::query()->pending()->count());
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Media;
use App\Filament\Resources\Projects\ProjectResource;
use App\Models\ImageAttachment;
use App\Models\Media;
use App\Models\Project;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* More than one image on a project: the screenshots and the before-and-after
* that make a write-up worth reading.
*/
class ProjectGalleryTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
protected function projectWithGallery(): Project
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Mi proyecto'],
'slug' => ['es' => 'mi-proyecto'],
]);
$project->images()->createMany([
[
'path' => 'projects/gallery/after.jpg',
'alt' => ['es' => 'El panel nuevo'],
'caption' => ['es' => 'Después'],
'position' => 2,
],
[
'path' => 'projects/gallery/before.jpg',
'alt' => ['es' => 'La hoja de cálculo que usaban'],
'caption' => ['es' => 'Antes'],
'position' => 1,
],
]);
return $project;
}
#[Test]
public function the_gallery_comes_back_in_the_order_it_was_arranged(): void
{
$this->projectWithGallery();
$gallery = $this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->json('data.gallery');
// An unordered gallery is a different gallery every time it is read,
// and "before" after "after" tells the wrong story.
$this->assertSame(['Antes', 'Después'], array_column($gallery, 'caption'));
}
#[Test]
public function each_image_carries_everything_needed_to_render_it(): void
{
$this->projectWithGallery();
Media::query()->where('path', 'projects/gallery/before.jpg')->sole()->forceFill([
'width' => 1200,
'height' => 800,
'variants' => [
['format' => 'webp', 'width' => 400, 'path' => 'projects/gallery/before-400.webp', 'size' => 8000],
],
'processed_at' => now(),
])->save();
$first = $this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->json('data.gallery.0');
$this->assertStringStartsWith('http', $first['url']);
$this->assertSame(1200, $first['width']);
$this->assertSame('La hoja de cálculo que usaban', $first['alt']);
$this->assertStringContainsString('400w', $first['srcset']['webp']);
// A caption is not a description: one is shown to everyone, the other
// is read by whoever cannot see the image.
$this->assertSame('Antes', $first['caption']);
}
#[Test]
public function a_listing_does_not_carry_every_screenshot_of_every_project(): void
{
$this->projectWithGallery();
$project = $this->getJson('/api/public/v1/projects')
->assertOk()
->json('data.0');
$this->assertArrayNotHasKey('gallery', $project);
}
#[Test]
public function adding_a_gallery_image_registers_it_like_any_other_upload(): void
{
$project = Project::factory()->create();
$project->images()->create(['path' => 'projects/gallery/new.jpg']);
// Nothing special about a gallery image: same media row, same derived
// versions, same queue.
$this->assertSame(1, Media::query()->where('path', 'projects/gallery/new.jpg')->count());
}
#[Test]
public function erasing_a_project_for_good_takes_its_gallery_with_it(): void
{
$project = $this->projectWithGallery();
$project->delete();
// A soft delete keeps it — that is the point of a soft delete.
$this->assertSame(2, ImageAttachment::query()->count());
$project->forceDelete();
// A morph relation has no foreign key to cascade for it, so this is
// handled explicitly and worth pinning down.
$this->assertSame(0, ImageAttachment::query()->count());
}
#[Test]
public function the_gallery_editor_renders(): void
{
$this->seed(RolePermissionSeeder::class);
$project = $this->projectWithGallery();
$this->actingAs($this->userWithRoles('admin'));
$this->get(ProjectResource::getUrl('edit', ['record' => $project]))
->assertSuccessful();
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\ApplicationStatus;
use App\Filament\Resources\Applications\ApplicationResource;
use App\Filament\Resources\Calls\CallResource;
use App\Models\Application;
use App\Models\Call;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The inbox is the one resource holding personal data belonging to people
* outside the collective, so who can open it is part of its contract.
*/
class ApplicationsInboxTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function the_inbox_renders(): void
{
$call = Call::factory()->create(['project_id' => null]);
Application::factory()->count(2)->create();
Application::factory()->forCall($call->id)->create();
Application::factory()->request()->create();
Application::factory()->spam()->create();
$this->actingAs($this->userWithRoles('admin'));
$this->get(ApplicationResource::getUrl('index'))->assertSuccessful();
}
#[Test]
public function a_submission_can_be_opened(): void
{
$application = Application::factory()->create();
$this->actingAs($this->userWithRoles('admin'));
$this->get(ApplicationResource::getUrl('view', ['record' => $application]))
->assertSuccessful()
->assertSee($application->name);
}
#[Test]
public function the_replies_to_a_call_show_on_the_call_itself(): void
{
$call = Call::factory()->create(['project_id' => null]);
Application::factory()->forCall($call->id)->create();
$this->actingAs($this->userWithRoles('admin'));
// "Is anybody interested?" is a question you have while looking at the
// opening, not while working through the whole inbox.
$this->get(CallResource::getUrl('edit', ['record' => $call]))
->assertSuccessful();
}
#[Test]
public function an_editor_runs_the_calls_so_an_editor_answers_them(): void
{
$editor = $this->userWithRoles('editor');
$this->assertTrue($editor->can('applications.view_any'));
$this->assertTrue($editor->can('applications.update'));
}
#[Test]
public function a_viewer_never_sees_a_strangers_contact_details(): void
{
$viewer = $this->userWithRoles('viewer');
$this->actingAs($viewer);
// Reading the site's content and reading the people who wrote in are
// deliberately different rights.
$this->assertFalse($viewer->can('applications.view_any'));
$this->assertFalse(ApplicationResource::canViewAny());
$this->get(ApplicationResource::getUrl('index'))->assertForbidden();
}
#[Test]
public function nobody_creates_a_submission_by_hand(): void
{
$admin = $this->userWithRoles('admin');
$this->actingAs($admin);
// They arrive from the public site. A hand-written one would be a
// record of a conversation that never happened.
$this->assertFalse(ApplicationResource::canCreate());
$this->assertFalse($admin->can('create', Application::class));
}
#[Test]
public function answering_records_who_answered(): void
{
$admin = $this->userWithRoles('admin');
$application = Application::factory()->create();
$application->markAs(ApplicationStatus::Contacted, $admin);
$application->refresh();
// An inbox worked by several people otherwise turns into two replies
// or none.
$this->assertSame(ApplicationStatus::Contacted, $application->status);
$this->assertSame($admin->id, $application->handled_by);
$this->assertNotNull($application->handled_at);
}
#[Test]
public function the_badge_counts_the_people_still_waiting(): void
{
Application::factory()->count(2)->create();
Application::factory()->handled()->create();
Application::factory()->spam()->create();
$this->actingAs($this->userWithRoles('admin'));
$this->assertSame('2', ApplicationResource::getNavigationBadge());
}
#[Test]
public function a_submission_survives_the_call_it_answered(): void
{
$call = Call::factory()->create([
'project_id' => null,
'title' => ['es' => 'Backend'],
]);
$application = Application::factory()->forCall($call->id, 'Backend')->create();
$call->delete();
$application->refresh();
// The opening was tidied up; the person who wrote in was not.
$this->assertNull($application->call_id);
$this->assertSame('Backend', $application->subject());
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\PageStatus;
use App\Filament\Resources\Pages\Pages\ListPages;
use App\Filament\Resources\Projects\Pages\ListProjects;
use App\Filament\Resources\Services\Pages\ListServices;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Publishing and requesting review, for a whole selection at once.
*
* The single-row versions of both already existed; what these tests hold
* still is that the bulk versions make the same judgement calls the single
* ones do — who may press the button, and which selected rows are actually
* eligible — rather than a cruder "do it to everything ticked".
*/
class BulkPublishingTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function publishing_a_selection_of_pages_skips_what_is_already_live(): void
{
$draft = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
$alreadyLive = Page::factory()->published()->create();
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(ListPages::class)
->callTableBulkAction('publish', [$draft, $alreadyLive]);
$this->assertSame(PageStatus::Published, $draft->fresh()->status);
$this->assertNotNull($draft->fresh()->published_at);
}
/**
* Publishing is what closes a pending review — the model's own hook does
* that on any `update()`, and the bulk action has to go through it one
* record at a time rather than a single UPDATE query, or this silently
* stops working.
*/
#[Test]
public function bulk_publishing_closes_a_pending_review(): void
{
$page = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
$page->requestReview();
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(ListPages::class)
->callTableBulkAction('publish', [$page]);
$this->assertFalse($page->fresh()->isAwaitingReview());
}
#[Test]
public function somebody_who_cannot_publish_does_not_see_the_bulk_action(): void
{
Page::factory()->create(['status' => PageStatus::Draft]);
$this->actingAs($this->userWithRoles('writer'));
Livewire::test(ListPages::class)
->assertTableBulkActionHidden('publish');
}
#[Test]
public function requesting_review_in_bulk_skips_what_is_already_awaiting_or_published(): void
{
$eligible = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
$alreadyAwaiting = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
$alreadyAwaiting->requestReview();
$alreadyPublished = Page::factory()->published()->create();
$this->actingAs($this->userWithRoles('writer'));
Livewire::test(ListPages::class)
->callTableBulkAction('request_review_bulk', [$eligible, $alreadyAwaiting, $alreadyPublished]);
$this->assertTrue($eligible->fresh()->isAwaitingReview());
// Untouched: neither had anything to gain from being asked again.
$this->assertSame(
$alreadyAwaiting->fresh()->review_requested_at->timestamp,
$alreadyAwaiting->review_requested_at->timestamp,
);
$this->assertFalse($alreadyPublished->fresh()->isAwaitingReview());
}
/**
* The same rule as the single button: somebody who can already publish
* has no use for asking permission first.
*/
#[Test]
public function somebody_who_can_already_publish_does_not_see_the_bulk_request_action(): void
{
Page::factory()->create();
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(ListPages::class)
->assertTableBulkActionHidden('request_review_bulk');
}
#[Test]
public function it_works_for_projects(): void
{
$project = Project::factory()->create(['published_at' => null]);
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(ListProjects::class)
->callTableBulkAction('publish', [$project]);
$this->assertNotNull($project->fresh()->published_at);
}
#[Test]
public function it_works_for_services(): void
{
$service = Service::factory()->create(['is_published' => false, 'published_at' => null]);
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(ListServices::class)
->callTableBulkAction('publish', [$service]);
$service->refresh();
$this->assertTrue($service->is_published);
$this->assertNotNull($service->published_at);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Calls\CallResource;
use App\Filament\Resources\Members\MemberResource;
use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Resources\Repositories\RepositoryResource;
use App\Filament\Resources\Technologies\TechnologyResource;
use App\Models\Call;
use App\Models\Member;
use App\Models\Project;
use App\Models\Repository;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class CollectiveResourcesTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function every_collective_resource_renders(): void
{
Project::factory()->count(2)->create();
Member::factory()->count(2)->create();
Technology::factory()->count(2)->create();
Call::factory()->create(['project_id' => null]);
Repository::factory()->create();
$this->actingAs($this->userWithRoles('admin'));
foreach ([
ProjectResource::class,
MemberResource::class,
TechnologyResource::class,
CallResource::class,
RepositoryResource::class,
] as $resource) {
$this->get($resource::getUrl('index'))
->assertSuccessful();
}
}
#[Test]
public function an_editor_can_manage_content_but_not_sync_repositories(): void
{
$editor = $this->userWithRoles('editor');
$this->actingAs($editor);
$this->assertTrue($editor->can('projects.create'));
$this->assertTrue($editor->can('calls.create'));
$this->assertTrue($editor->can('members.update'));
// Syncing spends a shared rate limit, so it is gated separately from
// ordinary editing.
$this->assertTrue($editor->can('repositories.view_any'));
$this->assertFalse($editor->can('repositories.sync'));
$this->assertFalse($editor->can('repositories.create'));
}
#[Test]
public function a_viewer_cannot_reach_the_collective_editors(): void
{
$viewer = $this->userWithRoles('viewer');
$this->actingAs($viewer);
$this->assertTrue(ProjectResource::canViewAny());
$this->assertFalse(ProjectResource::canCreate());
$this->assertFalse(MemberResource::canCreate());
$this->assertFalse(TechnologyResource::canCreate());
}
#[Test]
public function publishing_a_project_is_a_separate_permission_from_editing_it(): void
{
$editor = $this->userWithRoles('editor');
$editor->roles()->first()->revokePermissionTo('projects.publish');
$editor->forgetCachedPermissions();
$project = Project::factory()->draft()->create();
$this->assertTrue($editor->can('update', $project));
$this->assertFalse($editor->can('publish', $project));
}
#[Test]
public function a_member_may_always_edit_their_own_profile(): void
{
// Someone with no member-editing permission at all.
$user = $this->userWithRoles('viewer');
$own = Member::factory()->create(['user_id' => $user->id]);
$other = Member::factory()->create();
$this->assertTrue($user->can('update', $own));
$this->assertFalse($user->can('update', $other));
}
#[Test]
public function a_technology_still_in_use_cannot_be_deleted(): void
{
$admin = $this->userWithRoles('admin');
$used = Technology::factory()->create();
$unused = Technology::factory()->create();
Project::factory()->create()->technologies()->attach($used);
// Deleting it would silently strip the tag across the whole site.
$this->assertFalse($admin->can('delete', $used));
$this->assertTrue($admin->can('delete', $unused));
}
#[Test]
public function a_member_is_not_a_panel_user(): void
{
$member = Member::factory()->create();
// The two are deliberately separate: most members never need admin
// access, and revoking admin must not remove someone from the website.
$this->assertNull($member->user_id);
$this->assertDatabaseCount('users', 0);
$this->assertTrue($member->is_public);
}
#[Test]
public function removing_a_panel_user_leaves_the_member_on_the_site(): void
{
$user = User::factory()->create();
$member = Member::factory()->create(['user_id' => $user->id]);
$user->delete();
$member->refresh();
$this->assertTrue($member->exists);
$this->assertNull($member->user_id);
$this->assertTrue($member->is_public);
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Projects\Pages\EditProject;
use App\Filament\Resources\SettingsResource\Pages\EditSettings;
use App\Models\Project;
use App\Models\Setting;
use App\Support\Presence;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Two people, one record, one edit screen each.
*
* Nothing in Filament's own EditRecord page notices this happening, and the
* consequence of not noticing is the worst kind: whoever saves second
* silently erases whoever saved first, with no error and no trace beyond a
* revision that looks like an ordinary edit.
*
* Two things are tested here because they answer two different questions. The
* warning on open answers "should I even start" — advisory, shown once, easy
* to ignore. The refusal on save answers "did this just happen anyway" —
* exact, unavoidable, and the one that actually prevents the data loss. The
* second must hold even when the first was never seen.
*/
class ConcurrentEditingTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function a_save_is_refused_when_the_record_changed_underneath_it(): void
{
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
$this->actingAs($this->userWithRoles('admin'));
$editor = Livewire::test(EditProject::class, ['record' => $project->getKey()]);
// Somebody else — a second tab, a teammate, does not matter which —
// saves a change after this screen was opened. The truncated-second
// precision of `updated_at` means a real difference needs distance in
// time, not just a second call; a day is more than enough and mirrors
// the wall-clock gap an actual second editor would produce.
$this->travel(1)->day();
$project->fresh()->update(['title' => ['es' => 'Cambiado por otra persona']]);
$editor
->fillForm(['title' => ['es' => 'Lo que yo escribía']])
->call('save')
->assertNotified(__('panel.concurrency.title'));
// Not "no form errors" — the save must not have reached the database
// at all, and the other person's write must be exactly what survives.
$this->assertSame('Cambiado por otra persona', $project->fresh()->getTranslation('title', 'es', false));
}
#[Test]
public function the_conflict_message_names_who_changed_it_when_the_history_knows(): void
{
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
$other = $this->userWithRoles('admin', ['name' => 'Ada Lovelace']);
$this->actingAs($this->userWithRoles('admin'));
$editor = Livewire::test(EditProject::class, ['record' => $project->getKey()]);
$this->travel(1)->day();
$this->actingAs($other);
$project->fresh()->update(['title' => ['es' => 'Cambiado']]);
$editor
->fillForm(['title' => ['es' => 'Mi versión']])
->call('save')
->assertNotified(__('panel.concurrency.title'));
}
#[Test]
public function saving_without_interference_works_exactly_as_before(): void
{
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(EditProject::class, ['record' => $project->getKey()])
->fillForm(['title' => ['es' => 'Actualizado']])
->call('save')
->assertHasNoFormErrors();
$this->assertSame('Actualizado', $project->fresh()->getTranslation('title', 'es', false));
}
/**
* Saving twice from the same open screen — write, then write again — is
* not a conflict with yourself. Each save has to become the new baseline,
* or nobody could ever save more than once without reloading.
*/
#[Test]
public function a_second_save_from_the_same_screen_is_not_a_conflict_with_itself(): void
{
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
$this->actingAs($this->userWithRoles('admin'));
$editor = Livewire::test(EditProject::class, ['record' => $project->getKey()])
->fillForm(['title' => ['es' => 'Primero']])
->call('save')
->assertHasNoFormErrors();
$this->travel(1)->day();
$editor
->fillForm(['title' => ['es' => 'Segundo']])
->call('save')
->assertHasNoFormErrors()
->assertNotNotified(__('panel.concurrency.title'));
$this->assertSame('Segundo', $project->fresh()->getTranslation('title', 'es', false));
}
/**
* The guard is generic, not written against any one resource. Settings is
* the one model with a string primary key rather than an
* auto-incrementing id, which is exactly the kind of assumption a naive
* `WHERE id = ?` would get wrong.
*/
#[Test]
public function it_also_guards_a_model_with_a_string_primary_key(): void
{
Setting::set('site.maintenance', ['enabled' => false]);
$this->actingAs($this->userWithRoles('super_admin'));
$editor = Livewire::test(EditSettings::class, ['record' => 'site.maintenance']);
$this->travel(1)->day();
Setting::set('site.maintenance', ['enabled' => true]);
$editor
->fillForm(['value' => json_encode(['enabled' => false, 'changed_by_me' => true])])
->call('save')
->assertNotified(__('panel.concurrency.title'));
$this->assertSame(['enabled' => true], Setting::get('site.maintenance'));
}
#[Test]
public function opening_a_record_someone_else_has_open_warns_the_second_person(): void
{
$project = Project::factory()->create();
$first = $this->userWithRoles('admin', ['name' => 'Ada Lovelace']);
$this->actingAs($first);
Livewire::test(EditProject::class, ['record' => $project->getKey()]);
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(EditProject::class, ['record' => $project->getKey()])
->assertNotified(__('panel.presence.title'));
}
/**
* The first person to open a record has no reason to expect company, and
* nothing this screen could tell them would help — so they are the one
* case that stays silent.
*/
#[Test]
public function the_first_person_to_open_a_record_is_not_warned(): void
{
$project = Project::factory()->create();
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(EditProject::class, ['record' => $project->getKey()])
->assertNotNotified(__('panel.presence.title'));
}
#[Test]
public function presence_fades_after_the_window_passes(): void
{
$project = Project::factory()->create();
$first = $this->userWithRoles('admin');
$this->actingAs($first);
Livewire::test(EditProject::class, ['record' => $project->getKey()]);
// Long past the window: a tab left open and forgotten must stop
// haunting the record, not just for the display but for anyone else
// who opens it afterwards.
$this->travel(20)->minutes();
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(EditProject::class, ['record' => $project->getKey()])
->assertNotNotified(__('panel.presence.title'));
}
#[Test]
public function presence_never_counts_the_viewer_themselves(): void
{
$project = Project::factory()->create();
$user = $this->userWithRoles('admin');
$this->assertTrue(Presence::touch($project, $user)->isEmpty());
// Opening it again a moment later is still just one person here.
$this->assertTrue(Presence::touch($project, $user)->isEmpty());
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Members\MemberResource;
use App\Filament\Resources\Pages\PageResource;
use App\Filament\Resources\Projects\ProjectResource;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The editing forms themselves, which the resource listing tests never reach.
*/
class ContentFormsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
$this->actingAs($this->userWithRoles('admin'));
}
#[Test]
public function every_content_form_renders_empty(): void
{
foreach ([PageResource::class, ProjectResource::class, MemberResource::class] as $resource) {
$this->get($resource::getUrl('create'))->assertSuccessful();
}
}
#[Test]
public function every_content_form_renders_with_an_image_and_its_description(): void
{
$page = Page::factory()->create([
'featured_image' => 'pages/hero.jpg',
'featured_image_alt' => ['es' => 'Una captura'],
]);
$project = Project::factory()->create([
'cover_image' => 'projects/cover.jpg',
'cover_image_alt' => ['es' => 'La portada'],
]);
$member = Member::factory()->create(['avatar' => 'members/ada.jpg']);
// The alt-text tabs only appear once there is an image to describe,
// so a form carrying one exercises a path the empty one does not.
$this->get(PageResource::getUrl('edit', ['record' => $page]))->assertSuccessful();
$this->get(ProjectResource::getUrl('edit', ['record' => $project]))->assertSuccessful();
$this->get(MemberResource::getUrl('edit', ['record' => $member]))->assertSuccessful();
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The link to the API documentation in the panel's sidebar.
*
* It points outside the panel, which is the interesting part: the page opens
* in a new tab so that reaching for the reference does not throw away whatever
* was half-written in the panel.
*/
class DocsNavigationTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function the_sidebar_links_to_the_documentation(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$html = (string) $this->get('/admin')->assertSuccessful()->getContent();
$this->assertStringContainsString(route('docs.api'), $html);
$this->assertMatchesRegularExpression(
'/href="[^"]*docs\/api"[^>]*target="_blank"/',
$html,
'The documentation should open in a new tab.',
);
}
#[Test]
public function anybody_who_can_reach_the_panel_can_reach_the_docs(): void
{
// Reading the API reference is not a privilege: the page itself is
// public, so hiding the link would only make it harder to find.
$this->actingAs($this->userWithRoles('viewer'));
$this->get('/admin')
->assertSuccessful()
->assertSee(route('docs.api'), escape: false);
}
/**
* The panel is configured once per process, so a label resolved there
* would freeze in whatever language happened to be active at boot. This is
* the test that catches that, and it has to run in its own request to
* mean anything.
*/
#[Test]
public function the_label_follows_the_language_being_browsed(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$this->get('/admin?lang=en')
->assertSuccessful()
->assertSee(__('docs.title', [], 'en'));
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Pages\PageResource;
use App\Filament\Resources\Pages\Pages\CreatePage;
use App\Filament\Resources\Pages\Pages\EditPage;
use App\Filament\Resources\Projects\Pages\CreateProject;
use App\Filament\Resources\Services\Pages\CreateService;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Support\DuplicateTitles;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* A nudge, not a rule.
*
* The slug already has a hard uniqueness constraint — two records cannot
* answer at the same address. What this catches is the case that constraint
* cannot: two records with different addresses and almost the same title,
* which is what actually happens when nobody remembers something already
* exists. It warns; it has never once refused to save.
*/
class DuplicateTitlesTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->actingAs($this->userWithRoles('admin'));
}
#[Test]
public function a_near_identical_title_is_found(): void
{
Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
$matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios v2', 'es', null);
$this->assertCount(1, $matches);
$this->assertSame('Gestor de socios', $matches->first()->getTranslation('title', 'es', false));
}
#[Test]
public function an_unrelated_title_is_not_a_match(): void
{
Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
$matches = DuplicateTitles::similarTo(Page::class, 'Convocatoria de primavera', 'es', null);
$this->assertTrue($matches->isEmpty());
}
/**
* The record being edited must never flag itself: typing in an existing
* page's own title field would otherwise "find" a perfect match on every
* keystroke.
*/
#[Test]
public function a_record_is_never_a_duplicate_of_itself(): void
{
$page = Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
$matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios', 'es', $page->id);
$this->assertTrue($matches->isEmpty());
}
#[Test]
public function an_empty_title_matches_nothing(): void
{
Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
$this->assertTrue(DuplicateTitles::similarTo(Page::class, '', 'es', null)->isEmpty());
$this->assertTrue(DuplicateTitles::similarTo(Page::class, ' ', 'es', null)->isEmpty());
}
#[Test]
public function only_the_closest_three_are_returned(): void
{
foreach (range(1, 5) as $n) {
Page::factory()->create(['title' => ['es' => "Gestor de socios {$n}"]]);
}
$matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios', 'es', null);
$this->assertCount(3, $matches);
}
#[Test]
public function typing_a_near_duplicate_title_warns_while_creating(): void
{
Page::factory()->create(['title' => ['es' => 'Quiénes somos']]);
Livewire::test(CreatePage::class)
->fillForm(['title' => ['es' => 'Quienes somos']])
->assertNotified(__('panel.duplicates.title'));
}
/**
* Editing an existing page must not warn about itself the moment the
* form is touched.
*/
#[Test]
public function editing_a_page_without_changing_its_title_does_not_warn(): void
{
$page = Page::factory()->create(['title' => ['es' => 'Quiénes somos']]);
Livewire::test(EditPage::class, ['record' => $page->getKey()])
->fillForm(['excerpt' => ['es' => 'Un resumen nuevo']])
->assertNotNotified(__('panel.duplicates.title'));
}
#[Test]
public function the_warning_links_to_the_existing_record(): void
{
$existing = Page::factory()->create(['title' => ['es' => 'Quiénes somos']]);
$component = Livewire::test(CreatePage::class)
->fillForm(['title' => ['es' => 'Quienes somos']]);
// Read ahead of `assertNotified()`, which pulls this same key from
// the session and would leave nothing behind for a second read.
$notified = collect(session('filament.claimed_notifications'))->first();
$component->assertNotified(__('panel.duplicates.title'));
$this->assertSame(
PageResource::getUrl('edit', ['record' => $existing]),
$notified['actions'][0]['url'] ?? null,
);
}
#[Test]
public function it_works_for_projects(): void
{
Project::factory()->create(['title' => ['es' => 'Web del colectivo']]);
Livewire::test(CreateProject::class)
->fillForm(['title' => ['es' => 'Web del colectivo v2']])
->assertNotified(__('panel.duplicates.title'));
}
#[Test]
public function it_works_for_services(): void
{
Service::factory()->create(['title' => ['es' => 'Auditoría técnica']]);
Livewire::test(CreateService::class)
->fillForm(['title' => ['es' => 'Auditoria tecnica']])
->assertNotified(__('panel.duplicates.title'));
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The one banner that has to be right in the one direction that matters.
*
* Nobody edits a local clone carefully because they mistake it for
* production — the expensive mistake runs the other way. So the only
* environment that stays silent is `local`; every other one says so, and
* production says so the loudest.
*/
class EnvironmentBannerTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->actingAs($this->userWithRoles('admin'));
}
protected function setEnvironment(string $environment): void
{
$this->app['env'] = $environment;
}
#[Test]
public function nothing_is_shown_in_a_local_install(): void
{
$this->setEnvironment('local');
$this->get('/admin')
->assertOk()
->assertDontSee(__('panel.environment.production'))
->assertDontSeeText('bg-danger-600', escape: false);
}
#[Test]
public function production_says_so_in_the_loudest_colour(): void
{
$this->setEnvironment('production');
$this->get('/admin')
->assertOk()
->assertSee(__('panel.environment.production'));
}
#[Test]
public function anything_else_names_itself(): void
{
$this->setEnvironment('staging');
$this->get('/admin')
->assertOk()
->assertSee(__('panel.environment.other', ['name' => 'staging']));
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use Database\Seeders\RolePermissionSeeder;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
use Throwable;
/**
* Every screen in the panel opens.
*
* This exists because of how the settings page broke: nothing referenced a
* method that had moved in Filament, nothing failed to compile, and the suite
* stayed green — the page simply threw the first time a human opened it,
* months after it was written. A screen nobody has visited is a screen nobody
* has tested, and there is no cheaper way to visit all of them than this.
*/
class EveryPanelPageRendersTest extends TestCase
{
use FakesPireon, RefreshDatabase;
#[Test]
public function every_resource_listing_opens(): void
{
$this->seed(RolePermissionSeeder::class);
$this->actingAs($this->userWithRoles('super_admin'));
$failures = [];
foreach (Filament::getPanel('admin')->getResources() as $resource) {
/** @var class-string<resource> $resource */
if (! array_key_exists('index', $resource::getPages())) {
continue;
}
$failures = [...$failures, ...$this->visit($resource::getUrl('index'), $resource)];
}
$this->assertSame([], $failures, implode("\n", $failures));
}
#[Test]
public function every_custom_page_opens(): void
{
$this->seed(RolePermissionSeeder::class);
$this->actingAs($this->userWithRoles('super_admin'));
$failures = [];
foreach (Filament::getPanel('admin')->getPages() as $page) {
$failures = [...$failures, ...$this->visit($page::getUrl(), $page)];
}
$this->assertSame([], $failures, implode("\n", $failures));
}
/**
* @return array<int, string>
*/
protected function visit(string $url, string $subject): array
{
try {
$status = $this->withoutExceptionHandling()->get($url)->status();
} catch (Throwable $e) {
return ["{$subject} — {$url}\n ".$e::class.': '.$e->getMessage()];
}
return $status === 200 ? [] : ["{$subject} — {$url} answered {$status}"];
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Media\MediaResource;
use App\Filament\Resources\Technologies\TechnologyResource;
use App\Models\Media;
use App\Models\Project;
use App\Models\Technology;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The catalogue of everything uploaded, and logos on technologies.
*/
class MediaLibraryTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
Storage::fake(config('media.disk'));
}
#[Test]
public function the_library_lists_what_has_been_uploaded(): void
{
Project::factory()->create(['cover_image' => 'projects/una.jpg']);
$this->actingAs($this->userWithRoles('admin'));
$this->get(MediaResource::getUrl('index'))
->assertSuccessful()
->assertSee('projects/una.jpg');
}
#[Test]
public function nobody_uploads_from_the_library(): void
{
$this->actingAs($this->userWithRoles('admin'));
// Files arrive by being uploaded on a record, not from here.
$this->assertFalse(MediaResource::canCreate());
}
#[Test]
public function a_file_something_points_at_cannot_be_deleted(): void
{
Project::factory()->create(['cover_image' => 'projects/en-uso.jpg']);
$media = Media::query()->where('path', 'projects/en-uso.jpg')->sole();
$admin = $this->userWithRoles('admin');
// Deleting it would put a broken image on the public site, which is
// exactly what the health check exists to complain about.
$this->assertTrue($media->isReferenced());
$this->assertFalse($admin->can('delete', $media));
}
#[Test]
public function a_file_nothing_points_at_can(): void
{
$media = Media::factory()->create(['path' => 'projects/huerfana.jpg']);
$this->assertFalse($media->isReferenced());
$this->assertTrue($this->userWithRoles('admin')->can('delete', $media));
}
#[Test]
public function the_weight_counts_the_derived_versions_too(): void
{
$media = Media::factory()->create([
'size' => 1000,
'variants' => [
['format' => 'webp', 'width' => 400, 'path' => 'a-400.webp', 'size' => 300],
['format' => 'webp', 'width' => 800, 'path' => 'a-800.webp', 'size' => 700],
],
]);
// The number that explains where the disk went.
$this->assertSame(2000, $media->totalSize());
}
#[Test]
public function a_viewer_may_look_but_not_tidy(): void
{
$media = Media::factory()->create();
$viewer = $this->userWithRoles('viewer');
$this->assertTrue($viewer->can('media.view_any'));
$this->assertFalse($viewer->can('delete', $media));
}
#[Test]
public function a_technology_can_carry_a_logo(): void
{
$technology = Technology::factory()->create([
'name' => 'Laravel',
'logo' => 'technologies/laravel.svg',
]);
// Registered like any other upload: same media row, same processing.
$this->assertSame(1, Media::query()->where('path', 'technologies/laravel.svg')->count());
$this->assertSame('technologies/laravel.svg', $technology->logo);
}
#[Test]
public function the_logo_reaches_the_frontend_with_the_name_as_its_alt(): void
{
Technology::factory()->create([
'name' => 'Rust',
'slug' => 'rust',
'radar_ring' => 'adopt',
'logo' => 'technologies/rust.svg',
]);
$data = $this->withHeaders(['Accept-Language' => 'es'])
->getJson('/api/public/v1/radar')
->assertOk()
->json('data.0');
// A logo's description is the name of the thing, so there is nothing
// to type and nothing to get wrong.
$this->assertSame('Rust', $data['logo']['alt']);
$this->assertStringContainsString('technologies/rust.svg', $data['logo']['url']);
}
#[Test]
public function the_technology_editor_still_opens(): void
{
$technology = Technology::factory()->create(['logo' => 'technologies/x.svg']);
$this->actingAs($this->userWithRoles('admin'));
$this->get(TechnologyResource::getUrl('edit', ['record' => $technology]))->assertSuccessful();
}
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\MissingUrlStatus;
use App\Filament\Resources\MissingUrls\MissingUrlResource;
use App\Filament\Resources\MissingUrls\Pages\ListMissingUrls;
use App\Models\MissingUrl;
use App\Models\Redirect;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* What the panel does with a reported 404.
*
* The screen is a to-do list with one real action on it, so what is worth
* testing is that the action closes the loop — the redirect is written, and
* the entry stops asking to be dealt with — and that seeing the list is gated
* by the same permission as fixing what is on it.
*/
class MissingUrlsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function the_action_writes_the_redirect_and_closes_the_report(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$missing = MissingUrl::factory()->create([
'path' => '/proyectos/nombre-viejo',
'locale' => 'es',
]);
Livewire::test(ListMissingUrls::class)
->callTableAction('redirect', $missing, data: [
'to' => '/proyectos/nombre-nuevo',
'status' => 301,
'locale' => 'es',
])
->assertHasNoTableActionErrors();
$this->assertDatabaseHas('redirects', [
'from' => '/proyectos/nombre-viejo',
'to' => '/proyectos/nombre-nuevo',
'status' => 301,
'locale' => 'es',
]);
$this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status);
}
/**
* The destination goes through the same normalisation as a hand-written
* rule, so an address typed with a trailing slash does not become a
* redirect that never matches.
*/
#[Test]
public function the_destination_is_normalised(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$missing = MissingUrl::factory()->create(['path' => '/algo', 'locale' => 'es']);
Livewire::test(ListMissingUrls::class)
->callTableAction('redirect', $missing, data: [
'to' => '/servicios/algo/',
'status' => 301,
'locale' => 'es',
]);
$this->assertDatabaseHas('redirects', ['to' => '/servicios/algo']);
}
#[Test]
public function ignoring_hides_an_entry_without_stopping_the_count(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$missing = MissingUrl::factory()->create([
'path' => '/ruido',
'locale' => 'es',
'hits' => 1,
]);
Livewire::test(ListMissingUrls::class)
->callTableAction('ignore', $missing);
$this->assertSame(MissingUrlStatus::Ignored, $missing->refresh()->status);
// Still counted: ignoring is a decision about the list, not about
// whether the address is being asked for.
MissingUrl::report('/ruido', 'es');
$missing->refresh();
$this->assertSame(2, (int) $missing->hits);
// And it stays ignored. These addresses are requested constantly — if
// the next request revived them, the button would never work twice.
$this->assertSame(MissingUrlStatus::Ignored, $missing->status);
}
/**
* Gated by the redirect permissions rather than a set of its own, so a
* role that cannot do anything about a broken link does not see the list
* of broken links either.
*/
#[Test]
public function a_viewer_cannot_open_the_list(): void
{
$this->actingAs($this->userWithRoles('viewer'));
$this->get(MissingUrlResource::getUrl('index'))->assertForbidden();
}
#[Test]
public function an_editor_can_open_it(): void
{
$this->actingAs($this->userWithRoles('editor'));
$this->get(MissingUrlResource::getUrl('index'))->assertSuccessful();
}
#[Test]
public function nobody_creates_one_by_hand(): void
{
$this->assertFalse(MissingUrlResource::canCreate());
}
#[Test]
public function the_health_check_counts_what_is_still_broken(): void
{
MissingUrl::factory()->create(['path' => '/roto', 'locale' => 'es', 'hits' => 12]);
// A single request is as likely to be a scanner as a person.
MissingUrl::factory()->create(['path' => '/una-vez', 'locale' => 'es', 'hits' => 1]);
// Already dealt with.
MissingUrl::factory()->resolved()->create(['path' => '/arreglado', 'locale' => 'es', 'hits' => 30]);
MissingUrl::factory()->ignored()->create(['path' => '/ruido', 'locale' => 'es', 'hits' => 30]);
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls');
$this->assertInstanceOf(Finding::class, $finding);
$this->assertSame(1, $finding->count);
$this->assertSame(Severity::Warning, $finding->severity);
}
/**
* An address our own pages link to is not somebody else's stale bookmark:
* it is a fault in what we publish, and it will keep producing 404s.
*/
#[Test]
public function a_dead_link_on_our_own_site_is_an_error_not_a_warning(): void
{
MissingUrl::factory()->internal()->create([
'path' => '/roto',
'locale' => 'es',
'hits' => 4,
]);
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls');
$this->assertSame(Severity::Error, $finding->severity);
}
#[Test]
public function pruning_forgets_one_off_scans_and_keeps_the_rest(): void
{
MissingUrl::factory()->create([
'path' => '/una-vez', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDays(60),
]);
MissingUrl::factory()->create([
'path' => '/repetida', 'locale' => 'es',
'hits' => 9, 'last_seen_at' => now()->subDays(60),
]);
MissingUrl::factory()->create([
'path' => '/reciente', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDay(),
]);
// A decision already made must not come back next week as though it
// had never been made.
MissingUrl::factory()->ignored()->create([
'path' => '/ignorada', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDays(60),
]);
$this->artisan('content:prune')->assertExitCode(0);
$this->assertDatabaseMissing('missing_urls', ['path' => '/una-vez']);
$this->assertDatabaseHas('missing_urls', ['path' => '/repetida']);
$this->assertDatabaseHas('missing_urls', ['path' => '/reciente']);
$this->assertDatabaseHas('missing_urls', ['path' => '/ignorada']);
}
#[Test]
public function a_dry_run_deletes_nothing(): void
{
MissingUrl::factory()->create([
'path' => '/una-vez', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDays(60),
]);
$this->artisan('content:prune --dry-run')->assertExitCode(0);
$this->assertDatabaseHas('missing_urls', ['path' => '/una-vez']);
}
/**
* A rule typed straight into the redirects screen closes the report too:
* the two lists must never disagree about whether an address is answered.
*/
#[Test]
public function a_hand_written_redirect_also_closes_the_report(): void
{
$missing = MissingUrl::factory()->create(['path' => '/a-mano', 'locale' => 'es']);
Redirect::create([
'from' => '/a-mano',
'to' => '/destino',
'status' => 301,
'locale' => 'es',
'is_enabled' => true,
]);
$this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Categories\CategoryResource;
use App\Filament\Resources\Pages\PageResource;
use App\Filament\Resources\Roles\RoleResource;
use App\Filament\Resources\Users\UserResource;
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 PanelAccessTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function a_guest_is_sent_to_the_login_page(): void
{
$this->get('/admin')->assertRedirect();
$this->get('/admin/login')->assertOk()->assertSee(__('pireon.sign_in'));
}
#[Test]
public function the_login_page_asks_for_credentials_and_nothing_else(): void
{
$response = $this->get('/admin/login')->assertOk();
// The identity server is meant to be invisible: whoever signs in sees
// a form, not a hand-off to somewhere else.
$response->assertSee('data.email', escape: false);
$response->assertSee('data.password', escape: false);
$response->assertDontSee(route('auth.redirect'), escape: false);
$response->assertDontSee((string) config('pireon.server'), escape: false);
}
#[Test]
public function a_user_without_the_panel_access_permission_is_refused(): void
{
// A real identity, but no local role has been granted yet.
$user = $this->userWithRoles([]);
$this->actingAs($user)->get('/admin')->assertForbidden();
}
#[Test]
public function a_viewer_can_open_the_panel(): void
{
$user = $this->userWithRoles('viewer');
$this->actingAs($user)->get('/admin')->assertSuccessful();
}
#[Test]
public function a_deactivated_user_cannot_open_the_panel(): void
{
$user = $this->userWithRoles('viewer', ['is_active' => false]);
$this->actingAs($user)->get('/admin')->assertForbidden();
}
#[Test]
public function resource_visibility_follows_local_permissions(): void
{
$viewer = $this->userWithRoles('viewer');
$admin = $this->userWithRoles('admin');
$this->actingAs($viewer);
$this->assertTrue(PageResource::canViewAny());
$this->assertTrue(CategoryResource::canViewAny());
$this->assertFalse(UserResource::canViewAny());
$this->assertFalse(RoleResource::canViewAny());
$this->actingAs($admin);
$this->assertTrue(UserResource::canViewAny());
$this->assertTrue(RoleResource::canViewAny());
}
#[Test]
public function resource_index_pages_render(): void
{
Page::factory()->count(3)->create();
Category::factory()->count(2)->create();
$admin = $this->userWithRoles('admin');
$this->actingAs($admin);
$this->get(PageResource::getUrl('index'))->assertSuccessful();
$this->get(CategoryResource::getUrl('index'))->assertSuccessful();
$this->get(UserResource::getUrl('index'))->assertSuccessful();
$this->get(RoleResource::getUrl('index'))->assertSuccessful();
}
#[Test]
public function the_panel_renders_in_the_users_language(): void
{
$user = $this->userWithRoles('viewer', ['locale' => 'ca']);
$this->actingAs($user)
->get(PageResource::getUrl('index'))
->assertSuccessful()
->assertSee('Pàgines');
}
#[Test]
public function the_language_switcher_persists_the_choice(): void
{
$user = $this->userWithRoles('viewer', ['locale' => 'es']);
$this->actingAs($user)
->from(PageResource::getUrl('index'))
->get(route('locale.switch', 'en'))
->assertRedirect();
$this->assertSame('en', $user->fresh()->locale);
$this->assertSame('en', session(config('locales.session_key')));
}
#[Test]
public function an_unsupported_locale_is_rejected(): void
{
$user = $this->userWithRoles('viewer');
$this->actingAs($user)->get(route('locale.switch', 'de'))->assertNotFound();
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Pages\Trash;
use App\Models\Application;
use App\Models\Page;
use App\Models\Project;
use App\Models\User;
use App\Support\PreviewLink;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The everyday tools: the bin and the shareable preview link.
*/
class PanelToolsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
}
#[Test]
public function the_bin_gathers_everything_deleted(): void
{
Page::factory()->create(['title' => ['es' => 'Borrada']])->delete();
Project::factory()->create(['title' => ['es' => 'Borrado']])->delete();
$this->actingAs($this->userWithRoles('admin'));
// Recovering something used to mean remembering which resource it had
// been in and then turning on a filter.
$this->get(Trash::getUrl())
->assertSuccessful()
->assertSee('Borrada')
->assertSee('Borrado');
}
#[Test]
public function something_can_be_put_back_from_the_bin(): void
{
$page = Page::factory()->create();
$page->delete();
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(Trash::class)
->call('restoreRecord', Page::class, $page->id);
$this->assertNull($page->fresh()->deleted_at);
}
#[Test]
public function the_bin_refuses_a_model_it_does_not_list(): void
{
$this->actingAs($this->userWithRoles('admin'));
// An id and a class name arriving from the browser must not be able to
// reach anything the page did not declare.
Livewire::test(Trash::class)
->call('restoreRecord', User::class, 1)
->assertOk();
$this->assertTrue(true);
}
#[Test]
public function a_viewer_sees_only_what_they_may_see(): void
{
Application::factory()->create()->delete();
Page::factory()->create(['title' => ['es' => 'Una página']])->delete();
$this->actingAs($this->userWithRoles('viewer'));
// A viewer has no business reading a stranger's message, in the bin or
// anywhere else.
$this->get(Trash::getUrl())
->assertSuccessful()
->assertSee('Una página')
->assertDontSee(__('panel.applications.plural'));
}
#[Test]
public function a_preview_link_points_at_the_record_it_was_made_for(): void
{
$project = Project::factory()->draft()->create([
'title' => ['es' => 'Borrador'],
'slug' => ['es' => 'borrador'],
]);
$url = PreviewLink::for($project, 'project', 'es');
$this->assertStringContainsString('/proyectos/borrador', $url);
$this->assertStringContainsString('preview=', $url);
$token = urldecode((string) parse_url($url, PHP_URL_QUERY));
$token = str_replace('preview=', '', $token);
$payload = PreviewLink::open($token);
$this->assertSame('project', $payload['type']);
$this->assertSame($project->id, $payload['id']);
$this->assertSame('es', $payload['locale']);
}
#[Test]
public function a_preview_link_opens_a_draft_on_the_api(): void
{
$project = Project::factory()->draft()->create([
'title' => ['es' => 'Borrador'],
'slug' => ['es' => 'borrador'],
]);
$url = PreviewLink::for($project, 'project', 'es');
$token = str_replace('preview=', '', urldecode((string) parse_url($url, PHP_URL_QUERY)));
$this->withHeaders(['Accept-Language' => 'es'])
->getJson('/api/public/v1/projects/borrador')
->assertNotFound();
$this->withHeaders(['Accept-Language' => 'es'])
->getJson('/api/public/v1/projects/borrador?preview='.urlencode($token))
->assertOk();
}
#[Test]
public function a_forged_or_expired_link_opens_nothing(): void
{
$this->assertNull(PreviewLink::open('no-es-un-token'));
$project = Project::factory()->draft()->create([
'title' => ['es' => 'Borrador'], 'slug' => ['es' => 'borrador'],
]);
$url = PreviewLink::for($project, 'project', 'es');
$token = str_replace('preview=', '', urldecode((string) parse_url($url, PHP_URL_QUERY)));
$this->travel(PreviewLink::HOURS + 1)->hours();
// Short-lived on purpose: one forwarded in an e-mail thread stops
// working rather than living for ever.
$this->assertNull(PreviewLink::open($token));
}
#[Test]
public function a_record_with_no_slug_in_a_language_has_no_link_for_it(): void
{
$project = Project::factory()->draft()->create(['slug' => ['es' => 'solo-es']]);
$this->assertNotNull(PreviewLink::for($project, 'project', 'es'));
$this->assertNull(PreviewLink::for($project, 'project', 'ca'));
}
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\PageStatus;
use App\Enums\ReviewEvent;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use App\Notifications\ReviewRequested;
use App\Notifications\ReviewReturned;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Handing work to somebody who is allowed to publish it.
*
* The feature exists because `publish` is a permission of its own: there are
* people who can finish a piece of work and not put it on the site. What these
* tests hold still is the handover — that asking needs no new permission, that
* answering needs the one that made the handover necessary, and that nothing
* can leave a record live and still marked as waiting.
*/
class ReviewsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Notification::fake();
}
/**
* Somebody who can write and not publish — the shipped `writer` role,
* which is the whole reason this feature has anywhere to happen.
*/
protected function writer(): User
{
return $this->userWithRoles('writer');
}
protected function draft(?User $author = null): Page
{
return Page::factory()->create([
'status' => PageStatus::Draft,
'published_at' => null,
'author_id' => $author?->id,
]);
}
#[Test]
public function asking_for_a_review_records_who_asked_and_what_they_said(): void
{
$editor = $this->userWithRoles('editor');
$page = $this->draft($editor);
$page->requestReview(null, 'Falta la foto de portada', $editor);
$page->refresh();
$this->assertTrue($page->isAwaitingReview());
$this->assertSame($editor->id, $page->review_requested_by);
$comment = $page->reviewComments()->first();
$this->assertSame(ReviewEvent::Requested, $comment->event);
$this->assertSame('Falta la foto de portada', $comment->body);
$this->assertSame($editor->name, $comment->author());
}
/**
* The status enum already had a `Review` value, shown in the table and
* offered as a filter. It has to keep telling the truth.
*/
#[Test]
public function a_page_in_review_says_so_in_its_status(): void
{
$page = $this->draft();
$page->requestReview();
$this->assertSame(PageStatus::Review, $page->refresh()->status);
}
/**
* Nothing about asking is a new permission: being allowed to edit
* something is already the right to say you have finished with it.
*/
#[Test]
public function somebody_who_cannot_publish_can_still_ask(): void
{
$writer = $this->writer();
$this->assertTrue($writer->cannot('pages.publish'));
$this->assertTrue($writer->can('pages.update'));
$page = $this->draft($writer);
$page->requestReview(null, null, $writer);
$this->assertTrue($page->refresh()->isAwaitingReview());
}
/**
* The distinction the catalogue always made and no role exercised: a
* separate `publish` permission is only meaningful if somebody holds
* `update` without it.
*/
#[Test]
public function the_shipped_writer_role_is_the_one_this_exists_for(): void
{
$writer = $this->writer();
$this->assertTrue($writer->can('pages.update'));
$this->assertTrue($writer->can('projects.update'));
$this->assertTrue($writer->can('services.update'));
$this->assertTrue($writer->cannot('pages.publish'));
$this->assertTrue($writer->cannot('projects.publish'));
$this->assertTrue($writer->cannot('services.publish'));
// Same reason the viewer does not have it: writing for the site and
// reading strangers' contact details are different jobs.
$this->assertTrue($writer->cannot('applications.view_any'));
}
#[Test]
public function everybody_who_could_publish_it_is_told(): void
{
$writer = $this->writer();
$admin = $this->userWithRoles('admin');
$viewer = $this->userWithRoles('viewer');
$page = $this->draft($writer);
$page->requestReview(null, 'Listo', $writer);
Notification::assertSentTo($admin, ReviewRequested::class);
// Being told about work you could never act on is noise.
Notification::assertNotSentTo($viewer, ReviewRequested::class);
Notification::assertNotSentTo($writer, ReviewRequested::class);
}
/**
* Naming somebody addresses the request, and then it is theirs alone —
* otherwise "assigned to Ana" would mean nothing.
*/
#[Test]
public function naming_a_reviewer_tells_only_them(): void
{
$writer = $this->writer();
$ana = $this->userWithRoles('admin');
$other = $this->userWithRoles('admin');
$page = $this->draft($writer);
$page->requestReview($ana, null, $writer);
Notification::assertSentTo($ana, ReviewRequested::class);
Notification::assertNotSentTo($other, ReviewRequested::class);
}
#[Test]
public function sending_it_back_tells_the_person_who_asked(): void
{
$writer = $this->writer();
$admin = $this->userWithRoles('admin');
$page = $this->draft($writer);
$page->requestReview(null, null, $writer);
$page->returnForChanges('Cambia el título, es el de otra página', $admin);
$page->refresh();
$this->assertFalse($page->isAwaitingReview());
$this->assertSame(PageStatus::Draft, $page->status);
Notification::assertSentTo($writer, ReviewReturned::class);
$comment = $page->reviewComments()->first();
$this->assertSame(ReviewEvent::Returned, $comment->event);
$this->assertSame('Cambia el título, es el de otra página', $comment->body);
}
/**
* The loop closes on the state, not on a button, so no route can leave
* something live and still marked as waiting for somebody.
*/
#[Test]
public function publishing_closes_the_review_however_it_was_published(): void
{
$page = $this->draft();
$page->requestReview();
// Straight to the model, as the form and the table action both do.
$page->update(['status' => PageStatus::Published, 'published_at' => now()]);
$page->refresh();
$this->assertFalse($page->isAwaitingReview());
$this->assertNull($page->reviewer_id);
$this->assertSame(
ReviewEvent::Published,
$page->reviewComments()->first()->event,
);
}
/**
* Scheduling is not publishing: a page whose date is next week is not on
* the site yet, so nobody's review is over.
*/
#[Test]
public function a_future_date_does_not_close_the_review(): void
{
$page = $this->draft();
$page->requestReview();
$page->update([
'status' => PageStatus::Published,
'published_at' => now()->addWeek(),
]);
$this->assertTrue($page->refresh()->isAwaitingReview());
}
#[Test]
public function the_review_columns_stay_out_of_the_change_history(): void
{
$page = $this->draft();
$page->requestReview(null, 'Nota');
// The thread already says this in words. Repeating it as a diff would
// bury the edits somebody actually came looking for.
$fields = $page->revisions()->get()->flatMap(fn ($revision) => $revision->fields());
$this->assertNotContains('review_requested_at', $fields);
$this->assertNotContains('reviewer_id', $fields);
}
#[Test]
public function an_unaddressed_review_is_in_everybodys_queue(): void
{
$admin = $this->userWithRoles('admin');
$ana = $this->userWithRoles('admin');
$this->draft()->requestReview();
$this->draft()->requestReview($ana);
// The unaddressed one, plus nothing addressed to somebody else.
$this->assertSame(1, Page::query()->awaitingReviewBy($admin)->count());
// Ana sees hers and the unaddressed one.
$this->assertSame(2, Page::query()->awaitingReviewBy($ana)->count());
}
#[Test]
public function it_works_the_same_for_projects_and_services(): void
{
$project = Project::factory()->create(['published_at' => null]);
$service = Service::factory()->create(['is_published' => false, 'published_at' => null]);
$project->requestReview();
$service->requestReview();
$this->assertTrue($project->refresh()->isAwaitingReview());
$this->assertTrue($service->refresh()->isAwaitingReview());
$project->update(['published_at' => now()]);
$service->update(['is_published' => true, 'published_at' => now()]);
$this->assertFalse($project->refresh()->isAwaitingReview());
$this->assertFalse($service->refresh()->isAwaitingReview());
}
#[Test]
public function a_comment_changes_nothing_but_is_recorded(): void
{
$page = $this->draft();
$page->requestReview();
$page->comment('¿La foto la tienes tú?');
$this->assertTrue($page->refresh()->isAwaitingReview());
$this->assertSame(
ReviewEvent::Commented,
$page->reviewComments()->first()->event,
);
}
#[Test]
public function the_health_check_finds_a_handover_nobody_answered(): void
{
$fresh = $this->draft();
$fresh->requestReview();
$stale = $this->draft();
$stale->requestReview();
$stale->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly();
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'stale-reviews');
$this->assertInstanceOf(Finding::class, $finding);
$this->assertSame(1, $finding->count);
}
#[Test]
public function the_check_is_hidden_from_somebody_who_could_not_act_on_it(): void
{
$page = $this->draft();
$page->requestReview();
$page->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly();
$keys = fn (User $user) => app(HealthReport::class)->runFor($user)->pluck('key');
$this->assertContains('stale-reviews', $keys($this->userWithRoles('admin')));
$this->assertNotContains('stale-reviews', $keys($this->userWithRoles('viewer')));
}
}

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\PageStatus;
use App\Filament\Resources\Pages\PageResource;
use App\Models\Page;
use App\Models\Project;
use App\Models\Revision;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Who changed what.
*
* The gap this fills is publishing above all: `pages.publish` is a separate
* permission precisely because putting something on the public site is a
* bigger act than writing it, and nothing recorded who did it.
*/
class RevisionsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
}
#[Test]
public function creating_something_is_recorded(): void
{
$page = Page::factory()->create();
$this->assertSame('created', $page->revisions()->sole()->event);
}
#[Test]
public function it_records_who_made_the_change_by_name(): void
{
$user = $this->userWithRoles('admin', ['name' => 'María']);
$this->actingAs($user);
$page = Page::factory()->create();
$page->update(['title' => ['es' => 'Nuevo título']]);
$revision = $page->revisions()->where('event', 'updated')->sole();
$this->assertSame($user->id, $revision->user_id);
// The name is kept beside the id because people leave and accounts get
// deleted, and "somebody published this" is worth less than a name.
$this->assertSame('María', $revision->user_name);
$this->assertSame('María', $revision->author());
}
#[Test]
public function a_change_made_by_no_one_says_so(): void
{
$page = Page::factory()->create();
$page->update(['title' => ['es' => 'Cambiado por un comando']]);
// The scheduler and the console have no user, and blaming the last
// person who logged in would be a lie.
$this->assertNull($page->revisions()->first()->user_id);
$this->assertSame(__('panel.revisions.system'), $page->revisions()->first()->author());
}
#[Test]
public function publishing_reads_differently_from_editing(): void
{
$this->actingAs($this->userWithRoles('admin'));
$page = Page::factory()->create([
'status' => PageStatus::Draft,
'published_at' => null,
]);
$page->update(['status' => PageStatus::Published, 'published_at' => now()->subMinute()]);
$this->assertSame('published', $page->revisions()->first()->event);
$page->update(['status' => PageStatus::Draft]);
// A history where publishing looks like any other save hides exactly
// the thing somebody came looking for.
$this->assertSame('unpublished', $page->revisions()->first()->event);
}
#[Test]
public function it_keeps_the_old_value_beside_the_new_one(): void
{
$page = Page::factory()->create(['title' => ['es' => 'Antes']]);
$page->update(['title' => ['es' => 'Después']]);
$revision = $page->revisions()->where('event', 'updated')->sole();
$diff = $revision->diff('title');
$this->assertSame(['es' => 'Antes'], $diff['old']);
$this->assertSame(['es' => 'Después'], $diff['new']);
}
#[Test]
public function it_records_only_what_changed(): void
{
$page = Page::factory()->create(['title' => ['es' => 'Uno'], 'is_featured' => false]);
$page->update(['is_featured' => true]);
$revision = $page->revisions()->where('event', 'updated')->sole();
// Storing whole records would make the history larger than the content
// it describes.
$this->assertSame(['is_featured'], $revision->fields());
}
#[Test]
public function saving_without_changing_anything_records_nothing(): void
{
$page = Page::factory()->create();
$before = $page->revisions()->count();
$page->update(['title' => $page->getTranslations('title')]);
$this->assertSame($before, $page->revisions()->count());
}
#[Test]
public function a_previous_state_can_be_put_back(): void
{
$page = Page::factory()->create(['title' => ['es' => 'Original']]);
$page->update(['title' => ['es' => 'Equivocado']]);
$revision = $page->revisions()->where('event', 'updated')->sole();
$page->forceFill($revision->previousValues())->save();
$this->assertSame('Original', $page->fresh()->getTranslation('title', 'es'));
// Restoring is itself a change, so the way back is never lost.
$this->assertSame('updated', $page->fresh()->revisions()->first()->event);
}
#[Test]
public function deleting_and_erasing_are_told_apart(): void
{
$page = Page::factory()->create();
$page->delete();
$this->assertSame('deleted', $page->revisions()->first()->event);
$page->restore();
$this->assertSame('restored', $page->revisions()->first()->event);
$page->forceDelete();
// The record is gone; its history says what happened to it.
$this->assertSame('erased', Revision::query()
->where('revisionable_type', $page->getMorphClass())
->where('revisionable_id', $page->id)
// By id, not by timestamp: these all land in the same second.
->orderByDesc('id')
->first()
->event);
}
#[Test]
public function the_history_shows_on_the_record(): void
{
$this->actingAs($this->userWithRoles('admin'));
$page = Page::factory()->create();
$page->update(['title' => ['es' => 'Cambiado']]);
$this->get(PageResource::getUrl('edit', ['record' => $page]))->assertSuccessful();
}
#[Test]
public function every_content_type_keeps_one(): void
{
// Pinned: the factory randomises this one, and updating a value to
// what it already was is correctly not a change.
$project = Project::factory()->create(['is_featured' => false]);
$project->update(['is_featured' => true]);
$this->assertSame(2, $project->revisions()->count());
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\SettingsResource;
use App\Filament\Resources\SettingsResource\Pages\EditSettings;
use App\Models\Setting;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The generic settings editor.
*
* A setting holds arbitrary JSON — `site.maintenance` is a map containing a
* map of translations — so the thing this screen must never do is flatten it.
*/
class SettingsResourceTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function the_list_renders_whatever_shape_a_setting_has(): void
{
Setting::set('site.maintenance', ['enabled' => false, 'message' => ['es' => 'Hola']]);
Setting::set('un.escalar', 'texto');
Setting::set('una.lista', [1, 2, 3]);
$this->actingAs($this->userWithRoles('super_admin'));
$this->get(SettingsResource::getUrl('index'))->assertSuccessful();
}
#[Test]
public function a_nested_setting_survives_a_round_trip(): void
{
$value = [
'enabled' => true,
'message' => ['es' => 'Volvemos en un rato', 'ca' => 'Tornem de seguida'],
'until' => null,
];
Setting::set('site.maintenance', $value);
$this->actingAs($this->userWithRoles('super_admin'));
Livewire::test(EditSettings::class, ['record' => 'site.maintenance'])
->call('save')
->assertHasNoFormErrors();
// Opening and saving without touching anything must leave every
// translation exactly as it was. A key/value grid would have written
// back the word "Array" here.
// assertEquals, not assertSame: Postgres stores these as jsonb,
// which does not preserve object key order. Every value here is a map
// whose order carries no meaning.
$this->assertEquals($value, Setting::get('site.maintenance'));
}
#[Test]
public function a_nested_value_can_be_edited(): void
{
Setting::set('site.maintenance', ['enabled' => false, 'message' => ['es' => 'Antiguo']]);
$this->actingAs($this->userWithRoles('super_admin'));
Livewire::test(EditSettings::class, ['record' => 'site.maintenance'])
->fillForm(['value' => json_encode(['enabled' => true, 'message' => ['es' => 'Nuevo', 'ca' => 'Nou']])])
->call('save')
->assertHasNoFormErrors();
$this->assertEquals(
['enabled' => true, 'message' => ['es' => 'Nuevo', 'ca' => 'Nou']],
Setting::get('site.maintenance'),
);
}
#[Test]
public function a_typo_is_refused_rather_than_stored(): void
{
Setting::set('site.maintenance', ['enabled' => false]);
$this->actingAs($this->userWithRoles('super_admin'));
Livewire::test(EditSettings::class, ['record' => 'site.maintenance'])
->fillForm(['value' => '{"enabled": tru}'])
->call('save')
->assertHasFormErrors(['value']);
// Invalid JSON must not reach a column the whole application reads.
$this->assertSame(['enabled' => false], Setting::get('site.maintenance'));
}
#[Test]
public function an_editor_may_look_but_not_touch(): void
{
Setting::set('site.maintenance', ['enabled' => false]);
$editor = $this->userWithRoles('editor');
$this->assertTrue($editor->can('settings.view'));
$this->assertFalse($editor->can('settings.update'));
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Clients\ClientResource;
use App\Filament\Resources\Services\ServiceResource;
use App\Filament\Resources\Talks\TalkResource;
use App\Filament\Resources\Testimonials\TestimonialResource;
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 Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The four content types a landing page is built from.
*/
class ShowcaseResourcesTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
}
#[Test]
public function every_editor_screen_opens(): void
{
$client = Client::factory()->create();
$service = Service::factory()->create();
$testimonial = Testimonial::factory()->create(['client_id' => $client->id]);
$talk = Talk::factory()->create();
$this->actingAs($this->userWithRoles('admin'));
foreach ([
[ServiceResource::class, $service],
[ClientResource::class, $client],
[TestimonialResource::class, $testimonial],
[TalkResource::class, $talk],
] as [$resource, $record]) {
$this->get($resource::getUrl('create'))->assertSuccessful();
$this->get($resource::getUrl('edit', ['record' => $record]))->assertSuccessful();
}
}
#[Test]
public function publishing_a_service_is_a_separate_permission_from_writing_one(): void
{
$editor = $this->userWithRoles('editor');
// Same reasoning as projects and pages: somebody may write the offer
// without deciding it goes on the website.
$this->assertTrue($editor->can('services.create'));
$this->assertTrue($editor->can('services.publish'));
$viewer = $this->userWithRoles('viewer');
$this->assertTrue($viewer->can('services.view_any'));
$this->assertFalse($viewer->can('services.publish'));
$this->assertFalse($viewer->can('services.create'));
}
#[Test]
public function a_viewer_can_look_at_the_showcase_but_not_change_it(): void
{
$this->actingAs($this->userWithRoles('viewer'));
foreach ([ServiceResource::class, ClientResource::class, TestimonialResource::class, TalkResource::class] as $resource) {
$this->assertTrue($resource::canViewAny());
$this->assertFalse($resource::canCreate());
}
}
#[Test]
public function deleting_a_client_leaves_its_projects_standing(): void
{
$client = Client::factory()->create();
$project = Project::factory()->create(['client_id' => $client->id]);
$client->forceDelete();
// The work happened whether or not the organisation is still on file.
$this->assertNotNull($project->fresh());
$this->assertNull($project->fresh()->client_id);
}
#[Test]
public function a_talk_shows_on_the_profile_of_whoever_gave_it(): void
{
$talk = Talk::factory()->create();
$member = Member::factory()->create();
$talk->update(['member_id' => $member->id]);
// Written once, it appears in both places instead of being typed
// twice and drifting.
$this->assertSame(1, $member->talks()->count());
$this->assertSame($talk->id, $member->talks()->sole()->id);
}
#[Test]
public function a_service_knows_how_much_work_backs_it(): void
{
$service = Service::factory()->create();
$this->assertSame(0, $service->projects()->count());
$service->projects()->attach(Project::factory()->published()->create()->id);
// The number the listing shows in orange when it is zero: a service
// with nothing behind it is a promise.
$this->assertSame(1, $service->projects()->count());
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\CallStatus;
use App\Filament\Pages\SiteHealth;
use App\Models\Application;
use App\Models\Call;
use App\Models\Media;
use App\Models\Project;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The checks exist because the public site is somewhere else: none of these
* faults is visible where the content is edited.
*/
class SiteHealthTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
Storage::fake(config('media.disk'));
}
protected function finding(string $key): Finding
{
return app(HealthReport::class)->runAll()->firstWhere('key', $key)
?? throw new \RuntimeException("No finding for {$key}");
}
#[Test]
public function a_clean_site_reports_nothing(): void
{
$problems = app(HealthReport::class)->runAll()
->filter(fn (Finding $f) => $f->severity->isProblem());
$this->assertTrue($problems->isEmpty(), 'Expected an empty database to be healthy');
}
#[Test]
public function it_finds_a_record_translated_without_a_slug(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
'slug' => ['es' => 'mi-proyecto'],
]);
$finding = $this->finding('missing-slugs');
// Already broken on the public site: the Catalan alternate either
// vanishes or points at a 404.
$this->assertSame(Severity::Error, $finding->severity);
$this->assertSame(1, $finding->count);
}
#[Test]
public function it_finds_two_records_answering_at_the_same_url(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'repetido'],
]);
Project::factory()->published()->create([
'title' => ['es' => 'Dos'], 'slug' => ['es' => 'repetido'],
]);
$finding = $this->finding('duplicate-slugs');
// The write rule prevents this, but a slug lives inside JSON so it can
// never be a database constraint — imports and seeders get through.
$this->assertSame(Severity::Error, $finding->severity);
$this->assertSame(1, $finding->count);
}
#[Test]
public function the_same_slug_in_two_languages_is_not_a_duplicate(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Uno', 'en' => 'One'],
'slug' => ['es' => 'igual', 'en' => 'igual'],
]);
$this->assertSame(Severity::Ok, $this->finding('duplicate-slugs')->severity);
}
#[Test]
public function it_finds_an_image_whose_file_is_gone(): void
{
Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']);
$finding = $this->finding('broken-images');
// The API keeps serving the URL — it has no reason to doubt it — so
// the only symptom is a broken image, on the other site.
$this->assertSame(Severity::Error, $finding->severity);
$this->assertSame(1, $finding->count);
}
#[Test]
public function an_image_still_in_the_queue_is_not_broken(): void
{
Media::factory()->create(['path' => 'projects/pending.jpg']);
// It is early, not broken. That is a different check.
$this->assertSame(Severity::Ok, $this->finding('broken-images')->severity);
}
#[Test]
public function it_notices_when_nothing_is_processing_images(): void
{
Media::factory()->create([
'path' => 'projects/stuck.jpg',
'created_at' => now()->subHours(3),
]);
$finding = $this->finding('stalled-media');
// The site keeps working, images just lose their dimensions and
// srcset — which is exactly why nobody would notice.
$this->assertSame(1, $finding->count);
}
#[Test]
public function it_finds_a_published_image_nobody_described(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Con portada'],
'slug' => ['es' => 'con-portada'],
'cover_image' => 'projects/cover.jpg',
'cover_image_alt' => null,
]);
$this->assertSame(1, $this->finding('images-without-alt')->count);
}
#[Test]
public function it_finds_a_call_left_open_past_its_closing_date(): void
{
Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->subWeek(),
]);
$this->assertSame(1, $this->finding('expired-calls')->count);
}
#[Test]
public function it_counts_people_who_have_been_waiting_too_long(): void
{
Application::factory()->create(['created_at' => now()->subDays(10)]);
Application::factory()->create(['created_at' => now()->subHours(2)]);
$finding = $this->finding('unanswered-submissions');
// Somebody offered to help and heard nothing, which costs more than
// any other fault on the page — hence an error, not a warning.
$this->assertSame(Severity::Error, $finding->severity);
$this->assertSame(1, $finding->count);
}
#[Test]
public function a_check_is_hidden_from_anyone_who_may_not_see_what_it_counts(): void
{
Application::factory()->create(['created_at' => now()->subDays(10)]);
$editor = $this->userWithRoles('editor');
$viewer = $this->userWithRoles('viewer');
$report = app(HealthReport::class);
// Counting submissions is knowing they exist, and who may know that is
// the same question as who may read the inbox.
$this->assertNotNull($report->runFor($editor)->firstWhere('key', 'unanswered-submissions'));
$this->assertNull($report->runFor($viewer)->firstWhere('key', 'unanswered-submissions'));
}
#[Test]
public function findings_come_back_worst_first(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Sin portada'],
'slug' => ['es' => 'sin-portada'],
]);
Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']);
$severities = app(HealthReport::class)->runAll()
->map(fn (Finding $f) => $f->severity->weight())
->all();
$sorted = $severities;
sort($sorted);
$this->assertSame($sorted, $severities);
}
#[Test]
public function the_page_renders_for_anyone_who_can_reach_the_panel(): void
{
$this->actingAs($this->userWithRoles('viewer'));
$this->get(SiteHealth::getUrl())->assertSuccessful();
}
#[Test]
public function the_command_fails_a_deploy_when_something_is_broken(): void
{
Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']);
$this->artisan('site:check')->assertFailed();
// A warning-only site can still deploy when the threshold says so.
$this->artisan('site:check', ['--fail-on' => 'never'])->assertSuccessful();
}
#[Test]
public function the_command_can_ignore_warnings_and_fail_only_on_faults(): void
{
// A warning, not a fault: nothing a visitor hits.
Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->subWeek(),
]);
$this->artisan('site:check', ['--fail-on' => 'error'])->assertSuccessful();
$this->artisan('site:check')->assertFailed();
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Models\Page;
use App\Models\Project;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\TranslationFreshness;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Translations the original has moved on without.
*
* The fault this catches is the one nothing else can see: the text is there in
* every language, so every screen calls the record finished, and one of those
* languages has been saying something the source stopped saying. It is worked
* out from the change history, so what these tests really hold still is that
* the history is being read the way it is written.
*/
class StaleTranslationsTest extends TestCase
{
use RefreshDatabase;
protected function translated(): Page
{
return Page::factory()->published()->create([
'title' => ['es' => 'Uno', 'en' => 'One', 'ca' => 'U'],
'slug' => ['es' => 'uno', 'en' => 'one', 'ca' => 'u'],
'body' => ['es' => 'Texto', 'en' => 'Text', 'ca' => 'Text'],
]);
}
#[Test]
public function a_record_nobody_has_edited_is_not_out_of_date(): void
{
$page = $this->translated();
// Written in three languages at once: whatever else is true of it,
// none of them is behind another.
$this->assertSame([], TranslationFreshness::for($page));
}
#[Test]
public function editing_the_original_leaves_the_other_languages_behind(): void
{
$page = $this->translated();
$this->travel(1)->days();
$page->setTranslation('title', 'es', 'Uno corregido')->save();
$stale = TranslationFreshness::for($page->fresh());
$this->assertSame(['en', 'ca'], array_keys($stale));
// And it says since when, which is what decides how urgent it is.
$this->assertTrue($stale['ca']->isToday());
}
#[Test]
public function catching_a_translation_up_clears_it(): void
{
$page = $this->translated();
$this->travel(1)->days();
$page->setTranslation('body', 'es', 'Texto nuevo')->save();
$this->travel(1)->days();
$page->fresh()->setTranslation('body', 'ca', 'Text nou')->save();
$stale = TranslationFreshness::for($page->fresh());
// Catalan caught up; English never did.
$this->assertArrayNotHasKey('ca', $stale);
$this->assertArrayHasKey('en', $stale);
}
/**
* A language with nothing in it is not out of date, it is untranslated —
* and there is already a check that says so. Reporting both about one
* record would just make two lists that never empty.
*/
#[Test]
public function a_missing_translation_is_not_a_stale_one(): void
{
$page = Page::factory()->published()->create([
'title' => ['es' => 'Uno'],
'slug' => ['es' => 'uno'],
]);
$this->travel(1)->days();
$page->setTranslation('title', 'es', 'Uno corregido')->save();
$this->assertSame([], TranslationFreshness::for($page->fresh()));
}
/**
* Renaming the Spanish address does not make the Catalan text less true,
* and slugs are the one translatable field that routinely changes on their
* own — counting them would mark half the site out of date the first time
* somebody tidied a URL.
*/
#[Test]
public function renaming_a_slug_does_not_make_a_translation_stale(): void
{
$page = $this->translated();
$this->travel(1)->days();
$page->setTranslation('slug', 'es', 'uno-mejor')->save();
$this->assertSame([], TranslationFreshness::for($page->fresh()));
}
#[Test]
public function editing_a_translation_does_not_make_the_original_stale(): void
{
$page = $this->translated();
$this->travel(1)->days();
$page->setTranslation('title', 'ca', 'U corregit')->save();
// The source language is never reported against itself, and touching
// Catalan says nothing about English.
$this->assertSame([], TranslationFreshness::for($page->fresh()));
}
#[Test]
public function the_health_check_reports_it(): void
{
$page = $this->translated();
$this->travel(1)->days();
$page->setTranslation('body', 'es', 'Texto corregido')->save();
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'stale-translations');
$this->assertInstanceOf(Finding::class, $finding);
$this->assertSame(1, $finding->count);
$this->assertStringContainsString('ca', $finding->samples[0]);
}
#[Test]
public function it_works_for_every_translated_type(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Proyecto', 'ca' => 'Projecte'],
'slug' => ['es' => 'proyecto', 'ca' => 'projecte'],
]);
$this->travel(1)->days();
$project->setTranslation('title', 'es', 'Proyecto nuevo')->save();
$this->assertArrayHasKey('ca', TranslationFreshness::for($project->fresh()));
}
/**
* The filter's half: the answer cannot be a SQL condition, so it is worked
* out per record and handed to the query as a list.
*/
#[Test]
public function the_panel_can_ask_for_the_ones_that_fell_behind(): void
{
$behind = $this->translated();
$fine = $this->translated();
$this->travel(1)->days();
$behind->setTranslation('title', 'es', 'Uno corregido')->save();
$ids = TranslationFreshness::staleIds(Page::query());
$this->assertContains($behind->id, $ids);
$this->assertNotContains($fine->id, $ids);
}
/**
* The answer is cached against the record's `updated_at`, so it has to be
* recomputed the moment anything about that record changes.
*/
#[Test]
public function the_cached_answer_does_not_survive_the_next_edit(): void
{
$page = $this->translated();
$this->assertSame([], TranslationFreshness::for($page));
$this->travel(1)->days();
$page->setTranslation('title', 'es', 'Uno corregido')->save();
$this->assertNotSame([], TranslationFreshness::for($page->fresh()));
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Users\Pages\EditUser;
use App\Filament\Resources\Users\RelationManagers\SessionsRelationManager;
use App\Models\User;
use App\Models\UserSession;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Where an account is signed in from, read straight from the table Laravel's
* own database session driver already writes to.
*
* Deactivating somebody here has always cut them off on their *next*
* request; what was missing was any way to see a session already in
* progress, or end one before that next request happened on its own. Nothing
* here writes to `sessions` except the revoke action deleting a row — the
* table itself is entirely Laravel's.
*/
class UserSessionsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function plantSession(User $user, array $attributes = []): UserSession
{
return UserSession::create(array_merge([
'id' => Str::random(40),
'user_id' => $user->id,
'ip_address' => '203.0.113.10',
'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15',
'payload' => base64_encode(serialize([])),
'last_activity' => now()->subMinutes(5)->timestamp,
], $attributes));
}
#[Test]
public function an_admin_sees_the_accounts_open_sessions(): void
{
$target = $this->userWithRoles('editor');
$this->plantSession($target);
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(SessionsRelationManager::class, [
'ownerRecord' => $target,
'pageClass' => EditUser::class,
])
->assertSee('203.0.113.10')
->assertSee('Safari · macOS');
}
#[Test]
public function revoking_a_session_deletes_the_row(): void
{
$target = $this->userWithRoles('editor');
$session = $this->plantSession($target);
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(SessionsRelationManager::class, [
'ownerRecord' => $target,
'pageClass' => EditUser::class,
])->callTableAction('revoke', $session);
$this->assertDatabaseMissing('sessions', ['id' => $session->id]);
}
/**
* The one row this screen must never offer to end: doing so would sign
* out whoever is looking at it, mid-click.
*/
#[Test]
public function the_viewers_own_current_session_cannot_be_revoked_from_here(): void
{
$admin = $this->userWithRoles('admin');
$this->actingAs($admin);
// Force a request so Laravel actually starts a session and this test
// knows the real session id, then plant a row using that same id —
// exactly what the real session driver would have written.
$this->get(EditUser::getUrl(['record' => $admin]));
$currentId = session()->getId();
$currentSession = $this->plantSession($admin, ['id' => $currentId]);
$other = $this->plantSession($admin);
$manager = Livewire::test(SessionsRelationManager::class, [
'ownerRecord' => $admin,
'pageClass' => EditUser::class,
]);
$manager->assertTableActionHidden('revoke', $currentSession);
$manager->assertTableActionVisible('revoke', $other);
}
#[Test]
public function somebody_without_users_update_cannot_see_the_tab(): void
{
$target = $this->userWithRoles('editor');
$this->actingAs($this->userWithRoles('viewer'));
$this->assertFalse(SessionsRelationManager::canViewForRecord($target, EditUser::class));
}
#[Test]
public function a_device_with_no_recognisable_browser_or_platform_says_so(): void
{
$target = $this->userWithRoles('editor');
$this->plantSession($target, ['user_agent' => 'curl/8.4.0']);
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(SessionsRelationManager::class, [
'ownerRecord' => $target,
'pageClass' => EditUser::class,
])->assertSee(__('panel.sessions.unknown_device'));
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\Project;
use App\Support\ContentVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The number that tells the frontend its cache is stale.
*
* The public API is cached with a long `stale-while-revalidate`, which is
* right for an SSR frontend and wrong for an urgent correction. This is how a
* fix stops sitting behind a stale copy without a purge webhook or anything to
* deploy.
*/
class ContentVersionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
#[Test]
public function the_frontend_is_given_a_version(): void
{
$this->getJson('/api/public/v1/site')
->assertOk()
->assertJsonPath('data.content_version', ContentVersion::current());
}
#[Test]
public function publishing_something_moves_it(): void
{
$before = ContentVersion::current();
Project::factory()->published()->create();
$this->assertGreaterThan($before, ContentVersion::current());
}
#[Test]
public function a_draft_nobody_can_see_does_not(): void
{
Project::factory()->draft()->create();
$before = ContentVersion::current();
Project::query()->first()->update(['title' => ['es' => 'Otro borrador']]);
// Editing something invisible invalidates nobody's cache, and bumping
// on every keystroke of a half-written page would make the number
// meaningless.
$this->assertSame($before, ContentVersion::current());
}
#[Test]
public function taking_something_down_moves_it_too(): void
{
$page = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subDay(),
]);
$before = ContentVersion::current();
$page->update(['status' => PageStatus::Draft]);
// Going away is exactly as visible to a reader as arriving.
$this->assertGreaterThan($before, ContentVersion::current());
}
#[Test]
public function deleting_published_work_moves_it(): void
{
$project = Project::factory()->published()->create();
$before = ContentVersion::current();
$project->delete();
$this->assertGreaterThan($before, ContentVersion::current());
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Models\Project;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class MaintenanceModeTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
}
#[Test]
public function the_public_api_answers_503_with_the_notice(): void
{
Project::factory()->published()->create();
Maintenance::enable(message: ['es' => 'Volvemos en una hora.']);
$response = $this->getJson('/api/public/v1/projects');
$response->assertStatus(503)
->assertJsonPath('maintenance', true)
->assertJsonPath('message', 'Volvemos en una hora.');
$this->assertNotNull($response->headers->get('Retry-After'));
// A cached 503 would outlive the maintenance itself.
$this->assertSame('no-store, private', $response->headers->get('Cache-Control'));
}
#[Test]
public function the_notice_is_returned_in_the_requested_language(): void
{
Maintenance::enable(message: [
'es' => 'Estamos de obras.',
'ca' => 'Estem d\'obres.',
]);
$this->getJson('/api/public/v1/site?lang=ca')
->assertStatus(503)
->assertJsonPath('message', 'Estem d\'obres.');
}
#[Test]
public function a_language_without_a_written_notice_falls_back(): void
{
Maintenance::enable(message: ['es' => 'Solo en castellano.']);
// Falls back to the default locale's wording rather than showing
// nothing to an English visitor.
$this->getJson('/api/public/v1/site?lang=en')
->assertStatus(503)
->assertJsonPath('message', 'Solo en castellano.');
}
#[Test]
public function with_no_notice_written_a_translated_default_is_used(): void
{
Maintenance::enable();
$this->getJson('/api/public/v1/site?lang=en')
->assertStatus(503)
->assertJsonPath('message', __('maintenance.default_message', [], 'en'));
}
#[Test]
public function the_panel_keeps_working_while_the_site_is_down(): void
{
$this->seed(RolePermissionSeeder::class);
Maintenance::enable();
// The whole point: the collective must be able to fix whatever caused
// the maintenance.
$this->actingAs($this->userWithRoles('admin'))
->get('/admin')
->assertSuccessful();
}
#[Test]
public function the_admin_api_keeps_working_while_the_site_is_down(): void
{
$this->seed(RolePermissionSeeder::class);
Maintenance::enable();
$user = $this->userWithRoles('editor');
$this->getJson('/api/v1/me', $this->apiHeadersFor($user))->assertOk();
}
#[Test]
public function the_preview_secret_lets_the_team_see_the_real_site(): void
{
config()->set('site.preview_token', 'the-secret');
Project::factory()->published()->create();
Maintenance::enable();
$this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret'])
->assertOk()
->assertJsonCount(1, 'data');
}
#[Test]
public function a_trusted_ip_also_bypasses_it(): void
{
config()->set('site.trusted_ips', ['127.0.0.1']);
Project::factory()->published()->create();
Maintenance::enable();
$this->getJson('/api/public/v1/projects')->assertOk();
}
#[Test]
public function it_lifts_itself_once_the_window_has_passed(): void
{
Maintenance::enable(until: now()->addHour()->toImmutable());
$this->assertTrue(Maintenance::isEnabled());
$this->travel(2)->hours();
// A forgotten flag must not keep the site down forever.
$this->assertFalse(Maintenance::isEnabled());
$this->getJson('/api/public/v1/site')->assertOk();
}
#[Test]
public function retry_after_reflects_the_remaining_window(): void
{
Maintenance::enable(until: now()->addMinutes(10)->toImmutable());
$retryAfter = (int) $this->getJson('/api/public/v1/site')
->assertStatus(503)
->headers->get('Retry-After');
$this->assertGreaterThan(500, $retryAfter);
$this->assertLessThanOrEqual(600, $retryAfter);
}
#[Test]
public function disabling_brings_the_site_back(): void
{
Maintenance::enable();
$this->getJson('/api/public/v1/site')->assertStatus(503);
Maintenance::disable();
$this->getJson('/api/public/v1/site')->assertOk();
}
#[Test]
public function the_setting_is_cached_but_a_change_takes_effect_at_once(): void
{
$this->getJson('/api/public/v1/site')->assertOk();
Maintenance::enable();
// Writes clear the cache entry, so there is no stale window in which
// the site stays up after someone flipped the switch.
$this->getJson('/api/public/v1/site')->assertStatus(503);
}
#[Test]
public function toggling_it_needs_its_own_permission(): void
{
$this->seed(RolePermissionSeeder::class);
$editor = $this->userWithRoles('editor');
$admin = $this->userWithRoles('admin');
// Editors can see the state but not take the site down.
$this->assertTrue($editor->can('settings.view'));
$this->assertFalse($editor->can('settings.maintenance'));
$this->assertTrue($admin->can('settings.maintenance'));
}
#[Test]
public function the_command_reports_and_toggles(): void
{
$this->artisan('site:maintenance', ['--message' => 'Obras'])
->assertSuccessful();
$this->assertTrue(Maintenance::isEnabled());
$this->assertSame('Obras', Maintenance::message('es'));
$this->artisan('site:maintenance', ['--up' => true])->assertSuccessful();
$this->assertFalse(Maintenance::isEnabled());
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Models\Media;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Revision;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Keeping the system from rotting: redirects that make things worse, and the
* three kinds of debris that otherwise accumulate for ever.
*/
class PruneAndRedirectHealthTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Storage::fake(config('media.disk'));
}
protected function finding(string $key): Finding
{
return app(HealthReport::class)->runAll()->firstWhere('key', $key)
?? throw new \RuntimeException("No finding for {$key}");
}
#[Test]
public function it_finds_a_redirect_that_chains_into_another(): void
{
Redirect::factory()->create(['from' => '/a', 'to' => '/b', 'locale' => 'es']);
Redirect::factory()->create(['from' => '/b', 'to' => '/c', 'locale' => 'es']);
$finding = $this->finding('broken-redirects');
// The visitor is sent somewhere that sends them somewhere else: a
// round trip per hop, and eventually a loop.
$this->assertSame(Severity::Error, $finding->severity);
$this->assertSame(1, $finding->count);
}
#[Test]
public function it_finds_a_redirect_that_lands_on_nothing(): void
{
Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/no-existe']);
// Worse than the 404 it was meant to prevent: the visitor is bounced
// and lands on the same nothing.
$this->assertSame(1, $this->finding('redirects-to-nowhere')->count);
}
#[Test]
public function a_redirect_to_a_real_page_is_fine(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Vivo'], 'slug' => ['es' => 'vivo'],
]);
Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/vivo']);
$this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity);
}
#[Test]
public function a_redirect_off_this_site_is_not_ours_to_verify(): void
{
Redirect::factory()->create(['from' => '/viejo', 'to' => 'https://otra-web.example/algo']);
$this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity);
}
#[Test]
public function pruning_keeps_the_first_and_last_of_a_history(): void
{
$page = Page::factory()->create();
foreach (range(1, 4) as $n) {
$page->update(['title' => ['es' => "Versión {$n}"]]);
}
Revision::query()->update(['created_at' => now()->subYears(2)]);
$this->artisan('content:prune')->assertSuccessful();
// Two facts survive whatever happens: when it appeared, and who
// touched it last.
$this->assertSame(2, $page->revisions()->count());
// `reorder`, because the relation sorts newest-first by default and
// an added orderBy would only be a tie-breaker behind it.
$this->assertSame('created', $page->revisions()->reorder('id')->first()->event);
}
#[Test]
public function recent_history_is_left_alone(): void
{
$page = Page::factory()->create();
$page->update(['title' => ['es' => 'Reciente']]);
$this->artisan('content:prune')->assertSuccessful();
$this->assertSame(2, $page->revisions()->count());
}
#[Test]
public function it_erases_what_has_been_in_the_bin_long_enough(): void
{
$old = Page::factory()->create();
$old->delete();
$old->forceFill(['deleted_at' => now()->subYear()])->save();
$recent = Page::factory()->create();
$recent->delete();
$this->artisan('content:prune')->assertSuccessful();
$this->assertNull(Page::withTrashed()->find($old->id));
$this->assertNotNull(Page::withTrashed()->find($recent->id));
}
#[Test]
public function a_dry_run_changes_nothing(): void
{
$page = Page::factory()->create();
$page->delete();
$page->forceFill(['deleted_at' => now()->subYear()])->save();
$this->artisan('content:prune', ['--dry-run' => true])->assertSuccessful();
$this->assertNotNull(Page::withTrashed()->find($page->id));
}
#[Test]
public function it_deletes_image_files_nothing_points_at(): void
{
$disk = Storage::disk(config('media.disk'));
$disk->put('projects/en-uso.jpg', 'x');
$disk->put('projects/huerfana.jpg', 'x');
Project::factory()->create(['cover_image' => 'projects/en-uso.jpg']);
Media::factory()->create(['path' => 'projects/huerfana.jpg']);
$this->artisan('content:prune', ['--media' => true])->assertSuccessful();
$this->assertTrue($disk->exists('projects/en-uso.jpg'));
$this->assertFalse($disk->exists('projects/huerfana.jpg'));
}
#[Test]
public function an_image_belonging_to_something_in_the_bin_is_kept(): void
{
$disk = Storage::disk(config('media.disk'));
$disk->put('projects/borrado.jpg', 'x');
$project = Project::factory()->create(['cover_image' => 'projects/borrado.jpg']);
$project->delete();
$this->artisan('content:prune', ['--media' => true])->assertSuccessful();
// It can still be restored, and restoring it to a missing image would
// be worse than keeping a file.
$this->assertTrue($disk->exists('projects/borrado.jpg'));
}
#[Test]
public function files_are_left_alone_unless_asked_for(): void
{
$disk = Storage::disk(config('media.disk'));
$disk->put('projects/huerfana.jpg', 'x');
Media::factory()->create(['path' => 'projects/huerfana.jpg']);
$this->artisan('content:prune')->assertSuccessful();
// Deleting a file is the one thing here that restoring a row cannot
// undo, so it never happens by routine.
$this->assertTrue($disk->exists('projects/huerfana.jpg'));
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Enums\CallStatus;
use App\Enums\PageStatus;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Content that is only true between two dates.
*
* The window is enforced by the scopes rather than by a scheduled job, so it
* holds even when nothing is running — a queue worker being down must not put
* last month's notice back on the site.
*/
class PublicationWindowTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
#[Test]
public function a_page_stays_off_the_site_until_its_date(): void
{
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'El anuncio'],
'slug' => ['es' => 'el-anuncio'],
'published_at' => now()->addDay(),
]);
$this->getJson('/api/public/v1/pages/el-anuncio')->assertNotFound();
$this->travel(2)->days();
$this->getJson('/api/public/v1/pages/el-anuncio')->assertOk();
}
#[Test]
public function a_page_drops_off_the_site_on_its_end_date(): void
{
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'Taller de octubre'],
'slug' => ['es' => 'taller'],
'published_at' => now()->subWeek(),
'unpublish_at' => now()->addDay(),
]);
$this->getJson('/api/public/v1/pages/taller')->assertOk();
$this->travel(2)->days();
// Nobody has to remember to take it down, which is the whole point:
// nobody ever does.
$this->getJson('/api/public/v1/pages/taller')->assertNotFound();
}
#[Test]
public function the_window_holds_without_anything_running(): void
{
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'Caducada'],
'slug' => ['es' => 'caducada'],
'published_at' => now()->subMonth(),
'unpublish_at' => now()->subDay(),
]);
// No command, no queue, no scheduler — the scope is what decides.
$this->getJson('/api/public/v1/pages')
->assertOk()
->assertJsonCount(0, 'data');
}
#[Test]
public function an_expired_project_leaves_the_url_list_too(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Terminado'],
'slug' => ['es' => 'terminado'],
'unpublish_at' => now()->subDay(),
]);
$slugs = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data');
// Otherwise the frontend would keep it in the sitemap and point search
// engines at a 404.
$this->assertEmpty($slugs['projects'] ?? []);
}
#[Test]
public function preview_still_reaches_content_outside_its_window(): void
{
config()->set('site.preview_token', 'secreto');
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'Aún no'],
'slug' => ['es' => 'aun-no'],
'published_at' => now()->addWeek(),
]);
// Checking a scheduled page before its date is exactly what preview is
// for.
$this->withHeaders(['X-Preview-Token' => 'secreto'])
->getJson('/api/public/v1/pages/aun-no')
->assertOk();
}
#[Test]
public function the_command_closes_a_call_whose_date_has_passed(): void
{
$call = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->subWeek(),
]);
$this->artisan('content:schedule')->assertSuccessful();
// The site already stopped offering it; this is about the panel not
// claiming otherwise, and a counter that stays worth reading.
$this->assertSame(CallStatus::Closed, $call->refresh()->status);
}
#[Test]
public function the_command_leaves_a_call_that_is_still_open(): void
{
$open = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->addWeek(),
]);
$undated = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => null,
]);
$this->artisan('content:schedule')->assertSuccessful();
$this->assertSame(CallStatus::Open, $open->refresh()->status);
$this->assertSame(CallStatus::Open, $undated->refresh()->status);
}
#[Test]
public function a_dry_run_changes_nothing(): void
{
$call = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->subWeek(),
]);
$this->artisan('content:schedule', ['--dry-run' => true])->assertSuccessful();
$this->assertSame(CallStatus::Open, $call->refresh()->status);
}
#[Test]
public function the_panel_can_tell_the_three_states_apart(): void
{
$scheduled = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->addDays(3),
]);
$expired = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subMonth(),
'unpublish_at' => now()->subDay(),
]);
$live = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subDay(),
]);
// A panel that reports "Published" for something the site is not
// showing is lying to whoever wrote it.
$this->assertTrue($scheduled->isScheduled());
$this->assertFalse($scheduled->hasExpired());
$this->assertTrue($expired->hasExpired());
$this->assertFalse($expired->isScheduled());
$this->assertFalse($live->isScheduled());
$this->assertFalse($live->hasExpired());
}
#[Test]
public function content_about_to_go_can_be_spotted_in_advance(): void
{
$soon = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subMonth(),
'unpublish_at' => now()->addDays(2),
]);
$later = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subMonth(),
'unpublish_at' => now()->addMonths(2),
]);
$this->assertTrue($soon->expiresSoon());
$this->assertFalse($later->expiresSoon());
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Service;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The net under a renamed slug.
*
* Renaming one breaks every link anybody shared, silently: nothing here
* notices and the frontend only finds out when a visitor gets a 404. The
* moment the slug changes is the only moment the old address is still known.
*/
class RedirectsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
#[Test]
public function renaming_a_slug_leaves_a_redirect_behind(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Mi proyecto'],
'slug' => ['es' => 'nombre-viejo'],
]);
$project->update(['slug' => ['es' => 'nombre-nuevo']]);
$redirect = Redirect::query()->sole();
$this->assertSame('/proyectos/nombre-viejo', $redirect->from);
$this->assertSame('/proyectos/nombre-nuevo', $redirect->to);
$this->assertSame(301, $redirect->status);
$this->assertSame('es', $redirect->locale);
$this->assertTrue($redirect->wasAutomatic());
}
#[Test]
public function each_language_gets_its_own_rule(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno', 'ca' => 'U'],
'slug' => ['es' => 'viejo-es', 'ca' => 'vell-ca'],
]);
$project->update(['slug' => ['es' => 'nuevo-es', 'ca' => 'nou-ca']]);
// The Catalan URL carries its own prefix and its own path segment, so
// one rule could never cover both.
$this->assertSame(
['/proyectos/viejo-es' => '/proyectos/nuevo-es', '/ca/projectes/vell-ca' => '/ca/projectes/nou-ca'],
Redirect::query()->pluck('to', 'from')->all(),
);
}
#[Test]
public function adding_a_translation_is_not_a_rename(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno'],
'slug' => ['es' => 'uno'],
]);
$project->update(['slug' => ['es' => 'uno', 'ca' => 'u']]);
// Nothing broke: there was no Catalan address before.
$this->assertSame(0, Redirect::query()->count());
}
#[Test]
public function a_second_rename_does_not_leave_a_chain_behind(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'v1'],
]);
$project->update(['slug' => ['es' => 'v2']]);
$project->update(['slug' => ['es' => 'v3']]);
// Both old addresses point at the current one. A frontend following a
// chain would cost an extra round trip per hop.
$this->assertSame(
['/proyectos/v1' => '/proyectos/v3', '/proyectos/v2' => '/proyectos/v3'],
Redirect::query()->orderBy('from')->pluck('to', 'from')->all(),
);
}
#[Test]
public function renaming_back_does_not_create_a_loop(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'original'],
]);
$project->update(['slug' => ['es' => 'cambiado']]);
$project->update(['slug' => ['es' => 'original']]);
// A rule from a path to itself would send a browser round for ever.
foreach (Redirect::query()->get() as $redirect) {
$this->assertFalse($redirect->isCircular(), "{$redirect->from} → {$redirect->to}");
}
}
#[Test]
public function it_works_for_every_kind_of_record_with_a_public_url(): void
{
$service = Service::factory()->create([
'title' => ['es' => 'Servicio'], 'slug' => ['es' => 'antiguo'],
]);
$service->update(['slug' => ['es' => 'moderno']]);
$this->assertSame('/servicios/antiguo', Redirect::query()->sole()->from);
}
#[Test]
public function the_frontend_gets_the_whole_list_in_one_request(): void
{
Redirect::factory()->create(['from' => '/vieja', 'to' => '/nueva', 'locale' => null]);
Redirect::factory()->disabled()->create(['from' => '/apagada', 'to' => '/otra']);
$data = $this->getJson('/api/public/v1/redirects')->assertOk()->json('data');
// One request rather than a lookup per 404: there are never many, and
// an SSR frontend would otherwise ask on every miss it serves.
$this->assertCount(1, $data);
$this->assertSame('/vieja', $data[0]['from']);
$this->assertNull($data[0]['locale']);
}
#[Test]
public function paths_are_normalised_however_they_were_typed(): void
{
$this->assertSame('/proyectos/uno', Redirect::normalise('proyectos/uno'));
$this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno/'));
$this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno?utm=x'));
// So a rule typed by hand matches one written automatically.
$this->assertSame('/', Redirect::normalise('/'));
}
#[Test]
public function following_one_can_be_counted(): void
{
$redirect = Redirect::factory()->create(['hits' => 0]);
$redirect->markUsed();
// The number that decides whether a rule can ever be deleted.
$this->assertSame(1, $redirect->fresh()->hits);
$this->assertNotNull($redirect->fresh()->last_used_at);
}
}