set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); if ($site !== null) $s->set('site_scope', $site); return $s; } // ---- 라벨 익스포트 ---------------------------------------------------------- public function testExportRequiresAdmin(): void { $c = new ExportController(new InMemoryEventRepository(), new InMemorySnapshotRepository(), $this->sess('operator')); $this->assertSame(403, $c->labels(new Request('GET', '/api/export/labels', [], '', [], []))->status()); } public function testExportEmitsLabeledJsonl(): void { $events = new InMemoryEventRepository(); $snaps = new InMemorySnapshotRepository(); $now = (float)time(); $a = $events->insert(['event_uuid' => str_repeat('a', 32), 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => $now, 'metrics' => ['torso_angle' => 82.0], 'spatial_x' => 0.4, 'spatial_y' => 0.6]); $events->acknowledge($a, 'op'); $snaps->add($a, 'uuid-1', 'h1'); $snaps->add($a, 'uuid-2', 'h2'); $b = $events->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'occurred_at' => $now, 'metrics' => []]); $events->markFalsePositive($b, 'op'); $events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info', 'occurred_at' => $now, 'metrics' => []]); // unreviewed $events->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info', 'occurred_at' => $now, 'metrics' => []]); // 제외 대상 $c = new ExportController($events, $snaps, $this->sess('admin')); $res = $c->labels(new Request('GET', '/api/export/labels', [], '', [], [])); $this->assertSame(200, $res->status()); $this->assertStringContainsString('application/x-ndjson', $res->headers()['Content-Type']); $lines = array_values(array_filter(explode("\n", $res->body()))); $this->assertCount(3, $lines); // ergo_report 제외 $byType = []; foreach ($lines as $l) { $o = json_decode($l, true); $byType[$o['event_type']] = $o; } $this->assertSame('confirmed', $byType['fall']['label']); $this->assertSame('op', $byType['fall']['labeled_by']); $this->assertSame(['uuid-1', 'uuid-2'], $byType['fall']['snapshots']); $this->assertEquals([0.4, 0.6], $byType['fall']['spatial']); $this->assertSame('false_positive', $byType['zone_warning']['label']); $this->assertSame('unreviewed', $byType['near_fall']['label']); } // ---- 개선 조치 -------------------------------------------------------------- private function actionReq(array $body): Request { return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []); } /** 카메라는 등록 표가 없다 — 이벤트가 곧 존재·소유 근거이므로 테스트도 이벤트로 만든다. */ private function eventsWithCameras(array $cameraSites): InMemoryEventRepository { $events = new InMemoryEventRepository(); foreach ($cameraSites as $cam => $site) { $events->insert(['camera_id' => $cam, 'site' => $site, 'event_type' => 'near_fall', 'severity' => 'info', 'occurred_at' => (float)time(), 'metrics' => []]); } return $events; } public function testActionSaveValidation(): void { $events = $this->eventsWithCameras(['cam-1' => null]); $c = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('operator')); $this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x', 'taken_at' => time() + 999]))->status()); // 미래 시각 거부 // 오타 카메라: 전후 효과가 영원히 0/0이 되므로 기록 자체를 막는다 $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-오타', 'note' => 'x']))->status()); // viewer는 기록 불가 $cv = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('viewer')); $this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status()); } public function testScopedOperatorCannotWriteActionForAnotherSiteCamera(): void { $events = $this->eventsWithCameras(['cam-a' => 'site-a', 'cam-b' => 'site-b']); $actions = new InMemoryActionRepository(); $c = new ActionsController($actions, $events, $this->sess('operator', 'site-a')); $this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-a', 'note' => '매트']))->status()); // 타 현장 카메라: 존재 여부를 숨기며 거부(404) — 그 현장 리포트 오염 방지 $this->assertSame(404, $c->save($this->actionReq(['camera_id' => 'cam-b', 'note' => '침입']))->status()); $this->assertCount(1, $actions->rows); $this->assertSame('site-a', $actions->rows[1]['site']); // 카메라에서 해석해 비정규화 } public function testScopedOperatorSeesOnlyOwnSiteActions(): void { $actions = new InMemoryActionRepository(); $actions->insert(['camera_id' => 'cam-a', 'site' => 'site-a', 'note' => '우리 현장', 'taken_at' => time() - 86400, 'created_by' => 'a']); $actions->insert(['camera_id' => 'cam-b', 'site' => 'site-b', 'note' => '남의 현장', 'taken_at' => time() - 3600, 'created_by' => 'b']); $req = new Request('GET', '/api/actions', [], '', [], []); $scoped = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('operator', 'site-a')); $rows = json_decode($scoped->json($req)->body(), true)['actions']; $this->assertCount(1, $rows); $this->assertSame('우리 현장', $rows[0]['note']); // 타 현장 메모·작성자 미노출 $global = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('admin')); $this->assertCount(2, json_decode($global->json($req)->body(), true)['actions']); } public function testActionEffectComparesEqualWindows(): void { $actions = new InMemoryActionRepository(); $events = new InMemoryEventRepository(); $now = time(); $takenAt = $now - 10 * 86400; // 10일 전 조치 $actions->insert(['camera_id' => 'cam-1', 'note' => '매트', 'taken_at' => $takenAt, 'created_by' => 'u']); // 전 창(10일): 4건 / 후 창(10일): 1건 foreach ([1, 3, 5, 7] as $d) { $events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info', 'occurred_at' => (float)($takenAt - $d * 86400), 'metrics' => []]); } $events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info', 'occurred_at' => (float)($takenAt + 2 * 86400), 'metrics' => []]); // 다른 카메라·ergo는 집계 밖 $events->insert(['camera_id' => 'cam-9', 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => (float)($takenAt + 1), 'metrics' => []]); $events->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info', 'occurred_at' => (float)($takenAt + 1), 'metrics' => []]); $c = new ActionsController($actions, $events, $this->sess('operator')); $data = json_decode($c->json(new Request('GET', '/api/actions', [], '', [], []))->body(), true); $e = $data['actions'][0]['effect']; $this->assertSame(4, $e['before']); $this->assertSame(1, $e['after']); $this->assertSame(10, $e['window_days']); } public function testFreshActionReportsPendingEffect(): void { $actions = new InMemoryActionRepository(); $actions->insert(['camera_id' => 'cam-1', 'note' => '방금 조치', 'taken_at' => time() - 3600, 'created_by' => 'u']); $c = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('operator')); $data = json_decode($c->json(new Request('GET', '/api/actions', [], '', [], []))->body(), true); $this->assertNull($data['actions'][0]['effect']); // 3일 미만: 측정 중 } }