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>
222 lines
8.0 KiB
PHP
222 lines
8.0 KiB
PHP
<?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());
|
|
}
|
|
}
|