|
@@ -0,0 +1,241 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Tests\Cascade;
|
|
|
|
|
+
|
|
|
|
|
+use App\Repositories\SprintRepository;
|
|
|
|
|
+use App\Repositories\SprintWeekRepository;
|
|
|
|
|
+use App\Repositories\SprintWorkerDayRepository;
|
|
|
|
|
+use App\Repositories\SprintWorkerRepository;
|
|
|
|
|
+use App\Repositories\TaskAssignmentRepository;
|
|
|
|
|
+use App\Repositories\TaskRepository;
|
|
|
|
|
+use App\Repositories\WorkerRepository;
|
|
|
|
|
+use App\Services\AuditLogger;
|
|
|
|
|
+use App\Tests\TestCase;
|
|
|
|
|
+use PDO;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * End-to-end tests for Phase 8: every FK cascade that used to silently
|
|
|
|
|
+ * lose audit rows now emits one DELETE row per cascaded child.
|
|
|
|
|
+ *
|
|
|
|
|
+ * We don't spin up the full Controller (that needs a Request + Session);
|
|
|
|
|
+ * instead we exercise the exact "snapshot children then delete parent"
|
|
|
|
|
+ * flow each controller method now follows. A regression in the controller
|
|
|
|
|
+ * would have to skip those snapshots to break this test.
|
|
|
|
|
+ */
|
|
|
|
|
+final class CascadeAuditTest extends TestCase
|
|
|
|
|
+{
|
|
|
|
|
+ // -------------------------------------------------------------------
|
|
|
|
|
+ // Helpers: seed a tiny fully-populated sprint.
|
|
|
|
|
+ // -------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return array{
|
|
|
|
|
+ * pdo: PDO,
|
|
|
|
|
+ * sprintId: int,
|
|
|
|
|
+ * swAliceId: int,
|
|
|
|
|
+ * swBobId: int,
|
|
|
|
|
+ * weekIds: list<int>,
|
|
|
|
|
+ * 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));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|