import { describe, expect, it, vi } from "vitest"; import sharp from "sharp"; import type { CameraDevice, Snapshot } from "@parking/devices"; import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js"; import { silentLogger } from "./test-helpers.js"; // captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves // snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR // bridge AND the advisory snapshotAsync both capture the same camera within ~1s, each // from a SEPARATE adapter instance — so this deviceId-keyed cache coalesces in-flight // captures and serves a brief freshness window, collapsing the two into one real pull. // (Root cause of the slow 2026-06-25 subscriber entry.) /** A fake camera whose captureSnapshot is controllable (count calls, delay, fail). */ function fakeCamera(opts: { delayMs?: number; fail?: boolean; tag?: string } = {}): { camera: CameraDevice; calls: () => number; } { let calls = 0; const tag = opts.tag ?? "x"; const camera = { async captureSnapshot(): Promise { calls++; if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs)); if (opts.fail) throw new Error("HTTP 503"); // Tag distinguishes frames from different cameras (the per-camera keying test). return { bytes: Buffer.from(`shot-${tag}-${calls}`), contentType: "image/jpeg", capturedAt: new Date().toISOString() }; }, } as unknown as CameraDevice; return { camera, calls: () => calls }; } /** A unique deviceId per test so the module-level cache never bleeds across cases. */ function id(): string { return `cam-${Math.random().toString(36).slice(2)}`; } describe("captureSnapshotShared", () => { it("coalesces CONCURRENT captures into a single hardware pull (the 503 fix)", async () => { const { camera, calls } = fakeCamera({ delayMs: 20 }); const dev = id(); // The bridge and the advisory path fire at nearly the same instant. const [a, b] = await Promise.all([ captureSnapshotShared(dev, camera, { direction: "entry" }), captureSnapshotShared(dev, camera, { direction: "entry" }), ]); expect(calls()).toBe(1); // ONE GET, not two — no concurrent 503 expect(a.bytes.equals(b.bytes)).toBe(true); // both got the same frame }); it("reuses a fresh capture within the TTL (sequential, same vehicle)", async () => { const { camera, calls } = fakeCamera(); const dev = id(); const a = await captureSnapshotShared(dev, camera, { direction: "entry" }); const b = await captureSnapshotShared(dev, camera, { direction: "entry" }); // ~0ms later expect(calls()).toBe(1); // 2nd call served from the freshness cache expect(a.bytes.equals(b.bytes)).toBe(true); }); it("pulls AGAIN after the TTL lapses (a later, different vehicle)", async () => { vi.useFakeTimers(); try { const { camera, calls } = fakeCamera(); const dev = id(); await captureSnapshotShared(dev, camera, { direction: "entry" }); expect(calls()).toBe(1); await vi.advanceTimersByTimeAsync(2000); // past SNAPSHOT_TTL_MS (1500) await captureSnapshotShared(dev, camera, { direction: "entry" }); expect(calls()).toBe(2); // stale → a real new pull (never a stale frame for a new car) } finally { vi.useRealTimers(); } }); it("does NOT cache a failure — the next caller retries", async () => { const dev = id(); const failing = fakeCamera({ fail: true }); await expect(captureSnapshotShared(dev, failing.camera, { direction: "entry" })).rejects.toThrow("503"); // A subsequent capture (camera recovered) must actually pull, not inherit the error. const ok = fakeCamera(); const shot = await captureSnapshotShared(dev, ok.camera, { direction: "entry" }); expect(shot.bytes.toString()).toBe("shot-x-1"); expect(ok.calls()).toBe(1); }); it("keys by deviceId — different cameras never share a frame", async () => { const c1 = fakeCamera({ tag: "A" }); const c2 = fakeCamera({ tag: "B" }); const s1 = await captureSnapshotShared("cam-A", c1.camera, { direction: "entry" }); const s2 = await captureSnapshotShared("cam-B", c2.camera, { direction: "entry" }); expect(c1.calls()).toBe(1); expect(c2.calls()).toBe(1); expect(s1.bytes.equals(s2.bytes)).toBe(false); }); }); // encodeForStorage: downscale + re-compress a captured frame for STORAGE (smaller, plate // still readable). Recognition uses the original; this never runs on the OCR path. Fail-soft. describe("encodeForStorage", () => { /** A big synthetic JPEG (2688×1520, the Hikvision main-stream size) to downscale. */ async function bigJpeg(): Promise { return sharp({ create: { width: 2688, height: 1520, channels: 3, background: { r: 120, g: 130, b: 140 } }, }) .jpeg({ quality: 95 }) .toBuffer(); } it("downscales the long edge to ≤1280 and emits clean image/jpeg", async () => { const bytes = await bigJpeg(); const shot: Snapshot = { bytes, contentType: 'image/jpeg; charset="UTF-8"', capturedAt: new Date().toISOString() }; const out = await encodeForStorage(shot, silentLogger()); expect(out.contentType).toBe("image/jpeg"); // charset cruft stripped const meta = await sharp(out.bytes).metadata(); expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1280); expect(out.bytes.length).toBeLessThan(bytes.length); // smaller than the original }); it("never enlarges an already-small image", async () => { const small = await sharp({ create: { width: 640, height: 360, channels: 3, background: { r: 0, g: 0, b: 0 } } }) .jpeg() .toBuffer(); const out = await encodeForStorage( { bytes: small, contentType: "image/jpeg", capturedAt: new Date().toISOString() }, silentLogger(), ); const meta = await sharp(out.bytes).metadata(); expect(meta.width).toBe(640); // withoutEnlargement expect(meta.height).toBe(360); }); it("fails soft: a non-image body is stored unchanged with a cleaned type", async () => { const garbage = Buffer.from("this is not an image"); const out = await encodeForStorage( { bytes: garbage, contentType: 'text/plain; charset="UTF-8"', capturedAt: new Date().toISOString() }, silentLogger(), ); expect(out.bytes.equals(garbage)).toBe(true); // original bytes, never dropped expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback }); }); describe("cleanType", () => { it("strips a camera's charset cruft so a binary JPEG renders", () => { // The exact malformed value some cameras (Hikvision) return, which broke the // snapshot strip for every legacy row until the serve route normalized it. expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg"); expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg"); }); it("passes a clean type through and defaults a missing one", () => { expect(cleanType("image/jpeg")).toBe("image/jpeg"); expect(cleanType("image/png")).toBe("image/png"); expect(cleanType(null)).toBe("image/jpeg"); expect(cleanType(undefined)).toBe("image/jpeg"); expect(cleanType("")).toBe("image/jpeg"); }); });