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(self::sqliteSchema()); } public function testNonceRoundTrip(): void { $repo = new PdoNonceRepository($this->pdo); $this->assertFalse($repo->seen('n1')); $repo->remember('n1', 9999999999); $this->assertTrue($repo->seen('n1')); } public function testEventInsertFindAcknowledge(): void { $repo = new PdoEventRepository($this->pdo); $uuid = str_repeat('a', 32); $id = $repo->insert([ 'event_uuid' => $uuid, 'device_id' => 'dev-1', 'site' => 'factory-a', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'track_id' => 7, 'zone_id' => null, 'occurred_at' => 1700000000.5, 'duration_s' => 2.5, 'metrics' => ['confidence' => 0.9], ]); $this->assertGreaterThan(0, $id); $found = $repo->find($id); $this->assertNotNull($found); $this->assertSame('dev-1', $found['device_id']); $this->assertSame('factory-a', $found['site']); $this->assertSame($uuid, $found['event_uuid']); $this->assertSame('critical', $found['severity']); $this->assertSame(['confidence' => 0.9], $found['metrics']); $this->assertNull($found['acknowledged_by']); // Idempotency key lookup (P1-9). $this->assertSame($id, $repo->idByUuid($uuid)); $this->assertNull($repo->idByUuid(str_repeat('z', 32))); // Site-scoped page (P3-2b): matches its own site, excluded from another. $this->assertCount(1, $repo->page(10, null, null, null, 'factory-a')); $this->assertCount(0, $repo->page(10, null, null, null, 'factory-b')); $recent = $repo->recent(10, 'critical'); $this->assertCount(1, $recent); $this->assertSame([], $repo->recent(10, 'info')); $this->assertTrue($repo->acknowledge($id, 'alice')); $this->assertSame('alice', $repo->find($id)['acknowledged_by']); $this->assertFalse($repo->acknowledge(999999, 'bob')); } public function testZoneSaveIsOwnershipScoped(): void { $repo = new PdoZoneRepository($this->pdo); $zoneId = $repo->save([ 'zone_id' => null, 'device_id' => 'dev-1', 'camera_id' => 'cam-1', 'level' => 'warning', 'polygon_json' => '[[0,0],[1,0],[1,1]]', ]); $this->assertNotSame('', $zoneId); $this->assertCount(1, $repo->forCamera('cam-1')); // Legitimate update by the owning device/camera succeeds. $again = $repo->save([ 'zone_id' => $zoneId, 'device_id' => 'dev-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[2,0],[2,2]]', ]); $this->assertSame($zoneId, $again); // Supplying the same zone_id under a different device/camera must not // silently overwrite it -- this is the cross-tenant guard from the // Task 13 review. $this->expectException(RuntimeException::class); $repo->save([ 'zone_id' => $zoneId, 'device_id' => 'dev-2', 'camera_id' => 'cam-2', 'level' => 'pinch', 'polygon_json' => '[[9,9],[9,8],[8,8]]', ]); } public function testZoneDelete(): void { $repo = new PdoZoneRepository($this->pdo); $zoneId = $repo->save([ 'zone_id' => null, 'device_id' => 'dev-1', 'camera_id' => 'cam-1', 'level' => 'warning', 'polygon_json' => '[[0,0],[1,0],[1,1]]', ]); $this->assertTrue($repo->delete($zoneId)); $this->assertFalse($repo->delete($zoneId)); } /** * Minimal sqlite-flavored translation of the nonces/events/zones tables * in ../schema.sql (see that file's "sqlite compatibility notes" header * for the general MySQL -> sqlite mapping rules applied here). */ /** Insert an event with sensible defaults; only the interesting columns need naming. */ private function seedEvent(PdoEventRepository $repo, array $over = []): int { static $n = 0; $n++; return $repo->insert($over + [ 'event_uuid' => str_repeat((string)($n % 10), 32), 'device_id' => 'dev-1', 'site' => 'factory-a', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'track_id' => 1, 'zone_id' => null, 'occurred_at' => 1700000000.0, 'duration_s' => 1.0, 'metrics' => [], ]); } public function testCameraLookupsBackTheActionOwnershipCheck(): void { // These two back ActionsController's unknown_camera 400 and cross-site 404. Cameras have // no registry table, so events ARE the ownership record — verify against real SQL. $repo = new PdoEventRepository($this->pdo); $this->seedEvent($repo, ['camera_id' => 'cam-1', 'site' => 'factory-a']); $this->assertTrue($repo->cameraExists('cam-1')); $this->assertFalse($repo->cameraExists('cam-typo')); $this->assertSame('factory-a', $repo->siteForCamera('cam-1')); $this->assertNull($repo->siteForCamera('cam-typo')); // A re-homed camera belongs to the site it reports to NOW (newest event wins). $this->seedEvent($repo, ['camera_id' => 'cam-1', 'site' => 'factory-b']); $this->assertSame('factory-b', $repo->siteForCamera('cam-1')); } public function testActionRepositoryScopesBySite(): void { $repo = new PdoActionRepository($this->pdo); $repo->insert(['camera_id' => 'cam-a', 'site' => 'factory-a', 'note' => '매트', 'taken_at' => 100, 'created_by' => 'alice']); $repo->insert(['camera_id' => 'cam-b', 'site' => 'factory-b', 'note' => '난간', 'taken_at' => 200, 'created_by' => 'bob']); $this->assertCount(2, $repo->recent(10, null)); // global operator $scoped = $repo->recent(10, 'factory-a'); $this->assertCount(1, $scoped); $this->assertSame('매트', $scoped[0]['note']); $this->assertCount(0, $repo->recent(10, 'factory-c')); // Fail-closed: an empty-string scope is a misconfigured scope, not "everything". $this->assertCount(0, $repo->recent(10, '')); } public function testCountForCameraDrivesTheBeforeAfterEffect(): void { $repo = new PdoEventRepository($this->pdo); $this->seedEvent($repo, ['occurred_at' => 100.0]); $this->seedEvent($repo, ['occurred_at' => 150.0]); $this->seedEvent($repo, ['occurred_at' => 300.0]); $this->seedEvent($repo, ['occurred_at' => 120.0, 'event_type' => 'ergo_report']); $this->assertSame(2, $repo->countForCamera('cam-1', 0.0, 200.0, null)); // ergo excluded $this->assertSame(0, $repo->countForCamera('cam-1', 0.0, 200.0, 'factory-b')); } public function testAggregatesThatBackTheReportPageRunAgainstRealSql(): void { // reportSummary/fpStats/heatmapPoints/occurrenceTimes/exportRows were previously // exercised ONLY against the in-memory double, which reimplements their semantics in // PHP — so a broken query could not fail the suite. Round-trip them for real. $repo = new PdoEventRepository($this->pdo); $this->seedEvent($repo, ['occurred_at' => 1000.0, 'spatial_x' => 0.4, 'spatial_y' => 0.6]); $fp = $this->seedEvent($repo, ['occurred_at' => 1100.0, 'severity' => 'warning']); $repo->markFalsePositive($fp, 'alice'); $this->seedEvent($repo, ['occurred_at' => 1200.0, 'event_type' => 'ergo_report', 'severity' => 'info', 'metrics' => ['bend_s' => 12]]); $summary = $repo->reportSummary(0.0, null, 5); $this->assertNotEmpty($summary['by_type']); $this->assertNotEmpty($summary['by_camera']); $this->assertArrayHasKey('tta', $summary); $this->assertNotEmpty($repo->fpStats(0.0, null)); $this->assertCount(1, $repo->heatmapPoints('cam-1', 0.0, null, 100)); // only the spatial row $this->assertCount(2, $repo->occurrenceTimes(0.0, null, 100)); // ergo excluded $this->assertCount(3, $repo->exportRows(0.0, null, 100)); // Site scoping must reach every one of them. $this->assertSame([], $repo->fpStats(0.0, 'factory-b')); $this->assertSame([], $repo->heatmapPoints('cam-1', 0.0, 'factory-b', 100)); $this->assertSame([], $repo->exportRows(0.0, 'factory-b', 100)); } public function testEventPurgeIsOptInAndBounded(): void { $repo = new PdoEventRepository($this->pdo); for ($i = 0; $i < 5; $i++) $this->seedEvent($repo, ['occurred_at' => 100.0 + $i]); $this->seedEvent($repo, ['occurred_at' => 9000.0]); $this->assertSame(3, $repo->purgeOlderThan(9000.0, 3)); // batch bound respected $this->assertSame(2, $repo->purgeOlderThan(9000.0, 500)); $this->assertSame(0, $repo->purgeOlderThan(9000.0, 500)); // the newer row survives $this->assertCount(1, $repo->page(10, null, null, null, null)); } public function testSnapshotThumbnailLookupAvoidsNPlusOne(): void { $events = new PdoEventRepository($this->pdo); $snaps = new PdoSnapshotRepository($this->pdo, sys_get_temp_dir()); $a = $this->seedEvent($events, ['occurred_at' => 10.0]); $b = $this->seedEvent($events, ['occurred_at' => 20.0]); $snaps->add($a, str_repeat('a', 32), 'h1'); $snaps->add($a, str_repeat('b', 32), 'h2'); // newest wins as the thumbnail $snaps->add($b, str_repeat('c', 32), 'h3'); $thumbs = $snaps->thumbUuidsFor([$a, $b]); $this->assertSame(str_repeat('b', 32), $thumbs[$a]); $this->assertSame(str_repeat('c', 32), $thumbs[$b]); $this->assertCount(2, $snaps->forEvent($a)); } private static function sqliteSchema(): string { return <<<'SQL' CREATE TABLE move_nonces ( nonce TEXT PRIMARY KEY, expires_at INTEGER NOT NULL ); CREATE TABLE move_events ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, event_uuid TEXT UNIQUE, device_id TEXT NOT NULL, site TEXT, camera_id TEXT NOT NULL, event_type TEXT NOT NULL, severity TEXT NOT NULL, track_id INTEGER, zone_id TEXT, occurred_at REAL NOT NULL, duration_s REAL NOT NULL DEFAULT 0, metrics_json TEXT, spatial_x REAL, spatial_y REAL, acknowledged_by TEXT, acknowledged_at INTEGER, false_positive_by TEXT, false_positive_at INTEGER, received_at INTEGER NOT NULL ); CREATE TABLE move_zones ( zone_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, camera_id TEXT NOT NULL, level TEXT NOT NULL, polygon_json TEXT NOT NULL, rules_json TEXT, updated_at INTEGER NOT NULL ); CREATE TABLE move_actions ( action_id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id TEXT NOT NULL, site TEXT, note TEXT NOT NULL, taken_at INTEGER NOT NULL, created_by TEXT NOT NULL, created_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, content_hash TEXT, created_at INTEGER NOT NULL ); SQL; } }