Files
landing-api/tests/Feature/Panel/ConcurrentEditingTest.php
Josep Piguillem 3897113313 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>
2026-08-19 17:55:27 +02:00

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