1, 'site' => null, 'kind' => 'email', 'name' => 'shift lead', 'config_json' => '{"to":["a@b.c"]}', 'min_severity' => 'high', 'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0, ]; } private function event(array $over = []): array { return $over + [ 'event_id' => 1, 'device_id' => 'pi-1', 'site' => null, 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'zone_id' => null, 'occurred_at' => (float)(self::NOW - 10), 'duration_s' => 1.0, 'acknowledged_by' => null, 'false_positive_by' => null, ]; } private function dispatcher( ?InMemoryEventRepository $ev = null, ?InMemoryAlertRepository $al = null, ?InMemorySuppressionRepository $su = null ): AlertDispatcher { return new AlertDispatcher($ev ?? new InMemoryEventRepository(), $al ?? new InMemoryAlertRepository(), $su ?? new InMemorySuppressionRepository()); } // ---- 심각도 문턱 ------------------------------------------------------------ public function testSeverityFloorIsAFloorNotAnEquality(): void { $d = $this->dispatcher(); // critical >= high 이므로 발송 대상이다. 예전 구현이 동등 비교였다면 넘어짐이 조용히 빠진다. $this->assertCount(1, $d->planFor($this->event(['severity' => 'critical']), [$this->channel()], self::NOW)); $this->assertCount(0, $d->planFor($this->event(['severity' => 'warning']), [$this->channel()], self::NOW)); } public function testDisabledChannelNeverFires(): void { $d = $this->dispatcher(); $this->assertCount(0, $d->planFor($this->event(), [$this->channel(['enabled' => 0])], self::NOW)); } public function testLocalChannelsAreNotServerDelivered(): void { // 엣지가 자체 GPIO 로 울린다. 서버가 원장에 잡으면 영구 실패로 남는다. $d = $this->dispatcher(); $this->assertCount(0, $d->planFor($this->event(), [$this->channel(['kind' => 'local'])], self::NOW)); } // ---- 해소된 이벤트 ---------------------------------------------------------- public function testAcknowledgedEventStopsTheChain(): void { $d = $this->dispatcher(); $this->assertCount(0, $d->planFor($this->event(['acknowledged_by' => 'op']), [$this->channel()], self::NOW)); } public function testFalsePositiveStopsTheChain(): void { $d = $this->dispatcher(); $this->assertCount(0, $d->planFor($this->event(['false_positive_by' => 'op']), [$this->channel()], self::NOW)); } // ---- 에스컬레이션 타이밍 ---------------------------------------------------- public function testEscalationChannelWaitsForItsDelay(): void { $d = $this->dispatcher(); $ch = $this->channel(['channel_id' => 2, 'step' => 1, 'escalate_after_s' => 300]); $young = $this->event(['occurred_at' => (float)(self::NOW - 60)]); $old = $this->event(['occurred_at' => (float)(self::NOW - 600)]); $this->assertCount(0, $d->planFor($young, [$ch], self::NOW), '아직 이르다'); $this->assertCount(1, $d->planFor($old, [$ch], self::NOW), '지연 경과 후 발화'); } // ---- 사이트 스코프 ---------------------------------------------------------- public function testSiteScopedChannelOnlyServesItsOwnSite(): void { $d = $this->dispatcher(); $ch = $this->channel(['site' => 'factory-a']); $this->assertCount(1, $d->planFor($this->event(['site' => 'factory-a']), [$ch], self::NOW)); $this->assertCount(0, $d->planFor($this->event(['site' => 'factory-b']), [$ch], self::NOW)); // site 가 NULL 인 채널은 전역 (단일 현장 기본 설치). $this->assertCount(1, $d->planFor($this->event(['site' => 'factory-b']), [$this->channel()], self::NOW)); } // ---- 억제 ------------------------------------------------------------------ public function testSuppressionWithholdsSendButStillRecordsIt(): void { $events = new InMemoryEventRepository(); $id = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_restricted', 'severity' => 'high', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]); $alerts = new InMemoryAlertRepository([$this->channel()]); $sup = new InMemorySuppressionRepository(); $sup->create(['scope_type' => 'camera', 'scope_id' => 'cam-1', 'site' => null, 'reason' => '정비', 'starts_at' => self::NOW - 60, 'ends_at' => self::NOW + 600, 'created_by' => 'op', 'created_at' => self::NOW - 60]); $sent = 0; $stats = $this->dispatcher($events, $alerts, $sup) ->run(self::NOW, 3600, 100, function () use (&$sent) { $sent++; }); $this->assertSame(0, $sent, '억제 중에는 실제 발송이 없다'); $this->assertSame(1, $stats['suppressed']); // 그러나 원장에는 남는다 — 조사에서 "경보가 났지만 의도적으로 보류됐다"를 볼 수 있어야 한다. $log = $alerts->forEvent($id); $this->assertCount(1, $log); $this->assertSame('suppressed', $log[0]['status']); } // ---- 중복 발송 방지 --------------------------------------------------------- public function testSecondRunDoesNotResendTheSameEvent(): void { $events = new InMemoryEventRepository(); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]); $alerts = new InMemoryAlertRepository([$this->channel()]); $d = $this->dispatcher($events, $alerts); $sent = 0; $send = function () use (&$sent) { $sent++; }; $d->run(self::NOW, 3600, 100, $send); $d->run(self::NOW, 3600, 100, $send); // 크론 중첩 실행을 흉내낸다 $this->assertSame(1, $sent, '같은 사건을 두 번 전화하지 않는다'); } public function testOneFailingChannelDoesNotStopTheOthers(): void { $events = new InMemoryEventRepository(); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]); $alerts = new InMemoryAlertRepository([ $this->channel(['channel_id' => 1, 'name' => 'broken']), $this->channel(['channel_id' => 2, 'name' => 'works']), ]); $ok = 0; $stats = $this->dispatcher($events, $alerts)->run(self::NOW, 3600, 100, function (array $e, array $ch) use (&$ok) { if ($ch['name'] === 'broken') throw new \RuntimeException('relay down'); $ok++; }); $this->assertSame(1, $ok, '불통 채널 하나가 나머지를 막지 않는다'); $this->assertSame(1, $stats['failed']); $this->assertSame(1, $stats['sent']); } // ---- 미확인 에스컬레이션 ---------------------------------------------------- public function testUnackedCriticalBecomesItsOwnEvent(): void { $events = new InMemoryEventRepository(); $origin = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); $watch = new EscalationWatch($events); $this->assertSame([$origin], $watch->run(self::NOW, 300, 3600, 100)); $escalations = array_values(array_filter($events->rows, fn ($r) => ($r['event_type'] ?? '') === 'unacked_escalation')); $this->assertCount(1, $escalations); $this->assertSame('critical', $escalations[0]['severity'], '심각도를 낮추면 상위 채널 문턱을 못 넘는다'); $this->assertSame($origin, $escalations[0]['metrics']['origin_event_id']); } public function testEscalationIsIdempotent(): void { $events = new InMemoryEventRepository(); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); $watch = new EscalationWatch($events); $watch->run(self::NOW, 300, 3600, 100); $this->assertSame([], $watch->run(self::NOW, 300, 3600, 100), '두 번째 실행은 아무것도 만들지 않는다'); } public function testAcknowledgedEventNeverEscalates(): void { $events = new InMemoryEventRepository(); $id = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); $events->acknowledge($id, 'op'); $this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 3600, 100)); } public function testEscalationDoesNotEscalateItself(): void { $events = new InMemoryEventRepository(); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'unacked_escalation', 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); $this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 3600, 100), '무한 사다리 방지'); } public function testWarningNeverEscalates(): void { $events = new InMemoryEventRepository(); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'occurred_at' => (float)(self::NOW - 6000), 'metrics' => []]); $this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 86400, 100), '미처리 경고는 백로그이지 응급이 아니다'); } // ---- 메시지 내용 ------------------------------------------------------------ public function testMessageCarriesNoImageAndLinksToTheIncident(): void { $c = new Channels('https://example.com/move_detect'); $body = $c->body($this->event(['event_id' => 42])); $this->assertStringContainsString('https://example.com/move_detect/events/42/view', $body); $this->assertStringContainsString('넘어짐', $body); // 스냅샷은 PIPA 대상 개인정보다. 메일로 나가면 인증 프록시가 지키던 통제를 벗어난다. $this->assertStringNotContainsString('/snap/', $body); $this->assertStringContainsString('이미지가 포함되지 않습니다', $body); } // ---- 재감사(2026-08-18) 회귀 ------------------------------------------------ public function testAlreadyDeliveredPairsAreNotReClaimedEveryRun(): void { // A7: 디스패처는 매분 창 전체를 다시 훑는다. 사전 필터가 없으면 이미 보낸 // (event,channel) 마다 실패할 INSERT 를 한 번씩 또 시도한다 — 비용이 '새 이벤트'가 // 아니라 '창 길이'에 비례하고, 크론이 불안정해 창을 넓힌 현장일수록 더 나빠진다. $events = new InMemoryEventRepository(); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]); $alerts = new InMemoryAlertRepository([$this->channel()]); $d = $this->dispatcher($events, $alerts); $send = function () {}; $d->run(self::NOW, 3600, 100, $send); $afterFirst = $alerts->claimCalls; $d->run(self::NOW, 3600, 100, $send); $d->run(self::NOW, 3600, 100, $send); $this->assertSame(1, $afterFirst, '첫 실행은 한 번 claim 한다'); $this->assertSame(1, $alerts->claimCalls, '이후 실행은 원장을 먼저 읽어 이미 처리된 쌍에 claim 을 시도하지 않는다'); } public function testWebhookRefusesNonHttpSchemes(): void { $c = new Channels('https://example.com'); $this->expectException(\RuntimeException::class); $c->send($this->event(), $this->channel(['kind' => 'webhook', 'config_json' => '{"url":"file:///etc/passwd"}'])); } }