CascadeAuditTest.php 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Cascade;
  4. use App\Repositories\SprintRepository;
  5. use App\Repositories\SprintWeekRepository;
  6. use App\Repositories\SprintWorkerDayRepository;
  7. use App\Repositories\SprintWorkerRepository;
  8. use App\Repositories\TaskAssignmentRepository;
  9. use App\Repositories\TaskRepository;
  10. use App\Repositories\WorkerRepository;
  11. use App\Services\AuditLogger;
  12. use App\Tests\TestCase;
  13. use PDO;
  14. /**
  15. * End-to-end tests for Phase 8: every FK cascade that used to silently
  16. * lose audit rows now emits one DELETE row per cascaded child.
  17. *
  18. * We don't spin up the full Controller (that needs a Request + Session);
  19. * instead we exercise the exact "snapshot children then delete parent"
  20. * flow each controller method now follows. A regression in the controller
  21. * would have to skip those snapshots to break this test.
  22. */
  23. final class CascadeAuditTest extends TestCase
  24. {
  25. // -------------------------------------------------------------------
  26. // Helpers: seed a tiny fully-populated sprint.
  27. // -------------------------------------------------------------------
  28. /**
  29. * @return array{
  30. * pdo: PDO,
  31. * sprintId: int,
  32. * swAliceId: int,
  33. * swBobId: int,
  34. * weekIds: list<int>,
  35. * taskId: int,
  36. * audit: AuditLogger,
  37. * days: SprintWorkerDayRepository,
  38. * assignments: TaskAssignmentRepository,
  39. * sprintWorkers: SprintWorkerRepository,
  40. * weeks: SprintWeekRepository,
  41. * }
  42. */
  43. private function seed(): array
  44. {
  45. $pdo = $this->makeDb();
  46. $workers = new WorkerRepository($pdo);
  47. $sprints = new SprintRepository($pdo);
  48. $weeks = new SprintWeekRepository($pdo);
  49. $sw = new SprintWorkerRepository($pdo);
  50. $days = new SprintWorkerDayRepository($pdo);
  51. $tasks = new TaskRepository($pdo);
  52. $asg = new TaskAssignmentRepository($pdo);
  53. $audit = new AuditLogger($pdo);
  54. $wAlice = $workers->create('Alice', true, 0.0);
  55. $wBob = $workers->create('Bob', true, 0.0);
  56. $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2);
  57. $wks = $sprints->materializeWeeks($sprint->id, '2026-01-05', 4);
  58. $weekIds = array_map(fn($w) => (int) $w['id'], $wks);
  59. $swAlice = $sw->add($sprint->id, $wAlice->id, 0.0);
  60. $swBob = $sw->add($sprint->id, $wBob->id, 0.0);
  61. // Fill day cells for both workers across all 4 weeks.
  62. foreach ($weekIds as $weekId) {
  63. $days->upsert($swAlice->id, $weekId, 4.0);
  64. $days->upsert($swBob->id, $weekId, 3.0);
  65. }
  66. // A task + two assignments (one per sprint worker).
  67. $task = $tasks->create($sprint->id, 'T', null, 1);
  68. $asg->upsert($task->id, $swAlice->id, 2.0);
  69. $asg->upsert($task->id, $swBob->id, 1.5);
  70. return [
  71. 'pdo' => $pdo,
  72. 'sprintId' => $sprint->id,
  73. 'swAliceId' => $swAlice->id,
  74. 'swBobId' => $swBob->id,
  75. 'weekIds' => $weekIds,
  76. 'taskId' => $task->id,
  77. 'audit' => $audit,
  78. 'days' => $days,
  79. 'assignments' => $asg,
  80. 'sprintWorkers' => $sw,
  81. 'weeks' => $weeks,
  82. ];
  83. }
  84. // -------------------------------------------------------------------
  85. // Path 1: removing a sprint_worker cascades to sprint_worker_days
  86. // -------------------------------------------------------------------
  87. public function testRemovingSprintWorkerAuditsEveryCascadedDay(): void
  88. {
  89. $s = $this->seed();
  90. // Simulate SprintController::removeWorker for Alice.
  91. $cascadedDays = $s['days']->allForSprintWorker($s['swAliceId']);
  92. $cascadedAsgs = $s['assignments']->allForSprintWorker($s['swAliceId']);
  93. $this->assertCount(4, $cascadedDays, 'Alice has one cell per week');
  94. $this->assertCount(1, $cascadedAsgs, 'Alice has one assignment');
  95. $s['pdo']->beginTransaction();
  96. foreach ($cascadedDays as $d) {
  97. $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null);
  98. }
  99. foreach ($cascadedAsgs as $a) {
  100. $s['audit']->record('DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null);
  101. }
  102. $removed = $s['sprintWorkers']->remove($s['swAliceId']);
  103. $s['audit']->record('DELETE', 'sprint_worker', $removed->id, $removed->toAuditSnapshot(), null);
  104. $s['pdo']->commit();
  105. // FK cascade should have wiped the child tables for Alice.
  106. $this->assertSame(0, (int) $s['pdo']->query(
  107. "SELECT COUNT(*) FROM sprint_worker_days WHERE sprint_worker_id = {$s['swAliceId']}"
  108. )->fetchColumn());
  109. $this->assertSame(0, (int) $s['pdo']->query(
  110. "SELECT COUNT(*) FROM task_assignments WHERE sprint_worker_id = {$s['swAliceId']}"
  111. )->fetchColumn());
  112. // Audit counts.
  113. $dayAuditCount = (int) $s['pdo']->query(
  114. "SELECT COUNT(*) FROM audit_log
  115. WHERE action = 'DELETE' AND entity_type = 'sprint_worker_days'"
  116. )->fetchColumn();
  117. $this->assertSame(4, $dayAuditCount, 'one DELETE audit per cell that cascaded');
  118. $asgAuditCount = (int) $s['pdo']->query(
  119. "SELECT COUNT(*) FROM audit_log
  120. WHERE action = 'DELETE' AND entity_type = 'task_assignment'"
  121. )->fetchColumn();
  122. $this->assertSame(1, $asgAuditCount);
  123. $swAuditCount = (int) $s['pdo']->query(
  124. "SELECT COUNT(*) FROM audit_log
  125. WHERE action = 'DELETE' AND entity_type = 'sprint_worker'"
  126. )->fetchColumn();
  127. $this->assertSame(1, $swAuditCount);
  128. // Bob is untouched.
  129. $this->assertCount(4, $s['days']->allForSprintWorker($s['swBobId']));
  130. }
  131. // -------------------------------------------------------------------
  132. // Path 2: shrinking sprint_weeks cascades to sprint_worker_days
  133. // -------------------------------------------------------------------
  134. public function testShrinkingWeeksAuditsCascadedDaysInDroppedWeeks(): void
  135. {
  136. $s = $this->seed();
  137. // Simulate SprintController::replaceWeeks shrinking 4 → 2.
  138. $targetCount = 2;
  139. $existing = $s['weeks']->allForSprint($s['sprintId']);
  140. $toRemove = array_slice($existing, $targetCount);
  141. $this->assertCount(2, $toRemove);
  142. $cascadedDays = [];
  143. foreach ($toRemove as $w) {
  144. foreach ($s['days']->allForSprintWeek($w->id) as $d) {
  145. $cascadedDays[] = $d;
  146. }
  147. }
  148. // Two dropped weeks × 2 workers = 4 cells that will cascade.
  149. $this->assertCount(4, $cascadedDays);
  150. $s['pdo']->beginTransaction();
  151. foreach ($cascadedDays as $d) {
  152. $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null);
  153. }
  154. $diff = $s['weeks']->syncCount($s['sprintId'], '2026-01-05', $targetCount);
  155. foreach ($diff['removed'] as $w) {
  156. $s['audit']->record('DELETE', 'sprint_week', $w->id, $w->toAuditSnapshot(), null);
  157. }
  158. $s['pdo']->commit();
  159. // Weeks 3 and 4 are gone; their day cells are gone too.
  160. $remainingWeeks = $s['weeks']->allForSprint($s['sprintId']);
  161. $this->assertCount(2, $remainingWeeks);
  162. foreach ($remainingWeeks as $w) {
  163. // Each remaining week still has 2 cells (Alice + Bob).
  164. $this->assertCount(2, $s['days']->allForSprintWeek($w->id));
  165. }
  166. // Dropped week IDs have zero cells.
  167. foreach ($toRemove as $w) {
  168. $this->assertCount(0, $s['days']->allForSprintWeek($w->id));
  169. }
  170. $dayAudits = (int) $s['pdo']->query(
  171. "SELECT COUNT(*) FROM audit_log
  172. WHERE action = 'DELETE' AND entity_type = 'sprint_worker_days'"
  173. )->fetchColumn();
  174. $this->assertSame(4, $dayAudits, 'audits every cell in the dropped weeks');
  175. $weekAudits = (int) $s['pdo']->query(
  176. "SELECT COUNT(*) FROM audit_log
  177. WHERE action = 'DELETE' AND entity_type = 'sprint_week'"
  178. )->fetchColumn();
  179. $this->assertSame(2, $weekAudits);
  180. }
  181. // -------------------------------------------------------------------
  182. // Repo-level lookups used by the controller
  183. // -------------------------------------------------------------------
  184. public function testSprintWorkerDayRepoByParentLookups(): void
  185. {
  186. $s = $this->seed();
  187. $this->assertCount(4, $s['days']->allForSprintWorker($s['swAliceId']));
  188. $this->assertCount(4, $s['days']->allForSprintWorker($s['swBobId']));
  189. foreach ($s['weekIds'] as $weekId) {
  190. // Each week has two cells (Alice + Bob).
  191. $this->assertCount(2, $s['days']->allForSprintWeek($weekId));
  192. }
  193. // Unknown parent returns empty, not null.
  194. $this->assertSame([], $s['days']->allForSprintWorker(999_999));
  195. $this->assertSame([], $s['days']->allForSprintWeek(999_999));
  196. }
  197. public function testTaskAssignmentRepoByParentLookup(): void
  198. {
  199. $s = $this->seed();
  200. $this->assertCount(1, $s['assignments']->allForSprintWorker($s['swAliceId']));
  201. $this->assertCount(1, $s['assignments']->allForSprintWorker($s['swBobId']));
  202. $this->assertSame([], $s['assignments']->allForSprintWorker(999_999));
  203. }
  204. }