1
0

CascadeAuditTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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. // -------------------------------------------------------------------
  205. // Path 4 (Phase 22.1): deleting a sprint cascades through the entire
  206. // child tree — every leaf gets a DELETE audit before the parent goes.
  207. // -------------------------------------------------------------------
  208. public function testDeletingSprintAuditsEntireCascade(): void
  209. {
  210. $s = $this->seed();
  211. /** @var SprintRepository $sprints */
  212. $sprints = new SprintRepository($s['pdo']);
  213. // Mirror SprintController::delete: snapshot every leaf, then audit
  214. // each one, then drop the sprint and audit the parent.
  215. $sprintWorkers = $s['sprintWorkers']->allForSprint($s['sprintId']);
  216. $cascadedDays = [];
  217. $cascadedAsgs = [];
  218. foreach ($sprintWorkers as $sw) {
  219. foreach ($s['days']->allForSprintWorker($sw->id) as $d) {
  220. $cascadedDays[] = $d;
  221. }
  222. foreach ($s['assignments']->allForSprintWorker($sw->id) as $a) {
  223. $cascadedAsgs[] = $a;
  224. }
  225. }
  226. $tasksRepo = new TaskRepository($s['pdo']);
  227. $tasks = $tasksRepo->allForSprint($s['sprintId']);
  228. $weeks = $s['weeks']->allForSprint($s['sprintId']);
  229. $this->assertCount(2, $sprintWorkers);
  230. $this->assertCount(8, $cascadedDays, 'Alice + Bob × 4 weeks');
  231. $this->assertCount(2, $cascadedAsgs, 'one assignment per worker');
  232. $this->assertCount(1, $tasks);
  233. $this->assertCount(4, $weeks);
  234. $s['pdo']->beginTransaction();
  235. foreach ($cascadedAsgs as $a) {
  236. $s['audit']->record('DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null);
  237. }
  238. foreach ($cascadedDays as $d) {
  239. $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null);
  240. }
  241. foreach ($tasks as $t) {
  242. $s['audit']->record('DELETE', 'task', $t->id, $t->toAuditSnapshot(), null);
  243. }
  244. foreach ($sprintWorkers as $sw) {
  245. $s['audit']->record('DELETE', 'sprint_worker', $sw->id, $sw->toAuditSnapshot(), null);
  246. }
  247. foreach ($weeks as $w) {
  248. $s['audit']->record('DELETE', 'sprint_week', $w->id, $w->toAuditSnapshot(), null);
  249. }
  250. $deleted = $sprints->delete($s['sprintId']);
  251. $this->assertNotNull($deleted);
  252. $s['audit']->record('DELETE', 'sprint', $deleted->id, $deleted->toAuditSnapshot(), null);
  253. $s['pdo']->commit();
  254. // Sprint and every cascaded child are gone.
  255. $this->assertNull($sprints->find($s['sprintId']));
  256. $this->assertCount(0, $s['weeks']->allForSprint($s['sprintId']));
  257. $this->assertCount(0, $s['sprintWorkers']->allForSprint($s['sprintId']));
  258. $this->assertCount(0, $tasksRepo->allForSprint($s['sprintId']));
  259. $this->assertSame([], $s['days']->allForSprintWorker($s['swAliceId']));
  260. $this->assertSame([], $s['assignments']->allForSprintWorker($s['swAliceId']));
  261. // Audit count per entity_type matches the cascade size.
  262. $countByType = [];
  263. $stmt = $s['pdo']->query(
  264. "SELECT entity_type, COUNT(*) AS n
  265. FROM audit_log
  266. WHERE action = 'DELETE'
  267. GROUP BY entity_type"
  268. );
  269. foreach ($stmt as $row) {
  270. $countByType[(string) $row['entity_type']] = (int) $row['n'];
  271. }
  272. $this->assertSame(2, $countByType['task_assignment'] ?? 0);
  273. $this->assertSame(8, $countByType['sprint_worker_days'] ?? 0);
  274. $this->assertSame(1, $countByType['task'] ?? 0);
  275. $this->assertSame(2, $countByType['sprint_worker'] ?? 0);
  276. $this->assertSame(4, $countByType['sprint_week'] ?? 0);
  277. $this->assertSame(1, $countByType['sprint'] ?? 0);
  278. }
  279. }