import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DigestGetResult } from "./http-digest.js"; // The HTTP layer is mocked so the camera driver's RETRY logic is tested without a // network. Hikvision returns 503 "Device Busy" (sometimes 500) transiently when its // snapshot encoder is occupied — captureSnapshot must retry those and succeed, but // fail FAST on a config error (401 auth / 404 path). See camera.ts. const digestGet = vi.fn<(...a: unknown[]) => Promise>(); vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) })); // Import the driver AFTER the mock is registered. const { hikvisionDriver } = await import("./camera.js"); function reply(status: number, body = "jpeg-bytes"): DigestGetResult { return { status, contentType: "image/jpeg", body: Buffer.from(body) }; } function makeCamera() { return hikvisionDriver.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 }); } beforeEach(() => { digestGet.mockReset(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe("hikvision captureSnapshot — 503 Device Busy retry", () => { it("retries a transient 503 and succeeds", async () => { digestGet .mockResolvedValueOnce(reply(503)) .mockResolvedValueOnce(reply(503)) .mockResolvedValueOnce(reply(200, "the-frame")); const cam = makeCamera(); const p = cam.captureSnapshot({ direction: "entry" }); await vi.runAllTimersAsync(); // let the backoff sleeps fire const shot = await p; expect(shot.bytes.toString()).toBe("the-frame"); expect(digestGet).toHaveBeenCalledTimes(3); // 503, 503, 200 }); it("also retries a transient 500", async () => { digestGet.mockResolvedValueOnce(reply(500)).mockResolvedValueOnce(reply(200)); const cam = makeCamera(); const p = cam.captureSnapshot({ direction: "entry" }); await vi.runAllTimersAsync(); await p; expect(digestGet).toHaveBeenCalledTimes(2); }); it("gives up after the attempt cap, naming it 'device busy'", async () => { digestGet.mockResolvedValue(reply(503)); // always busy const cam = makeCamera(); // Attach the rejection assertion BEFORE flushing timers so the rejection always // has a handler (no unhandled-rejection noise), then drive the backoff sleeps. const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 503 \(device busy\)/); await vi.runAllTimersAsync(); await assertion; expect(digestGet).toHaveBeenCalledTimes(4); // SNAPSHOT_MAX_ATTEMPTS }); it("does NOT retry a 401 (auth error self-won't-heal) — fails fast", async () => { digestGet.mockResolvedValue(reply(401)); const cam = makeCamera(); const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 401/); await vi.runAllTimersAsync(); await assertion; expect(digestGet).toHaveBeenCalledTimes(1); // no retry }); it("does NOT retry a 404 (wrong path/channel) — fails fast", async () => { digestGet.mockResolvedValue(reply(404)); const cam = makeCamera(); const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 404/); await vi.runAllTimersAsync(); await assertion; expect(digestGet).toHaveBeenCalledTimes(1); }); it("succeeds first try with no retry on a clean 200", async () => { digestGet.mockResolvedValue(reply(200)); const cam = makeCamera(); const shot = await cam.captureSnapshot({ direction: "entry" }); expect(shot.contentType).toBe("image/jpeg"); expect(digestGet).toHaveBeenCalledTimes(1); }); }); describe("hikvision snapshot stream selection (main vs sub)", () => { function pathFor(config: Record): string { digestGet.mockReset(); digestGet.mockResolvedValue(reply(200)); hikvisionDriver.create(config as never).captureSnapshot({ direction: "entry" }); return String((digestGet.mock.calls[0]![0] as { path: string }).path); } it("defaults to the MAIN stream (…/channels/101/picture) — back-compat", () => { expect(pathFor({ host: "1.2.3.4", channel: 1 })).toBe("/ISAPI/Streaming/channels/101/picture"); }); it("stream=2 selects the SUB stream (…/channels/102/picture) — the G3H 503 fix", () => { expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 2 })).toBe("/ISAPI/Streaming/channels/102/picture"); }); it("honours the channel number with the stream (ch2 sub = 202)", () => { expect(pathFor({ host: "1.2.3.4", channel: 2, stream: 2 })).toBe("/ISAPI/Streaming/channels/202/picture"); }); it("an invalid stream falls back to main (1)", () => { expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture"); }); });