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:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user