| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * SPDX-License-Identifier: Apache-2.0
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * See the LICENSE file in the project root for the full license text.
- */
- 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));
- }
- // -------------------------------------------------------------------
- // 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);
- }
- }
|