, * 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)); } // ------------------------------------------------------------------- // Path 4 (Phase 22.1): deleting a sprint cascades through the entire // child tree — every leaf gets a DELETE audit before the parent goes. // ------------------------------------------------------------------- public function testDeletingSprintAuditsEntireCascade(): void { $s = $this->seed(); /** @var SprintRepository $sprints */ $sprints = new SprintRepository($s['pdo']); // Mirror SprintController::delete: snapshot every leaf, then audit // each one, then drop the sprint and audit the parent. $sprintWorkers = $s['sprintWorkers']->allForSprint($s['sprintId']); $cascadedDays = []; $cascadedAsgs = []; foreach ($sprintWorkers as $sw) { foreach ($s['days']->allForSprintWorker($sw->id) as $d) { $cascadedDays[] = $d; } foreach ($s['assignments']->allForSprintWorker($sw->id) as $a) { $cascadedAsgs[] = $a; } } $tasksRepo = new TaskRepository($s['pdo']); $tasks = $tasksRepo->allForSprint($s['sprintId']); $weeks = $s['weeks']->allForSprint($s['sprintId']); $this->assertCount(2, $sprintWorkers); $this->assertCount(8, $cascadedDays, 'Alice + Bob × 4 weeks'); $this->assertCount(2, $cascadedAsgs, 'one assignment per worker'); $this->assertCount(1, $tasks); $this->assertCount(4, $weeks); $s['pdo']->beginTransaction(); foreach ($cascadedAsgs as $a) { $s['audit']->record('DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null); } foreach ($cascadedDays as $d) { $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null); } foreach ($tasks as $t) { $s['audit']->record('DELETE', 'task', $t->id, $t->toAuditSnapshot(), null); } foreach ($sprintWorkers as $sw) { $s['audit']->record('DELETE', 'sprint_worker', $sw->id, $sw->toAuditSnapshot(), null); } foreach ($weeks as $w) { $s['audit']->record('DELETE', 'sprint_week', $w->id, $w->toAuditSnapshot(), null); } $deleted = $sprints->delete($s['sprintId']); $this->assertNotNull($deleted); $s['audit']->record('DELETE', 'sprint', $deleted->id, $deleted->toAuditSnapshot(), null); $s['pdo']->commit(); // Sprint and every cascaded child are gone. $this->assertNull($sprints->find($s['sprintId'])); $this->assertCount(0, $s['weeks']->allForSprint($s['sprintId'])); $this->assertCount(0, $s['sprintWorkers']->allForSprint($s['sprintId'])); $this->assertCount(0, $tasksRepo->allForSprint($s['sprintId'])); $this->assertSame([], $s['days']->allForSprintWorker($s['swAliceId'])); $this->assertSame([], $s['assignments']->allForSprintWorker($s['swAliceId'])); // Audit count per entity_type matches the cascade size. $countByType = []; $stmt = $s['pdo']->query( "SELECT entity_type, COUNT(*) AS n FROM audit_log WHERE action = 'DELETE' GROUP BY entity_type" ); foreach ($stmt as $row) { $countByType[(string) $row['entity_type']] = (int) $row['n']; } $this->assertSame(2, $countByType['task_assignment'] ?? 0); $this->assertSame(8, $countByType['sprint_worker_days'] ?? 0); $this->assertSame(1, $countByType['task'] ?? 0); $this->assertSame(2, $countByType['sprint_worker'] ?? 0); $this->assertSame(4, $countByType['sprint_week'] ?? 0); $this->assertSame(1, $countByType['sprint'] ?? 0); } }