markTestSkipped('pdo_sqlite not available on this host'); } $this->pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $this->pdo->exec(<<<'SQL' CREATE TABLE move_events ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, camera_id TEXT NOT NULL, event_type TEXT NOT NULL, severity TEXT NOT NULL, occurred_at REAL NOT NULL, duration_s REAL NOT NULL DEFAULT 0, hold_until INTEGER, received_at INTEGER NOT NULL ); CREATE TABLE move_event_snapshots ( snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, file_uuid TEXT NOT NULL UNIQUE, content_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE move_app_state ( k TEXT PRIMARY KEY, v TEXT NOT NULL, updated_at INTEGER NOT NULL ); SQL); $this->dir = sys_get_temp_dir() . '/move-hold-' . bin2hex(random_bytes(6)); mkdir($this->dir); } protected function tearDown(): void { if (!isset($this->dir) || !is_dir($this->dir)) return; foreach (glob($this->dir . '/*') ?: [] as $f) @unlink($f); @rmdir($this->dir); } /** 한 사건 + 그 사건의 스냅샷 1장. 반환값은 [event_id, file_uuid]. */ private function seed(int $createdAt, ?int $holdUntil = null): array { $st = $this->pdo->prepare( 'INSERT INTO move_events (device_id, camera_id, event_type, severity, occurred_at, hold_until, received_at) VALUES (?, ?, ?, ?, ?, ?, ?)'); $st->execute(['pi-01', 'cam-01', 'fall', 'high', (float)$createdAt, $holdUntil, $createdAt]); $eventId = (int)$this->pdo->lastInsertId(); $uuid = bin2hex(random_bytes(16)); file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes'); $this->pdo->prepare( 'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at) VALUES (?, ?, ?, ?)')->execute([$eventId, $uuid, str_repeat('a', 64), $createdAt]); return [$eventId, $uuid]; } private function snaps(): PdoSnapshotRepository { return new PdoSnapshotRepository($this->pdo, $this->dir); } public function test_an_old_snapshot_is_purged_when_nothing_holds_it(): void { [, $uuid] = $this->seed(1_000); $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000); $this->assertSame(1, $purged); $this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg'); } public function test_a_held_snapshot_survives_its_retention_window(): void { [, $uuid] = $this->seed(1_000, holdUntil: 9_000); $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000); $this->assertSame(0, $purged, '조사 중인 사건의 사진이 보존기간 때문에 지워졌습니다.'); $this->assertFileExists($this->dir . '/' . $uuid . '.jpg'); } public function test_the_hold_stops_protecting_once_it_expires(): void { // 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. 기간이 지나면 다시 대상이 된다. [, $uuid] = $this->seed(1_000, holdUntil: 2_500); $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000); $this->assertSame(1, $purged); $this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg'); } public function test_one_hold_does_not_shield_other_events(): void { // JOIN 조건이 틀리면(예: 보류 행이 하나라도 있으면 전부 남김) 프라이버시 기본값이 // 조용히 "영구 보존"으로 뒤집힌다. 한 건만 남고 나머지는 지워져야 한다. [, $held] = $this->seed(1_000, holdUntil: 9_000); [, $a] = $this->seed(1_000); [, $b] = $this->seed(1_000); $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000); $this->assertSame(2, $purged); $this->assertFileExists($this->dir . '/' . $held . '.jpg'); $this->assertFileDoesNotExist($this->dir . '/' . $a . '.jpg'); $this->assertFileDoesNotExist($this->dir . '/' . $b . '.jpg'); } public function test_an_orphan_snapshot_is_still_purged(): void { // 이벤트 행이 먼저 정리된 스냅샷은 보류가 아니라 잔여물이다. LEFT JOIN 이 NULL 을 // 내므로 대상이 되어야 한다 — INNER JOIN 으로 쓰면 이게 영원히 안 지워지고, // 작업자 사진이 보존기간을 넘겨 디스크에 남는 PIPA 누출이 된다. $uuid = bin2hex(random_bytes(16)); file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes'); $this->pdo->prepare( 'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at) VALUES (?, ?, ?, ?)')->execute([9999, $uuid, str_repeat('b', 64), 1_000]); $this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000)); $this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg'); } public function test_setting_and_clearing_a_hold_round_trips(): void { [$id] = $this->seed(1_000); $events = new PdoEventRepository($this->pdo); $this->assertTrue($events->setHold($id, 9_000)); $this->assertSame(0, $this->snaps()->purgeOlderThan(2_000, 500, 3_000)); $this->assertTrue($events->setHold($id, null)); $this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000)); } public function test_holding_a_missing_event_reports_failure(): void { $this->assertFalse((new PdoEventRepository($this->pdo))->setHold(4242, 9_000)); } public function test_app_state_upsert_round_trips(): void { $state = new PdoAppStateRepository($this->pdo); $this->assertNull($state->get('dispatch_alerts.last_run')); $state->set('dispatch_alerts.last_run', '1000', 1000); $this->assertSame(['value' => '1000', 'updated_at' => 1000], $state->get('dispatch_alerts.last_run')); // 덮어쓰기여야 한다. 행이 쌓이면 대시보드가 첫 행(옛날 시각)을 읽고 크론이 죽었다고 // 오판하거나, 그 반대로 영원히 살아있다고 본다. $state->set('dispatch_alerts.last_run', '2000', 2000); $this->assertSame(['value' => '2000', 'updated_at' => 2000], $state->get('dispatch_alerts.last_run')); $this->assertSame(1, (int)$this->pdo->query('SELECT COUNT(*) FROM move_app_state')->fetchColumn()); } }