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>
This commit is contained in:
2026-05-08 21:29:49 +02:00
parent bdb379f55f
commit bc7d3fcbee
2 changed files with 101 additions and 5 deletions

View File

@@ -16,6 +16,7 @@ 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
@@ -184,4 +185,85 @@ final class LaneCascadeServiceTest extends TestCase
$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);
}
}