cfac14e09e
Cameras (Hikvision) return `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is malformed, and browsers refuse to decode an <img> declared that way. Old capture code persisted that raw header into snapshots.content_type (100/101 dev-DB rows); GET /api/snapshots/:id re-emitted it verbatim, so every legacy snapshot rendered blank in the booth modal. Capture was already hardened (encodeForStorage re-encodes to a clean image/jpeg, fail-soft via cleanType), but the serve route trusted the stored value. Export cleanType and apply it when setting the response header, so a bare image/jpeg is sent regardless of what was stored — un-breaks all legacy rows with no data migration. A stored value from an untrusted device is itself input; normalize on capture AND on serve. Adds cleanType unit tests. Verified: a previously-unrenderable 2560x1440 row now decodes in-browser. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
159 lines
7.2 KiB
TypeScript
159 lines
7.2 KiB
TypeScript
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<Snapshot> {
|
||
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<Buffer> {
|
||
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");
|
||
});
|
||
});
|