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