프로젝트 8개 차원 감사(종합 82/100)에서 확정된 상위 3건 수정. 1. 개선 조치(move_actions)가 유일하게 site 스코프 규율에서 이탈 — 스코프 운영자가 타 현장의 조치 기록(카메라 ID·메모·작성자)을 목록에서 읽고, 타 현장 카메라에 조치를 기록해 그 현장 리포트의 전후 효과 지표를 오염시킬 수 있었다. - move_actions.site 컬럼 + (site, taken_at) 인덱스, 기존 행 백필 마이그레이션 - 카메라 소유 현장은 이벤트에서 해석 (move_cameras는 등록 플로우가 없어 항상 비어 있음) - 오타 카메라는 400: 전후 효과가 영원히 0/0이 되는 조치를 애초에 못 만들게 한다 - 타 현장 카메라는 404: 존재 여부를 숨기는 기존 규약 유지 2. README-UPLOAD 업그레이드 절차가 마이그레이션 7종 중 1종만 지시했다. move_ 접두 리네임을 건너뛰면 이후 전부 실패하고 전 요청이 500이 된다 — 순서·적용조건·재실행 비안전성을 표로 명시. 문서 4곳의 테이블 수(10→11)·비프리픽스 표기도 정정. 3. 두 납품 번들 재생성: 8/6 이후 4개 기능 커밋이 반영되지 않아 Report/Export/Actions 컨트롤러와 8/7 마이그레이션이 빠진 채였다. 엣지 소스 패리티도 회복. 검증: pytest 235 passed / PHPUnit 147 passed (PDO 4 skip), 번들 산출물 내부 검증 (28 클래스 해석·전 파일 php -l·SHA256SUMS 55행·휠 11개·HEF 다이제스트 일치).
163 lines
9.2 KiB
PHP
163 lines
9.2 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
|
|
use App\Http\Request;
|
|
use App\Auth\ArraySessionStore;
|
|
use App\Dashboard\{ExportController, ActionsController};
|
|
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryActionRepository};
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ExportActionsTest extends TestCase
|
|
{
|
|
private function sess(string $role = 'admin', ?string $site = null): ArraySessionStore
|
|
{
|
|
$s = new ArraySessionStore();
|
|
$s->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일 미만: 측정 중
|
|
}
|
|
}
|