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:
159
tests/Feature/Panel/ApplicationsInboxTest.php
Normal file
159
tests/Feature/Panel/ApplicationsInboxTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
154
tests/Feature/Panel/BulkPublishingTest.php
Normal file
154
tests/Feature/Panel/BulkPublishingTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
155
tests/Feature/Panel/CollectiveResourcesTest.php
Normal file
155
tests/Feature/Panel/CollectiveResourcesTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
221
tests/Feature/Panel/ConcurrentEditingTest.php
Normal file
221
tests/Feature/Panel/ConcurrentEditingTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
66
tests/Feature/Panel/ContentFormsTest.php
Normal file
66
tests/Feature/Panel/ContentFormsTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
73
tests/Feature/Panel/DocsNavigationTest.php
Normal file
73
tests/Feature/Panel/DocsNavigationTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
164
tests/Feature/Panel/DuplicateTitlesTest.php
Normal file
164
tests/Feature/Panel/DuplicateTitlesTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
68
tests/Feature/Panel/EnvironmentBannerTest.php
Normal file
68
tests/Feature/Panel/EnvironmentBannerTest.php
Normal 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']));
|
||||
}
|
||||
}
|
||||
77
tests/Feature/Panel/EveryPanelPageRendersTest.php
Normal file
77
tests/Feature/Panel/EveryPanelPageRendersTest.php
Normal 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}"];
|
||||
}
|
||||
}
|
||||
148
tests/Feature/Panel/MediaLibraryTest.php
Normal file
148
tests/Feature/Panel/MediaLibraryTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
249
tests/Feature/Panel/MissingUrlsTest.php
Normal file
249
tests/Feature/Panel/MissingUrlsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
140
tests/Feature/Panel/PanelAccessTest.php
Normal file
140
tests/Feature/Panel/PanelAccessTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
166
tests/Feature/Panel/PanelToolsTest.php
Normal file
166
tests/Feature/Panel/PanelToolsTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
330
tests/Feature/Panel/ReviewsTest.php
Normal file
330
tests/Feature/Panel/ReviewsTest.php
Normal 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')));
|
||||
}
|
||||
}
|
||||
198
tests/Feature/Panel/RevisionsTest.php
Normal file
198
tests/Feature/Panel/RevisionsTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
116
tests/Feature/Panel/SettingsResourceTest.php
Normal file
116
tests/Feature/Panel/SettingsResourceTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
127
tests/Feature/Panel/ShowcaseResourcesTest.php
Normal file
127
tests/Feature/Panel/ShowcaseResourcesTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
244
tests/Feature/Panel/SiteHealthTest.php
Normal file
244
tests/Feature/Panel/SiteHealthTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
195
tests/Feature/Panel/StaleTranslationsTest.php
Normal file
195
tests/Feature/Panel/StaleTranslationsTest.php
Normal 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()));
|
||||
}
|
||||
}
|
||||
135
tests/Feature/Panel/UserSessionsTest.php
Normal file
135
tests/Feature/Panel/UserSessionsTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user