, * taskId: int, * audit: AuditLogger, * days: SprintWorkerDayRepository, * assignments: TaskAssignmentRepository, * sprintWorkers: SprintWorkerRepository, * weeks: SprintWeekRepository, * } */ private function seed(): array { $pdo = $this->makeDb(); $workers = new WorkerRepository($pdo); $sprints = new SprintRepository($pdo); $weeks = new SprintWeekRepository($pdo); $sw = new SprintWorkerRepository($pdo); $days = new SprintWorkerDayRepository($pdo); $tasks = new TaskRepository($pdo); $asg = new TaskAssignmentRepository($pdo); $audit = new AuditLogger($pdo); $wAlice = $workers->create('Alice', true, 0.0); $wBob = $workers->create('Bob', true, 0.0); $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2); $wks = $sprints->materializeWeeks($sprint->id, '2026-01-05', 4); $weekIds = array_map(fn($w) => (int) $w['id'], $wks); $swAlice = $sw->add($sprint->id, $wAlice->id, 0.0); $swBob = $sw->add($sprint->id, $wBob->id, 0.0); // Fill day cells for both workers across all 4 weeks. foreach ($weekIds as $weekId) { $days->upsert($swAlice->id, $weekId, 4.0); $days->upsert($swBob->id, $weekId, 3.0); } // A task + two assignments (one per sprint worker). $task = $tasks->create($sprint->id, 'T', null, 1); $asg->upsert($task->id, $swAlice->id, 2.0); $asg->upsert($task->id, $swBob->id, 1.5); return [ 'pdo' => $pdo, 'sprintId' => $sprint->id, 'swAliceId' => $swAlice->id, 'swBobId' => $swBob->id, 'weekIds' => $weekIds, 'taskId' => $task->id, 'audit' => $audit, 'days' => $days, 'assignments' => $asg, 'sprintWorkers' => $sw, 'weeks' => $weeks, ]; } // ------------------------------------------------------------------- // Path 1: removing a sprint_worker cascades to sprint_worker_days // ------------------------------------------------------------------- public function testRemovingSprintWorkerAuditsEveryCascadedDay(): void { $s = $this->seed(); // Simulate SprintController::removeWorker for Alice. $cascadedDays = $s['days']->allForSprintWorker($s['swAliceId']); $cascadedAsgs = $s['assignments']->allForSprintWorker($s['swAliceId']); $this->assertCount(4, $cascadedDays, 'Alice has one cell per week'); $this->assertCount(1, $cascadedAsgs, 'Alice has one assignment'); $s['pdo']->beginTransaction(); foreach ($cascadedDays as $d) { $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null); } foreach ($cascadedAsgs as $a) { $s['audit']->record('DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null); } $removed = $s['sprintWorkers']->remove($s['swAliceId']); $s['audit']->record('DELETE', 'sprint_worker', $removed->id, $removed->toAuditSnapshot(), null); $s['pdo']->commit(); // FK cascade should have wiped the child tables for Alice. $this->assertSame(0, (int) $s['pdo']->query( "SELECT COUNT(*) FROM sprint_worker_days WHERE sprint_worker_id = {$s['swAliceId']}" )->fetchColumn()); $this->assertSame(0, (int) $s['pdo']->query( "SELECT COUNT(*) FROM task_assignments WHERE sprint_worker_id = {$s['swAliceId']}" )->fetchColumn()); // Audit counts. $dayAuditCount = (int) $s['pdo']->query( "SELECT COUNT(*) FROM audit_log WHERE action = 'DELETE' AND entity_type = 'sprint_worker_days'" )->fetchColumn(); $this->assertSame(4, $dayAuditCount, 'one DELETE audit per cell that cascaded'); $asgAuditCount = (int) $s['pdo']->query( "SELECT COUNT(*) FROM audit_log WHERE action = 'DELETE' AND entity_type = 'task_assignment'" )->fetchColumn(); $this->assertSame(1, $asgAuditCount); $swAuditCount = (int) $s['pdo']->query( "SELECT COUNT(*) FROM audit_log WHERE action = 'DELETE' AND entity_type = 'sprint_worker'" )->fetchColumn(); $this->assertSame(1, $swAuditCount); // Bob is untouched. $this->assertCount(4, $s['days']->allForSprintWorker($s['swBobId'])); } // ------------------------------------------------------------------- // Path 2: shrinking sprint_weeks cascades to sprint_worker_days // ------------------------------------------------------------------- public function testShrinkingWeeksAuditsCascadedDaysInDroppedWeeks(): void { $s = $this->seed(); // Simulate SprintController::replaceWeeks shrinking 4 → 2. $targetCount = 2; $existing = $s['weeks']->allForSprint($s['sprintId']); $toRemove = array_slice($existing, $targetCount); $this->assertCount(2, $toRemove); $cascadedDays = []; foreach ($toRemove as $w) { foreach ($s['days']->allForSprintWeek($w->id) as $d) { $cascadedDays[] = $d; } } // Two dropped weeks × 2 workers = 4 cells that will cascade. $this->assertCount(4, $cascadedDays); $s['pdo']->beginTransaction(); foreach ($cascadedDays as $d) { $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null); } $diff = $s['weeks']->syncCount($s['sprintId'], '2026-01-05', $targetCount); foreach ($diff['removed'] as $w) { $s['audit']->record('DELETE', 'sprint_week', $w->id, $w->toAuditSnapshot(), null); } $s['pdo']->commit(); // Weeks 3 and 4 are gone; their day cells are gone too. $remainingWeeks = $s['weeks']->allForSprint($s['sprintId']); $this->assertCount(2, $remainingWeeks); foreach ($remainingWeeks as $w) { // Each remaining week still has 2 cells (Alice + Bob). $this->assertCount(2, $s['days']->allForSprintWeek($w->id)); } // Dropped week IDs have zero cells. foreach ($toRemove as $w) { $this->assertCount(0, $s['days']->allForSprintWeek($w->id)); } $dayAudits = (int) $s['pdo']->query( "SELECT COUNT(*) FROM audit_log WHERE action = 'DELETE' AND entity_type = 'sprint_worker_days'" )->fetchColumn(); $this->assertSame(4, $dayAudits, 'audits every cell in the dropped weeks'); $weekAudits = (int) $s['pdo']->query( "SELECT COUNT(*) FROM audit_log WHERE action = 'DELETE' AND entity_type = 'sprint_week'" )->fetchColumn(); $this->assertSame(2, $weekAudits); } // ------------------------------------------------------------------- // Repo-level lookups used by the controller // ------------------------------------------------------------------- public function testSprintWorkerDayRepoByParentLookups(): void { $s = $this->seed(); $this->assertCount(4, $s['days']->allForSprintWorker($s['swAliceId'])); $this->assertCount(4, $s['days']->allForSprintWorker($s['swBobId'])); foreach ($s['weekIds'] as $weekId) { // Each week has two cells (Alice + Bob). $this->assertCount(2, $s['days']->allForSprintWeek($weekId)); } // Unknown parent returns empty, not null. $this->assertSame([], $s['days']->allForSprintWorker(999_999)); $this->assertSame([], $s['days']->allForSprintWeek(999_999)); } public function testTaskAssignmentRepoByParentLookup(): void { $s = $this->seed(); $this->assertCount(1, $s['assignments']->allForSprintWorker($s['swAliceId'])); $this->assertCount(1, $s['assignments']->allForSprintWorker($s['swBobId'])); $this->assertSame([], $s['assignments']->allForSprintWorker(999_999)); } }