CascadeAuditTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Tests\Cascade;
  12. use App\Repositories\SprintRepository;
  13. use App\Repositories\SprintWeekRepository;
  14. use App\Repositories\SprintWorkerDayRepository;
  15. use App\Repositories\SprintWorkerRepository;
  16. use App\Repositories\TaskAssignmentRepository;
  17. use App\Repositories\TaskRepository;
  18. use App\Repositories\WorkerRepository;
  19. use App\Services\AuditLogger;
  20. use App\Tests\TestCase;
  21. use PDO;
  22. /**
  23. * End-to-end tests for Phase 8: every FK cascade that used to silently
  24. * lose audit rows now emits one DELETE row per cascaded child.
  25. *
  26. * We don't spin up the full Controller (that needs a Request + Session);
  27. * instead we exercise the exact "snapshot children then delete parent"
  28. * flow each controller method now follows. A regression in the controller
  29. * would have to skip those snapshots to break this test.
  30. */
  31. final class CascadeAuditTest extends TestCase
  32. {
  33. // -------------------------------------------------------------------
  34. // Helpers: seed a tiny fully-populated sprint.
  35. // -------------------------------------------------------------------
  36. /**
  37. * @return array{
  38. * pdo: PDO,
  39. * sprintId: int,
  40. * swAliceId: int,
  41. * swBobId: int,
  42. * weekIds: list<int>,
  43. * taskId: int,
  44. * audit: AuditLogger,
  45. * days: SprintWorkerDayRepository,
  46. * assignments: TaskAssignmentRepository,
  47. * sprintWorkers: SprintWorkerRepository,
  48. * weeks: SprintWeekRepository,
  49. * }
  50. */
  51. private function seed(): array
  52. {
  53. $pdo = $this->makeDb();
  54. $workers = new WorkerRepository($pdo);
  55. $sprints = new SprintRepository($pdo);
  56. $weeks = new SprintWeekRepository($pdo);
  57. $sw = new SprintWorkerRepository($pdo);
  58. $days = new SprintWorkerDayRepository($pdo);
  59. $tasks = new TaskRepository($pdo);
  60. $asg = new TaskAssignmentRepository($pdo);
  61. $audit = new AuditLogger($pdo);
  62. $wAlice = $workers->create('Alice', true, 0.0);
  63. $wBob = $workers->create('Bob', true, 0.0);
  64. $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2);
  65. $wks = $sprints->materializeWeeks($sprint->id, '2026-01-05', '2026-01-30', 4);
  66. $weekIds = array_map(fn($w) => (int) $w['id'], $wks);
  67. $swAlice = $sw->add($sprint->id, $wAlice->id, 0.0);
  68. $swBob = $sw->add($sprint->id, $wBob->id, 0.0);
  69. // Fill day cells for both workers across all 4 weeks.
  70. foreach ($weekIds as $weekId) {
  71. $days->upsert($swAlice->id, $weekId, 4.0);
  72. $days->upsert($swBob->id, $weekId, 3.0);
  73. }
  74. // A task + two assignments (one per sprint worker).
  75. $task = $tasks->create($sprint->id, 'T', null, 1);
  76. $asg->upsert($task->id, $swAlice->id, 2.0);
  77. $asg->upsert($task->id, $swBob->id, 1.5);
  78. return [
  79. 'pdo' => $pdo,
  80. 'sprintId' => $sprint->id,
  81. 'swAliceId' => $swAlice->id,
  82. 'swBobId' => $swBob->id,
  83. 'weekIds' => $weekIds,
  84. 'taskId' => $task->id,
  85. 'audit' => $audit,
  86. 'days' => $days,
  87. 'assignments' => $asg,
  88. 'sprintWorkers' => $sw,
  89. 'weeks' => $weeks,
  90. ];
  91. }
  92. // -------------------------------------------------------------------
  93. // Path 1: removing a sprint_worker cascades to sprint_worker_days
  94. // -------------------------------------------------------------------
  95. public function testRemovingSprintWorkerAuditsEveryCascadedDay(): void
  96. {
  97. $s = $this->seed();
  98. // Simulate SprintController::removeWorker for Alice.
  99. $cascadedDays = $s['days']->allForSprintWorker($s['swAliceId']);
  100. $cascadedAsgs = $s['assignments']->allForSprintWorker($s['swAliceId']);
  101. $this->assertCount(4, $cascadedDays, 'Alice has one cell per week');
  102. $this->assertCount(1, $cascadedAsgs, 'Alice has one assignment');
  103. $s['pdo']->beginTransaction();
  104. foreach ($cascadedDays as $d) {
  105. $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null);
  106. }
  107. foreach ($cascadedAsgs as $a) {
  108. $s['audit']->record('DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null);
  109. }
  110. $removed = $s['sprintWorkers']->remove($s['swAliceId']);
  111. $s['audit']->record('DELETE', 'sprint_worker', $removed->id, $removed->toAuditSnapshot(), null);
  112. $s['pdo']->commit();
  113. // FK cascade should have wiped the child tables for Alice.
  114. $this->assertSame(0, (int) $s['pdo']->query(
  115. "SELECT COUNT(*) FROM sprint_worker_days WHERE sprint_worker_id = {$s['swAliceId']}"
  116. )->fetchColumn());
  117. $this->assertSame(0, (int) $s['pdo']->query(
  118. "SELECT COUNT(*) FROM task_assignments WHERE sprint_worker_id = {$s['swAliceId']}"
  119. )->fetchColumn());
  120. // Audit counts.
  121. $dayAuditCount = (int) $s['pdo']->query(
  122. "SELECT COUNT(*) FROM audit_log
  123. WHERE action = 'DELETE' AND entity_type = 'sprint_worker_days'"
  124. )->fetchColumn();
  125. $this->assertSame(4, $dayAuditCount, 'one DELETE audit per cell that cascaded');
  126. $asgAuditCount = (int) $s['pdo']->query(
  127. "SELECT COUNT(*) FROM audit_log
  128. WHERE action = 'DELETE' AND entity_type = 'task_assignment'"
  129. )->fetchColumn();
  130. $this->assertSame(1, $asgAuditCount);
  131. $swAuditCount = (int) $s['pdo']->query(
  132. "SELECT COUNT(*) FROM audit_log
  133. WHERE action = 'DELETE' AND entity_type = 'sprint_worker'"
  134. )->fetchColumn();
  135. $this->assertSame(1, $swAuditCount);
  136. // Bob is untouched.
  137. $this->assertCount(4, $s['days']->allForSprintWorker($s['swBobId']));
  138. }
  139. // -------------------------------------------------------------------
  140. // Path 2: shrinking sprint_weeks cascades to sprint_worker_days
  141. // -------------------------------------------------------------------
  142. public function testShrinkingWeeksAuditsCascadedDaysInDroppedWeeks(): void
  143. {
  144. $s = $this->seed();
  145. // Simulate SprintController::replaceWeeks shrinking 4 → 2.
  146. $targetCount = 2;
  147. $existing = $s['weeks']->allForSprint($s['sprintId']);
  148. $toRemove = array_slice($existing, $targetCount);
  149. $this->assertCount(2, $toRemove);
  150. $cascadedDays = [];
  151. foreach ($toRemove as $w) {
  152. foreach ($s['days']->allForSprintWeek($w->id) as $d) {
  153. $cascadedDays[] = $d;
  154. }
  155. }
  156. // Two dropped weeks × 2 workers = 4 cells that will cascade.
  157. $this->assertCount(4, $cascadedDays);
  158. $s['pdo']->beginTransaction();
  159. foreach ($cascadedDays as $d) {
  160. $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null);
  161. }
  162. $diff = $s['weeks']->syncCount($s['sprintId'], '2026-01-05', $targetCount);
  163. foreach ($diff['removed'] as $w) {
  164. $s['audit']->record('DELETE', 'sprint_week', $w->id, $w->toAuditSnapshot(), null);
  165. }
  166. $s['pdo']->commit();
  167. // Weeks 3 and 4 are gone; their day cells are gone too.
  168. $remainingWeeks = $s['weeks']->allForSprint($s['sprintId']);
  169. $this->assertCount(2, $remainingWeeks);
  170. foreach ($remainingWeeks as $w) {
  171. // Each remaining week still has 2 cells (Alice + Bob).
  172. $this->assertCount(2, $s['days']->allForSprintWeek($w->id));
  173. }
  174. // Dropped week IDs have zero cells.
  175. foreach ($toRemove as $w) {
  176. $this->assertCount(0, $s['days']->allForSprintWeek($w->id));
  177. }
  178. $dayAudits = (int) $s['pdo']->query(
  179. "SELECT COUNT(*) FROM audit_log
  180. WHERE action = 'DELETE' AND entity_type = 'sprint_worker_days'"
  181. )->fetchColumn();
  182. $this->assertSame(4, $dayAudits, 'audits every cell in the dropped weeks');
  183. $weekAudits = (int) $s['pdo']->query(
  184. "SELECT COUNT(*) FROM audit_log
  185. WHERE action = 'DELETE' AND entity_type = 'sprint_week'"
  186. )->fetchColumn();
  187. $this->assertSame(2, $weekAudits);
  188. }
  189. // -------------------------------------------------------------------
  190. // Repo-level lookups used by the controller
  191. // -------------------------------------------------------------------
  192. public function testSprintWorkerDayRepoByParentLookups(): void
  193. {
  194. $s = $this->seed();
  195. $this->assertCount(4, $s['days']->allForSprintWorker($s['swAliceId']));
  196. $this->assertCount(4, $s['days']->allForSprintWorker($s['swBobId']));
  197. foreach ($s['weekIds'] as $weekId) {
  198. // Each week has two cells (Alice + Bob).
  199. $this->assertCount(2, $s['days']->allForSprintWeek($weekId));
  200. }
  201. // Unknown parent returns empty, not null.
  202. $this->assertSame([], $s['days']->allForSprintWorker(999_999));
  203. $this->assertSame([], $s['days']->allForSprintWeek(999_999));
  204. }
  205. public function testTaskAssignmentRepoByParentLookup(): void
  206. {
  207. $s = $this->seed();
  208. $this->assertCount(1, $s['assignments']->allForSprintWorker($s['swAliceId']));
  209. $this->assertCount(1, $s['assignments']->allForSprintWorker($s['swBobId']));
  210. $this->assertSame([], $s['assignments']->allForSprintWorker(999_999));
  211. }
  212. // -------------------------------------------------------------------
  213. // Path 4 (Phase 22.1): deleting a sprint cascades through the entire
  214. // child tree — every leaf gets a DELETE audit before the parent goes.
  215. // -------------------------------------------------------------------
  216. public function testDeletingSprintAuditsEntireCascade(): void
  217. {
  218. $s = $this->seed();
  219. /** @var SprintRepository $sprints */
  220. $sprints = new SprintRepository($s['pdo']);
  221. // Mirror SprintController::delete: snapshot every leaf, then audit
  222. // each one, then drop the sprint and audit the parent.
  223. $sprintWorkers = $s['sprintWorkers']->allForSprint($s['sprintId']);
  224. $cascadedDays = [];
  225. $cascadedAsgs = [];
  226. foreach ($sprintWorkers as $sw) {
  227. foreach ($s['days']->allForSprintWorker($sw->id) as $d) {
  228. $cascadedDays[] = $d;
  229. }
  230. foreach ($s['assignments']->allForSprintWorker($sw->id) as $a) {
  231. $cascadedAsgs[] = $a;
  232. }
  233. }
  234. $tasksRepo = new TaskRepository($s['pdo']);
  235. $tasks = $tasksRepo->allForSprint($s['sprintId']);
  236. $weeks = $s['weeks']->allForSprint($s['sprintId']);
  237. $this->assertCount(2, $sprintWorkers);
  238. $this->assertCount(8, $cascadedDays, 'Alice + Bob × 4 weeks');
  239. $this->assertCount(2, $cascadedAsgs, 'one assignment per worker');
  240. $this->assertCount(1, $tasks);
  241. $this->assertCount(4, $weeks);
  242. $s['pdo']->beginTransaction();
  243. foreach ($cascadedAsgs as $a) {
  244. $s['audit']->record('DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null);
  245. }
  246. foreach ($cascadedDays as $d) {
  247. $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null);
  248. }
  249. foreach ($tasks as $t) {
  250. $s['audit']->record('DELETE', 'task', $t->id, $t->toAuditSnapshot(), null);
  251. }
  252. foreach ($sprintWorkers as $sw) {
  253. $s['audit']->record('DELETE', 'sprint_worker', $sw->id, $sw->toAuditSnapshot(), null);
  254. }
  255. foreach ($weeks as $w) {
  256. $s['audit']->record('DELETE', 'sprint_week', $w->id, $w->toAuditSnapshot(), null);
  257. }
  258. $deleted = $sprints->delete($s['sprintId']);
  259. $this->assertNotNull($deleted);
  260. $s['audit']->record('DELETE', 'sprint', $deleted->id, $deleted->toAuditSnapshot(), null);
  261. $s['pdo']->commit();
  262. // Sprint and every cascaded child are gone.
  263. $this->assertNull($sprints->find($s['sprintId']));
  264. $this->assertCount(0, $s['weeks']->allForSprint($s['sprintId']));
  265. $this->assertCount(0, $s['sprintWorkers']->allForSprint($s['sprintId']));
  266. $this->assertCount(0, $tasksRepo->allForSprint($s['sprintId']));
  267. $this->assertSame([], $s['days']->allForSprintWorker($s['swAliceId']));
  268. $this->assertSame([], $s['assignments']->allForSprintWorker($s['swAliceId']));
  269. // Audit count per entity_type matches the cascade size.
  270. $countByType = [];
  271. $stmt = $s['pdo']->query(
  272. "SELECT entity_type, COUNT(*) AS n
  273. FROM audit_log
  274. WHERE action = 'DELETE'
  275. GROUP BY entity_type"
  276. );
  277. foreach ($stmt as $row) {
  278. $countByType[(string) $row['entity_type']] = (int) $row['n'];
  279. }
  280. $this->assertSame(2, $countByType['task_assignment'] ?? 0);
  281. $this->assertSame(8, $countByType['sprint_worker_days'] ?? 0);
  282. $this->assertSame(1, $countByType['task'] ?? 0);
  283. $this->assertSame(2, $countByType['sprint_worker'] ?? 0);
  284. $this->assertSame(4, $countByType['sprint_week'] ?? 0);
  285. $this->assertSame(1, $countByType['sprint'] ?? 0);
  286. }
  287. }