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