Files
crewli/api/tests/Feature/Artist/LaneCascadeServiceTest.php
bert.hausmans bc7d3fcbee fix(timetable): single activity entry per cascade-move per RFC §8
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>
2026-05-08 21:29:49 +02:00

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