The operator's daily clicks become durable assets:
Label export (GET /api/export/labels?days=N, JSONL download):
- Every 확인/오탐 click is a human verdict on a detection, and each verdict
carries a blurred 3-frame evidence timeline. The export turns that into a
dataset: one JSON line per event with type/severity/camera/zone/metrics/
spatial, label (confirmed | false_positive | unreviewed), labeled_by, and
snapshot uuids (images fetched via /snap, which re-checks session + site).
ergo_report rows are excluded - statistics are not labelable detections.
- Admin-only ('manage_compliance'): bulk export of detection history is a
compliance decision, not an operator convenience. Site scope applies.
- This is where the deferred P1-C evaluation set will come from for free.
Improvement actions (move_actions + GET/POST /api/actions):
- Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's
new card compares detection density over EQUAL windows before/after the
fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise,
not a verdict). Improvement shows accent, worsening shows red - the card
reports honestly in both directions, which is exactly what makes the
improvement numbers credible.
- Future timestamps and >1y-old entries are rejected (the comparison could
not be fair). Recording needs edit_zones + CSRF.
Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/
snapshot join, action validation, equal-window effect math, pending state)
+ Chrome real-render (action card with better/worse/pending rows, export
link, no console errors).
238 lines
17 KiB
PHP
238 lines
17 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
use App\Http\Request;
|
|
use App\Auth\ArraySessionStore;
|
|
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, DeviceStatusController};
|
|
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository,
|
|
InMemoryDeviceRepository};
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class DashboardTest extends TestCase
|
|
{
|
|
private function authed(string $role = 'operator'): ArraySessionStore {
|
|
$s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s;
|
|
}
|
|
public function testListRequiresAuth(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$c = new EventListController($repo, new InMemorySnapshotRepository(), new ArraySessionStore());
|
|
$this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status());
|
|
}
|
|
public function testListReturnsRecent(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall']);
|
|
$c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed());
|
|
$res = $c->json(new Request('GET', '/api/events', [], '', [], []));
|
|
$this->assertSame(200, $res->status());
|
|
$this->assertCount(1, json_decode($res->body(), true)['events']);
|
|
}
|
|
public function testListPaginatesWithCursor(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 100.0]); // id 1
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 200.0]); // id 2
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3
|
|
$c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed());
|
|
// Page 1: the newest two (300, 200); a full page hands back a cursor at the oldest row.
|
|
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true);
|
|
$this->assertCount(2, $p1['events']);
|
|
$this->assertSame([3, 2], [$p1['events'][0]['event_id'], $p1['events'][1]['event_id']]);
|
|
$this->assertSame('200:2', $p1['next_cursor']);
|
|
// Page 2: following the cursor yields only the older remaining event; short page => no cursor.
|
|
$p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2', 'cursor' => $p1['next_cursor']], []))->body(), true);
|
|
$this->assertCount(1, $p2['events']);
|
|
$this->assertSame(1, $p2['events'][0]['event_id']);
|
|
$this->assertNull($p2['next_cursor']);
|
|
}
|
|
public function testCursorPreservesMicrosecondPrecision(): void {
|
|
// Two events whose occurred_at differ only at the microsecond. The cursor must round-trip
|
|
// that value exactly — the old float stringification (precision=14) rounded it and made
|
|
// the page-edge row reappear on the next page (duplicate) or vanish (skip).
|
|
$repo = new InMemoryEventRepository();
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123456]); // id 1
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2
|
|
$c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed());
|
|
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true);
|
|
$this->assertSame(2, $p1['events'][0]['event_id']); // newest first
|
|
$p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1', 'cursor' => $p1['next_cursor']], []))->body(), true);
|
|
$this->assertCount(1, $p2['events']);
|
|
$this->assertSame(1, $p2['events'][0]['event_id']); // id 2 not duplicated, id 1 not skipped
|
|
}
|
|
public function testAcknowledgeRequiresPermissionAndSetsUser(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);
|
|
$req = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'tok'], '', [], []);
|
|
// viewer denied (RBAC check fires before CSRF)
|
|
$cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
|
|
$this->assertSame(403, $cv->acknowledge($req, ['id' => (string)$id])->status());
|
|
// operator allowed
|
|
$co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
|
|
$this->assertSame(200, $co->acknowledge($req, ['id' => (string)$id])->status());
|
|
$this->assertSame('u', $repo->find($id)['acknowledged_by']);
|
|
}
|
|
public function testAcknowledgeRejectsMissingOrWrongCsrfToken(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);
|
|
$c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
|
|
// missing header
|
|
$missing = new Request('POST', "/events/$id/ack", [], '', [], []);
|
|
$this->assertSame(403, $c->acknowledge($missing, ['id' => (string)$id])->status());
|
|
// wrong token
|
|
$wrong = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'nope'], '', [], []);
|
|
$this->assertSame(403, $c->acknowledge($wrong, ['id' => (string)$id])->status());
|
|
// acknowledge did not proceed
|
|
$this->assertNull($repo->find($id)['acknowledged_by']);
|
|
}
|
|
public function testFalsePositiveTagAcknowledgesAndKeepsFirstTagger(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']);
|
|
$req = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'tok'], '', [], []);
|
|
// viewer denied (same 'acknowledge' permission as ack)
|
|
$cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
|
|
$this->assertSame(403, $cv->falsePositive($req, ['id' => (string)$id])->status());
|
|
// operator tags: FP set AND acknowledged (an FP is a handled event)
|
|
$co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
|
|
$this->assertSame(200, $co->falsePositive($req, ['id' => (string)$id])->status());
|
|
$row = $repo->find($id);
|
|
$this->assertSame('u', $row['false_positive_by']);
|
|
$this->assertSame('u', $row['acknowledged_by']);
|
|
// a second tag by another user must NOT rewrite the original judgement
|
|
$s2 = $this->authed('operator'); $s2->set('username', 'u2');
|
|
$c2 = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $s2);
|
|
$this->assertSame(200, $c2->falsePositive($req, ['id' => (string)$id])->status());
|
|
$this->assertSame('u', $repo->find($id)['false_positive_by']);
|
|
}
|
|
public function testFalsePositiveRejectsBadCsrf(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']);
|
|
$c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
|
|
$wrong = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'nope'], '', [], []);
|
|
$this->assertSame(403, $c->falsePositive($wrong, ['id' => (string)$id])->status());
|
|
$this->assertNull($repo->find($id)['false_positive_by'] ?? null);
|
|
}
|
|
public function testSnapshotProxyAuthAnd404(): void {
|
|
$snaps = new InMemorySnapshotRepository();
|
|
$events = new InMemoryEventRepository();
|
|
$proxy = new SnapshotProxy($snaps, $events, new ArraySessionStore()); // unauth
|
|
$this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status());
|
|
$proxy2 = new SnapshotProxy($snaps, $events, $this->authed());
|
|
$this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status());
|
|
}
|
|
|
|
private function authedSite(string $site): ArraySessionStore {
|
|
$s = $this->authed('operator'); $s->set('site_scope', $site); return $s;
|
|
}
|
|
public function testListIsScopedToTheCallersSite(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1.0, 'site' => 'factory-a']);
|
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']);
|
|
// A factory-a operator sees only the factory-a event.
|
|
$scoped = new EventListController($repo, new InMemorySnapshotRepository(), $this->authedSite('factory-a'));
|
|
$rows = json_decode($scoped->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
|
|
$this->assertCount(1, $rows);
|
|
$this->assertSame('factory-a', $rows[0]['site']);
|
|
// A global operator (no site_scope) sees both.
|
|
$global = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed('operator'));
|
|
$all = json_decode($global->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
|
|
$this->assertCount(2, $all);
|
|
}
|
|
public function testDetailHidesOtherSitesEvent(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$id = $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'site' => 'factory-b']);
|
|
$c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authedSite('factory-a'));
|
|
// 404 (not 403) so a cross-site id is indistinguishable from a non-existent one.
|
|
$this->assertSame(404, $c->show(new Request('GET', "/events/$id", [], '', [], []), ['id' => (string)$id])->status());
|
|
// The owning site sees it.
|
|
$own = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authedSite('factory-b'));
|
|
$this->assertSame(200, $own->show(new Request('GET', "/events/$id", [], '', [], []), ['id' => (string)$id])->status());
|
|
}
|
|
public function testSnapshotServeHidesOtherSitesImage(): void {
|
|
// A real on-disk file so the 404 can only come from the site guard, not a missing file.
|
|
$repo = new InMemoryEventRepository();
|
|
$id = $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'site' => 'factory-b']);
|
|
$file = sys_get_temp_dir() . '/snaptest' . bin2hex(random_bytes(4)) . '.jpg';
|
|
file_put_contents($file, 'jpegbytes');
|
|
$uuid = str_repeat('a', 32);
|
|
$snaps = new class($file, $id, $uuid) implements \App\Repository\SnapshotRepository {
|
|
public function __construct(private string $file, private int $eid, private string $uuid) {}
|
|
public function add(int $e, string $u, string $h): void {}
|
|
public function forEvent(int $e): array { return []; }
|
|
public function thumbUuidsFor(array $ids): array { return []; }
|
|
public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; }
|
|
public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; }
|
|
public function latestForCamera(string $cameraId): ?array { return null; }
|
|
public function purgeOlderThan(int $c): int { return 0; }
|
|
public function uuidsFor(array $ids): array { return []; }
|
|
};
|
|
$cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a'));
|
|
$this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status());
|
|
$same = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-b'));
|
|
$served = $same->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid]);
|
|
$this->assertSame(200, $served->status());
|
|
// A snapshot is immutable per uuid, so it is browser-cacheable — without this the event
|
|
// list re-downloads every visible JPEG on every 5s poll (a Cafe24 traffic-cap hazard).
|
|
// `private` keeps a shared proxy from serving one site's worker image to another (PIPA).
|
|
$cache = $served->headers()['Cache-Control'] ?? '';
|
|
$this->assertStringContainsString('private', $cache);
|
|
$this->assertStringContainsString('max-age=', $cache);
|
|
@unlink($file);
|
|
}
|
|
|
|
public function testEventListAttachesNewestSnapshotThumbnail(): void {
|
|
$repo = new InMemoryEventRepository();
|
|
$withSnap = $repo->insert(['severity' => 'critical', 'event_type' => 'fall', 'occurred_at' => 2.0]);
|
|
$noSnap = $repo->insert(['severity' => 'info', 'event_type' => 'ghost', 'occurred_at' => 1.0]);
|
|
$snaps = new InMemorySnapshotRepository();
|
|
$snaps->add($withSnap, 'uuidA', 'hashA');
|
|
$snaps->add($withSnap, 'uuidB', 'hashB'); // newer -> this one wins
|
|
$c = new EventListController($repo, $snaps, $this->authed());
|
|
$rows = json_decode($c->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
|
|
$byId = [];
|
|
foreach ($rows as $r) $byId[$r['event_id']] = $r;
|
|
$this->assertSame('uuidB', $byId[$withSnap]['thumb_uuid']); // newest snapshot per event
|
|
$this->assertNull($byId[$noSnap]['thumb_uuid']); // event with no snapshot
|
|
}
|
|
|
|
public function testDeviceStatusRequiresAuth(): void {
|
|
$c = new DeviceStatusController(new InMemoryDeviceRepository(), new ArraySessionStore());
|
|
$this->assertSame(401, $c->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->status());
|
|
}
|
|
|
|
public function testDeviceStatusComputesLivenessAndDrift(): void {
|
|
$now = 10000;
|
|
$dev = new InMemoryDeviceRepository([], [], [], [
|
|
['device_id' => 'pi-online', 'name' => 'A동', 'site' => null, 'last_heartbeat_at' => $now - 30,
|
|
'last_fps' => 12.0, 'last_temp' => 55.0, 'last_throttle' => false, 'config_version' => 3, 'reported_config_version' => 3],
|
|
['device_id' => 'pi-offline', 'name' => 'B동', 'site' => null, 'last_heartbeat_at' => $now - 600,
|
|
'last_fps' => 0.0, 'last_temp' => 0.0, 'last_throttle' => true, 'config_version' => 1, 'reported_config_version' => 1],
|
|
['device_id' => 'pi-new', 'name' => null, 'site' => null, 'last_heartbeat_at' => null,
|
|
'last_fps' => null, 'last_temp' => null, 'last_throttle' => null, 'config_version' => 1, 'reported_config_version' => null],
|
|
['device_id' => 'pi-drift', 'name' => 'C동', 'site' => null, 'last_heartbeat_at' => $now - 10,
|
|
'last_fps' => 10.0, 'last_temp' => 50.0, 'last_throttle' => false, 'config_version' => 5, 'reported_config_version' => 4],
|
|
]);
|
|
$out = json_decode((new DeviceStatusController($dev, $this->authed()))
|
|
->json(new Request('GET', '/api/devices', [], '', [], []), $now)->body(), true)['devices'];
|
|
$by = [];
|
|
foreach ($out as $d) $by[$d['device_id']] = $d;
|
|
$this->assertTrue($by['pi-online']['online']);
|
|
$this->assertSame(30, $by['pi-online']['seconds_since']);
|
|
$this->assertFalse($by['pi-offline']['online']); // 600s past the 120s threshold
|
|
$this->assertFalse($by['pi-new']['online']); // never reported
|
|
$this->assertNull($by['pi-new']['seconds_since']);
|
|
$this->assertFalse($by['pi-online']['config_drift']);
|
|
$this->assertTrue($by['pi-drift']['config_drift']); // device on v4, server published v5
|
|
}
|
|
|
|
public function testDeviceStatusIsScopedToSite(): void {
|
|
$row = fn (string $id, string $site) => ['device_id' => $id, 'name' => null, 'site' => $site,
|
|
'last_heartbeat_at' => null, 'last_fps' => null, 'last_temp' => null, 'last_throttle' => null,
|
|
'config_version' => 1, 'reported_config_version' => null];
|
|
$dev = new InMemoryDeviceRepository([], [], [], [$row('a1', 'factory-a'), $row('b1', 'factory-b')]);
|
|
$scoped = json_decode((new DeviceStatusController($dev, $this->authedSite('factory-a')))
|
|
->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->body(), true)['devices'];
|
|
$this->assertCount(1, $scoped);
|
|
$this->assertSame('a1', $scoped[0]['device_id']);
|
|
$all = json_decode((new DeviceStatusController($dev, $this->authed('operator')))
|
|
->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->body(), true)['devices'];
|
|
$this->assertCount(2, $all);
|
|
}
|
|
}
|