Files
parking_solution/apps/server/src/snapshot.test.ts
T
julian cfac14e09e fix(snapshots): normalize content-type on serve so stored images render
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
2026-06-30 17:58:06 +02:00

159 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
});