LaneCascadeService::move() now calls disableLogging() before every
save inside the transaction (locked performance + cascade-bumped
peers + park-path). The two explicit activity('performance')
->event('moved'|'parked') entries with cascade_count + cascaded_ids
properties are the only audit records per move, matching RFC §8's
"single parent entry summarising the cascade" requirement.
Park path additionally writes an explicit 'performance.parked'
entry per RFC §8 vocabulary instead of falling back to a generic
'updated' auto-log entry.
Two new tests verify:
- cascade move with N peers produces exactly 1 activity entry on
the moved subject and 0 on each cascade-bumped peer
- park writes exactly 1 'parked' entry
PerformanceObserver::saving (version bump) is unaffected:
disableLogging() suppresses only the activity log trait, not
Eloquent model events.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
8.5 KiB
PHP
270 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Artist;
|
|
|
|
use App\Exceptions\Artist\VersionMismatchException;
|
|
use App\Models\Artist;
|
|
use App\Models\ArtistEngagement;
|
|
use App\Models\Event;
|
|
use App\Models\Organisation;
|
|
use App\Models\Performance;
|
|
use App\Models\Stage;
|
|
use App\Models\StageDay;
|
|
use App\Services\Artist\LaneCascadeService;
|
|
use Carbon\CarbonImmutable;
|
|
use Database\Seeders\RoleSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
use Tests\TestCase;
|
|
|
|
final class LaneCascadeServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private LaneCascadeService $service;
|
|
|
|
private Organisation $org;
|
|
|
|
private Event $event;
|
|
|
|
private Stage $stage;
|
|
|
|
private ArtistEngagement $engagement;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->seed(RoleSeeder::class);
|
|
|
|
$this->service = $this->app->make(LaneCascadeService::class);
|
|
$this->org = Organisation::factory()->create();
|
|
$this->event = Event::factory()->create([
|
|
'organisation_id' => $this->org->id,
|
|
'start_date' => CarbonImmutable::now()->subDay(),
|
|
'end_date' => CarbonImmutable::now()->addDays(30),
|
|
]);
|
|
$this->stage = Stage::factory()->create(['event_id' => $this->event->id]);
|
|
StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]);
|
|
|
|
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
|
|
$this->engagement = ArtistEngagement::factory()->create([
|
|
'artist_id' => $artist->id,
|
|
'event_id' => $this->event->id,
|
|
]);
|
|
}
|
|
|
|
public function test_simple_move_no_overlap_succeeds(): void
|
|
{
|
|
$perf = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => $this->stage->id,
|
|
'lane' => 0,
|
|
'version' => 0,
|
|
]);
|
|
|
|
$start = CarbonImmutable::now()->addDays(2)->setTime(20, 0);
|
|
$result = $this->service->move(
|
|
performance: $perf,
|
|
targetStage: $this->stage,
|
|
start: $start,
|
|
end: $start->addHour(),
|
|
targetLane: 0,
|
|
clientVersion: 0,
|
|
);
|
|
|
|
$this->assertSame([], $result->cascaded);
|
|
$this->assertGreaterThan(0, $result->moved->version);
|
|
}
|
|
|
|
public function test_overlap_cascades_existing_to_higher_lane(): void
|
|
{
|
|
$start = CarbonImmutable::now()->addDays(3)->setTime(22, 0);
|
|
|
|
$existing = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => $this->stage->id,
|
|
'lane' => 0,
|
|
'start_at' => $start,
|
|
'end_at' => $start->addHour(),
|
|
'version' => 0,
|
|
]);
|
|
|
|
$other = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => null, // parked
|
|
'lane' => 0,
|
|
'start_at' => $start,
|
|
'end_at' => $start->addHour(),
|
|
'version' => 0,
|
|
]);
|
|
|
|
$result = $this->service->move(
|
|
performance: $other,
|
|
targetStage: $this->stage,
|
|
start: $start->addMinutes(15),
|
|
end: $start->addMinutes(75),
|
|
targetLane: 0,
|
|
clientVersion: 0,
|
|
);
|
|
|
|
$this->assertCount(1, $result->cascaded);
|
|
$this->assertSame((string) $existing->id, (string) $result->cascaded[0]->id);
|
|
$this->assertSame(1, (int) $result->cascaded[0]->lane);
|
|
}
|
|
|
|
public function test_version_mismatch_throws(): void
|
|
{
|
|
$perf = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => $this->stage->id,
|
|
'lane' => 0,
|
|
'version' => 5,
|
|
]);
|
|
|
|
$this->expectException(VersionMismatchException::class);
|
|
|
|
$this->service->move(
|
|
performance: $perf,
|
|
targetStage: $this->stage,
|
|
start: CarbonImmutable::parse((string) $perf->start_at),
|
|
end: CarbonImmutable::parse((string) $perf->end_at),
|
|
targetLane: 0,
|
|
clientVersion: 4,
|
|
);
|
|
}
|
|
|
|
public function test_park_clears_stage_id(): void
|
|
{
|
|
$perf = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => $this->stage->id,
|
|
'lane' => 2,
|
|
'version' => 0,
|
|
]);
|
|
|
|
$result = $this->service->move(
|
|
performance: $perf,
|
|
targetStage: null,
|
|
start: null,
|
|
end: null,
|
|
targetLane: null,
|
|
clientVersion: 0,
|
|
);
|
|
|
|
$this->assertNull($result->moved->stage_id);
|
|
$this->assertSame([], $result->cascaded);
|
|
$this->assertSame(2, (int) $result->moved->lane);
|
|
}
|
|
|
|
public function test_unpark_to_stage_succeeds(): void
|
|
{
|
|
$perf = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => null,
|
|
'lane' => 0,
|
|
'version' => 0,
|
|
]);
|
|
|
|
$start = CarbonImmutable::now()->addDays(4)->setTime(21, 0);
|
|
$result = $this->service->move(
|
|
performance: $perf,
|
|
targetStage: $this->stage,
|
|
start: $start,
|
|
end: $start->addHour(),
|
|
targetLane: 0,
|
|
clientVersion: 0,
|
|
);
|
|
|
|
$this->assertSame((string) $this->stage->id, (string) $result->moved->stage_id);
|
|
}
|
|
|
|
public function test_move_with_cascade_writes_exactly_one_activity_entry_on_moved_subject_and_zero_on_peers(): void
|
|
{
|
|
$start = CarbonImmutable::now()->addDays(5)->setTime(20, 0);
|
|
|
|
$p2 = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => $this->stage->id,
|
|
'lane' => 0,
|
|
'start_at' => $start,
|
|
'end_at' => $start->addHour(),
|
|
'version' => 0,
|
|
]);
|
|
|
|
$p1 = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => $this->stage->id,
|
|
'lane' => 1,
|
|
'start_at' => $start,
|
|
'end_at' => $start->addHour(),
|
|
'version' => 0,
|
|
]);
|
|
|
|
// Clear setup-time activity rows so we measure only the move.
|
|
Activity::query()->delete();
|
|
|
|
$this->service->move(
|
|
performance: $p1,
|
|
targetStage: $this->stage,
|
|
start: $start,
|
|
end: $start->addHour(),
|
|
targetLane: 0,
|
|
clientVersion: 0,
|
|
);
|
|
|
|
$movedEntries = Activity::query()
|
|
->where('subject_type', $p1->getMorphClass())
|
|
->where('subject_id', $p1->id)
|
|
->get();
|
|
$this->assertCount(1, $movedEntries, 'Expected exactly one activity entry for moved performance');
|
|
$this->assertSame('moved', $movedEntries->first()->event);
|
|
$this->assertSame(1, $movedEntries->first()->properties['cascade_count']);
|
|
$this->assertContains((string) $p2->id, $movedEntries->first()->properties['cascaded_ids']);
|
|
|
|
$cascadedEntries = Activity::query()
|
|
->where('subject_type', $p2->getMorphClass())
|
|
->where('subject_id', $p2->id)
|
|
->get();
|
|
$this->assertCount(0, $cascadedEntries, 'Expected zero activity entries on cascade-bumped peer');
|
|
}
|
|
|
|
public function test_park_writes_single_parked_activity_entry(): void
|
|
{
|
|
$perf = Performance::factory()->create([
|
|
'engagement_id' => $this->engagement->id,
|
|
'event_id' => $this->event->id,
|
|
'stage_id' => $this->stage->id,
|
|
'lane' => 0,
|
|
'version' => 0,
|
|
]);
|
|
|
|
Activity::query()->delete();
|
|
|
|
$this->service->move(
|
|
performance: $perf,
|
|
targetStage: null,
|
|
start: null,
|
|
end: null,
|
|
targetLane: null,
|
|
clientVersion: 0,
|
|
);
|
|
|
|
$entries = Activity::query()
|
|
->where('subject_type', $perf->getMorphClass())
|
|
->where('subject_id', $perf->id)
|
|
->get();
|
|
$this->assertCount(1, $entries, 'Expected exactly one parked entry');
|
|
$this->assertSame('parked', $entries->first()->event);
|
|
}
|
|
}
|