lastRun === null) return null; return ['value' => (string)$this->lastRun, 'updated_at' => $this->lastRun]; } public function set(string $key, string $value, int $now): void {} }; } private function dispatcherBlock(?AppStateRepository $state): array { $sess = new ArraySessionStore(); $sess->set('user_id', 1); $sess->set('site_scope', null); $ctl = new DeviceStatusController(new InMemoryDeviceRepository(), $sess, null, null, $state); $res = $ctl->json(new Request('GET', '/api/devices', [], '', []), 10_000); return json_decode($res->body(), true)['dispatcher']; } public function test_a_recent_run_reads_as_alive(): void { $d = $this->dispatcherBlock($this->state(10_000 - 30)); $this->assertFalse($d['stale']); $this->assertSame(30, $d['seconds_since']); } public function test_a_stopped_cron_is_visible(): void { // 크론이 5분 넘게 안 돌았다 = 통보가 통째로 멈춘 상태. 화면이 조용한 것이 // "사고 없음"이라는 뜻이 아니게 되는 지점이고, 그걸 사람이 알아야 한다. $d = $this->dispatcherBlock($this->state(10_000 - 600)); $this->assertTrue($d['stale']); $this->assertSame(600, $d['seconds_since']); } public function test_never_run_is_distinguishable_from_stopped(): void { // 조치가 다르다 — 전자는 크론을 등록하는 것이고 후자는 왜 멈췄는지 보는 것이다. $d = $this->dispatcherBlock($this->state(null)); $this->assertTrue($d['stale']); $this->assertNull($d['last_run_at']); $this->assertNull($d['seconds_since']); } public function test_a_missing_state_table_does_not_break_the_status_panel(): void { // 마이그레이션 전 배포에서 상태 패널 전체가 죽으면, 이 기능을 넣은 대가로 기존 // 장치 감시를 잃는다. 그 경우 "한 번도 안 돌았다"로 보이고, 그게 실제로 맞다. $throwing = new class implements AppStateRepository { public function get(string $key): ?array { throw new \RuntimeException('no such table'); } public function set(string $key, string $value, int $now): void {} }; $d = $this->dispatcherBlock($throwing); $this->assertTrue($d['stale']); $this->assertNull($d['last_run_at']); } public function test_the_panel_still_works_without_the_repository(): void { $d = $this->dispatcherBlock(null); $this->assertTrue($d['stale']); } // --- 증거 보존 보류 -------------------------------------------------------------- /** @return array{0:EventDetailController,1:InMemoryEventRepository,2:InMemoryAuditRepository,3:ArraySessionStore,4:int} */ private function detail(string $role): array { $events = new InMemoryEventRepository(); $id = $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => null]); $audit = new InMemoryAuditRepository(); $sess = new ArraySessionStore(); $sess->set('user_id', 1); $sess->set('username', '관리자'); $sess->set('role', $role); $sess->set('site_scope', null); $sess->set('csrf', 'tok'); return [new EventDetailController($events, new InMemorySnapshotRepository(), $audit, $sess), $events, $audit, $sess, $id]; } private function holdReq(mixed $days): Request { return new Request('POST', '/events/1/hold', ['X-Csrf-Token' => 'tok'], json_encode(['days' => $days]), []); } public function test_an_admin_can_hold_evidence_and_it_is_recorded(): void { [$ctl, $events, $audit, , $id] = $this->detail('admin'); $res = $ctl->hold($this->holdReq(90), ['id' => $id]); $this->assertSame(200, $res->status()); $this->assertNotNull($events->rows[$id]['hold_until']); $this->assertSame('evidence_hold', $audit->log[0]['act'], '보존기간 연장이 기록되지 않으면, 노조에 "사고 건만 기록과 함께 남긴다"고 말할 근거가 없습니다.'); } public function test_releasing_a_hold_is_recorded_separately(): void { [$ctl, $events, $audit, , $id] = $this->detail('admin'); $ctl->hold($this->holdReq(90), ['id' => $id]); $res = $ctl->hold($this->holdReq(0), ['id' => $id]); $this->assertSame(200, $res->status()); $this->assertNull($events->rows[$id]['hold_until']); $this->assertSame('evidence_hold_release', $audit->log[1]['act']); } public function test_an_operator_cannot_extend_retention(): void { // 보존기간 연장은 운영 편의가 아니라 규정 판단이다. [$ctl, $events, $audit, , $id] = $this->detail('operator'); $this->assertSame(403, $ctl->hold($this->holdReq(90), ['id' => $id])->status()); $this->assertNull($events->rows[$id]['hold_until'] ?? null); $this->assertSame([], $audit->log); } public function test_an_unbounded_hold_is_refused(): void { // 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. [$ctl, $events, , , $id] = $this->detail('admin'); foreach ([400, -1, 'forever', null] as $bad) { $this->assertSame(400, $ctl->hold($this->holdReq($bad), ['id' => $id])->status(), 'days=' . var_export($bad, true) . ' 를 통과시켰습니다'); } $this->assertNull($events->rows[$id]['hold_until'] ?? null); } public function test_a_hold_without_csrf_is_refused(): void { [$ctl, , $audit, , $id] = $this->detail('admin'); $req = new Request('POST', '/events/1/hold', [], json_encode(['days' => 90]), []); $this->assertSame(403, $ctl->hold($req, ['id' => $id])->status()); $this->assertSame([], $audit->log); } }