Test camera.ts: connection handling, errors and snapshot auth
Adds 47 tests for camera.ts (vrek iss-qak2mz8), bringing it to 100% line coverage. A scripted stand-in for the onvif Cam class drives the connect outcomes: profiles, warnings, inactive devices, and login errors. Snapshots run over real HTTP against a local server with real Digest and Basic 401 challenges. Also covers isAllowedHost edge cases. Overall line coverage is 72.4% (143 tests). Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33c12ea70c
commit
31026559b7
@ -229,3 +229,9 @@
|
||||
{"id":"evt-pqwgd02ntpka","type":"edge.added","subject":"ver-d925rqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-d925rqw","to":"iss-qbh3541"},"at":"2026-09-19T14:03:00.600Z","parents":["evt-s95vt9k8f63d"],"hash":"13c905b15e00750c6d5380978fad98e4e9ecc1adfee1ed1b3880b712cdbb4052"}
|
||||
{"id":"evt-41e116z167g3","type":"verification.recorded","subject":"ver-d925rqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Children iss-a0hz0py, iss-rjqy3hy, iss-dbwgww8, iss-2fm6x2y, iss-ksxmctm, iss-m032zwq, iss-8hfq2y2 and iss-tz5s098 are all done with passing evidence. The only fetch calls in client code are in src/app/camera-queries.ts, wrapped in React Query; the only useEffect in camera-card.tsx manages object URLs, not polling. page.tsx has no 'use client', and next build lists `ƒ /`. The user manually tested the login flows in a browser, 2026-09-19."},"at":"2026-09-19T14:03:00.601Z","parents":["evt-pqwgd02ntpka"],"hash":"f1cffc889f8bf493746e287f36ab195418460ccfc909c39975f11126ffe74888"}
|
||||
{"id":"evt-kd9pxqn4bcd8","type":"node.status_changed","subject":"iss-qbh3541","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:03:01.991Z","parents":["evt-41e116z167g3"],"hash":"b22b1be718202b8f41e5491d630fa52dd1f46083789a98c8a89a4aa273bb796d"}
|
||||
{"id":"evt-kan6asvzm60c","type":"node.created","subject":"ver-rqk3ktd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"camera.ts is at 100% line coverage. Tests cover info mapping (Media1 and Media2 profiles), connection reuse and resetConnection, inactive/auth/missing-login/no-profile failures, testCredentials not caching, snapshot fetch from the target address, Digest and Basic challenges, a refused login, HTTP errors, an unreachable host, snapshot URI caching and failure recovery, and isAllowedHost edge cases.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:07:14.315Z","parents":["evt-kd9pxqn4bcd8"],"hash":"56ec4ef908aebc91798a7244c7e114ac806e30c8f4c7042c7563c2d3f0442f43"}
|
||||
{"id":"evt-j9mdfr9msk1v","type":"edge.added","subject":"ver-rqk3ktd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-rqk3ktd","to":"iss-qak2mz8"},"at":"2026-09-19T14:07:14.317Z","parents":["evt-kan6asvzm60c"],"hash":"6b0824745dde7fa28c6b00f93afd25f1f7032929b2c02d7c0ab525c21be44e45"}
|
||||
{"id":"evt-2amh1dbbb8v2","type":"verification.recorded","subject":"ver-rqk3ktd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/camera.test.ts, 47 tests; `npx vitest run src/lib/camera.test.ts --coverage --coverage.include=src/lib/camera.ts` gives 100% lines and 93.3% branches, 2026-09-19. Deviation from the issue text: the onvif Cam class is a scripted test double (vi.mock) rather than a fake SOAP server. Emulating onvif's SOAP parsing would test the library rather than our code. Snapshots use a real node:http server on 127.0.0.1 with real 401 challenges. Not covered: the 10 s socket-timeout callback, and the https branch (no self-signed cert without a new dependency). Full suite 143/143, tsc and eslint clean."},"at":"2026-09-19T14:07:14.318Z","parents":["evt-j9mdfr9msk1v"],"hash":"8e594820c4c2eab47b002b459d9c02cd6ab391e3e4f8710f50016807e4f62f37"}
|
||||
{"id":"evt-vpr581bxp7sw","type":"node.status_changed","subject":"iss-qak2mz8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:07:15.570Z","parents":["evt-2amh1dbbb8v2"],"hash":"c83dad23d807ecca00d847f3a30a682df460eba2e5ec6c65d2f3779232aef003"}
|
||||
{"id":"evt-2gyj0y16gz93","type":"node.created","subject":"mea-prapanf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Line coverage of src/** (excluding page.tsx, *.d.ts, tests)","body":"`npm run coverage`, 2026-09-19, after camera.ts tests (iss-qak2mz8): 317/438 lines, 143 tests. camera.ts is now at 100%. Remaining gaps: onvif.ts (76 lines), the info/snapshot/credentials routes (36), layout and providers (8), camera-registry (1).","status":"recorded","owner":null,"attrs":{"value":72.37,"applies_at":"2026-09-19T14:07:16.619Z"}},"at":"2026-09-19T14:07:16.619Z","parents":["evt-vpr581bxp7sw"],"hash":"80890f1f1c559bca0f6f33fc114338073510bc94fa2fd84153c123a19f1cf9a8"}
|
||||
{"id":"evt-f17cnwzvhjqf","type":"edge.added","subject":"mea-prapanf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-prapanf","to":"gol-9zxah3p"},"at":"2026-09-19T14:07:16.621Z","parents":["evt-2gyj0y16gz93"],"hash":"e26fa3671b95cc3b9dae9ffcf894da0d704f61588d0b9111e25fce22e0b6ed7d"}
|
||||
|
||||
426
src/lib/camera.test.ts
Normal file
426
src/lib/camera.test.ts
Normal file
@ -0,0 +1,426 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Credentials } from "./credential-store";
|
||||
|
||||
/**
|
||||
* camera.ts drives the `onvif` library's Cam class; a scripted stand-in replaces it so each
|
||||
* test decides what the "camera" answers during connect. Snapshots go over real HTTP to a
|
||||
* local server, so the Digest/Basic challenge handling runs for real.
|
||||
*/
|
||||
const fake = await vi.hoisted(async () => {
|
||||
// Hoisted above the imports, so load what it needs itself.
|
||||
const { EventEmitter } = await import("node:events");
|
||||
type Callback<T> = (err: Error | null, value?: T) => void;
|
||||
interface Script {
|
||||
connect: (cam: FakeCam, done: (err: Error | null) => void) => void;
|
||||
deviceInformation: (done: Callback<Record<string, string>>) => void;
|
||||
snapshotUri: (options: { profileToken?: string }, done: Callback<{ uri?: string }>) => void;
|
||||
}
|
||||
class FakeCam extends EventEmitter {
|
||||
static instances: FakeCam[] = [];
|
||||
static script: Script;
|
||||
profiles?: unknown[];
|
||||
constructor(readonly options: Record<string, unknown>) {
|
||||
super();
|
||||
FakeCam.instances.push(this);
|
||||
}
|
||||
connect(cb: (err: Error | null) => void) {
|
||||
FakeCam.script.connect(this, cb);
|
||||
}
|
||||
getDeviceInformation(cb: Callback<Record<string, string>>) {
|
||||
FakeCam.script.deviceInformation(cb);
|
||||
}
|
||||
getSnapshotUri(options: { profileToken?: string }, cb: Callback<{ uri?: string }>) {
|
||||
FakeCam.script.snapshotUri(options, cb);
|
||||
}
|
||||
digestAuth(challenges: string[], req: { method: string; path: string }) {
|
||||
return `Digest from=${challenges.length} ${req.method} ${req.path}`;
|
||||
}
|
||||
}
|
||||
return { FakeCam };
|
||||
});
|
||||
|
||||
vi.mock("onvif", () => ({ Cam: fake.FakeCam }));
|
||||
|
||||
const getCredentials = vi.fn<(id: string) => Promise<Credentials | null>>();
|
||||
vi.mock("./credential-store", () => ({ getCredentials }));
|
||||
|
||||
const { FakeCam } = fake;
|
||||
type Camera = typeof import("./camera");
|
||||
let camera: Camera;
|
||||
|
||||
const LOGIN = { username: "admin", password: "pw" };
|
||||
const media1Profile = {
|
||||
token: "p1",
|
||||
name: "Main",
|
||||
videoEncoderConfiguration: {
|
||||
encoding: "H264",
|
||||
resolution: { width: 1920, height: 1080 },
|
||||
rateControl: { frameRateLimit: 25 },
|
||||
},
|
||||
};
|
||||
const media2Profile = {
|
||||
$: { token: "p2" },
|
||||
name: "Sub",
|
||||
configurations: {
|
||||
videoEncoder: {
|
||||
encoding: "H265",
|
||||
resolution: { width: 640, height: 360 },
|
||||
rateControl: { $: { FrameRateLimit: 15 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// --- local snapshot server -------------------------------------------------------------
|
||||
|
||||
type SnapshotHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
||||
let snapshotHandler: SnapshotHandler;
|
||||
const snapshotRequests: { url?: string; authorization?: string }[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
snapshotRequests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
snapshotHandler(req, res);
|
||||
});
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
const jpeg: SnapshotHandler = (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "image/jpeg" });
|
||||
res.end("JPEGDATA");
|
||||
};
|
||||
|
||||
// The camera advertises an internal hostname; getSnapshot must use the target's address.
|
||||
const advertisedUri = () => `http://camera.internal:${port}/snap.jpg?channel=1`;
|
||||
|
||||
// --- per-test setup --------------------------------------------------------------------
|
||||
|
||||
const target = () => ({ id: "cam-1", host: "127.0.0.1", port });
|
||||
|
||||
function healthyCamera() {
|
||||
FakeCam.script = {
|
||||
connect: (cam, done) => {
|
||||
cam.profiles = [media1Profile, media2Profile];
|
||||
done(null);
|
||||
},
|
||||
deviceInformation: (done) =>
|
||||
done(null, {
|
||||
manufacturer: "Hikvision",
|
||||
model: "DS-2CD",
|
||||
firmwareVersion: "V5.7",
|
||||
serialNumber: "SN1",
|
||||
}),
|
||||
snapshotUri: (_options, done) => done(null, { uri: advertisedUri() }),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
FakeCam.instances = [];
|
||||
snapshotRequests.length = 0;
|
||||
snapshotHandler = jpeg;
|
||||
healthyCamera();
|
||||
getCredentials.mockReset().mockResolvedValue(LOGIN);
|
||||
vi.resetModules();
|
||||
camera = await import("./camera");
|
||||
});
|
||||
|
||||
// --- tests -----------------------------------------------------------------------------
|
||||
|
||||
describe("isAllowedHost", () => {
|
||||
it.each([
|
||||
["10.0.0.1", true],
|
||||
["10.255.255.255", true],
|
||||
["172.16.0.1", true],
|
||||
["172.31.255.254", true],
|
||||
["192.168.1.10", true],
|
||||
["172.15.0.1", false],
|
||||
["172.32.0.1", false],
|
||||
["192.169.1.1", false],
|
||||
["8.8.8.8", false],
|
||||
["127.0.0.1", false],
|
||||
["169.254.1.1", false],
|
||||
["10.0.0.256", false],
|
||||
["999.1.1.1", false],
|
||||
["10.0.0", false],
|
||||
["::1", false],
|
||||
["fe80::1", false],
|
||||
["camera.local", false],
|
||||
[" 10.0.0.1", false],
|
||||
])("%s → %s", (host, allowed) => {
|
||||
expect(camera.isAllowedHost(host)).toBe(allowed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCameraInfo", () => {
|
||||
it("connects with the stored login and maps device info and both profile formats", async () => {
|
||||
const info = await camera.getCameraInfo(target());
|
||||
|
||||
expect(info).toEqual({
|
||||
manufacturer: "Hikvision",
|
||||
model: "DS-2CD",
|
||||
firmwareVersion: "V5.7",
|
||||
serialNumber: "SN1",
|
||||
profiles: [
|
||||
{ token: "p1", name: "Main", encoding: "H264", width: 1920, height: 1080, fps: 25 },
|
||||
{ token: "p2", name: "Sub", encoding: "H265", width: 640, height: 360, fps: 15 },
|
||||
],
|
||||
});
|
||||
expect(getCredentials).toHaveBeenCalledWith("cam-1");
|
||||
expect(FakeCam.instances[0].options).toMatchObject({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
...LOGIN,
|
||||
preserveAddress: true,
|
||||
autoconnect: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("still returns profiles when device information fails", async () => {
|
||||
FakeCam.script.deviceInformation = (done) => done(new Error("not supported"));
|
||||
const info = await camera.getCameraInfo(target());
|
||||
expect(info.manufacturer).toBeUndefined();
|
||||
expect(info.profiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("treats a missing device-information body as empty", async () => {
|
||||
FakeCam.script.deviceInformation = (done) => done(null);
|
||||
expect((await camera.getCameraInfo(target())).model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses one connection across requests", async () => {
|
||||
await camera.getCameraInfo(target());
|
||||
await camera.getCameraInfo(target());
|
||||
expect(FakeCam.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reconnects after resetConnection", async () => {
|
||||
await camera.getCameraInfo(target());
|
||||
camera.resetConnection(target());
|
||||
await camera.getCameraInfo(target());
|
||||
expect(FakeCam.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("when connecting fails", () => {
|
||||
it("reports an unactivated camera, whatever else went wrong", async () => {
|
||||
FakeCam.script.connect = (cam, done) => {
|
||||
cam.emit("rawResponse", "<Fault>The device is inactive</Fault>");
|
||||
done(new Error("ONVIF SOAP Fault"));
|
||||
};
|
||||
await expect(camera.getCameraInfo(target())).rejects.toBeInstanceOf(
|
||||
camera.CameraInactiveError,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores unrelated raw responses", async () => {
|
||||
FakeCam.script.connect = (cam, done) => {
|
||||
cam.emit("rawResponse", "<ok/>");
|
||||
cam.profiles = [media1Profile];
|
||||
done(null);
|
||||
};
|
||||
expect((await camera.getCameraInfo(target())).profiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Digest authentication failed 401",
|
||||
"ONVIF SOAP Fault: Sender not Authorized",
|
||||
"Unauthorized",
|
||||
])("turns '%s' into a CameraAuthError", async (message) => {
|
||||
FakeCam.script.connect = (_cam, done) => done(new Error(message));
|
||||
const err = await camera.getCameraInfo(target()).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(camera.CameraAuthError);
|
||||
expect(err.missingLogin).toBe(false);
|
||||
});
|
||||
|
||||
it("passes other connection errors through unchanged", async () => {
|
||||
const failure = new Error("connect ECONNREFUSED");
|
||||
FakeCam.script.connect = (_cam, done) => done(failure);
|
||||
await expect(camera.getCameraInfo(target())).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it("says no login is saved when there are no credentials and no profiles", async () => {
|
||||
getCredentials.mockResolvedValue(null);
|
||||
FakeCam.script.connect = (_cam, done) => done(null);
|
||||
const err = await camera.getCameraInfo(target()).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(camera.CameraAuthError);
|
||||
expect(err.missingLogin).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an auth warning with no profiles as a rejected login", async () => {
|
||||
FakeCam.script.connect = (cam, done) => {
|
||||
cam.emit("warning", "ONVIF SOAP Fault: NotAuthorized");
|
||||
cam.profiles = [];
|
||||
done(null);
|
||||
};
|
||||
const err = await camera.getCameraInfo(target()).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(camera.CameraAuthError);
|
||||
expect(err.missingLogin).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no profiles, with any warning, as a plain error", async () => {
|
||||
FakeCam.script.connect = (cam, done) => {
|
||||
cam.emit("warning", "Optional action not implemented");
|
||||
done(null);
|
||||
};
|
||||
const err = await camera.getCameraInfo(target()).catch((e) => e);
|
||||
expect(err).not.toBeInstanceOf(camera.CameraAuthError);
|
||||
expect(err.message).toBe("Camera returned no media profiles: Optional action not implemented");
|
||||
});
|
||||
|
||||
it("reports no profiles without a warning", async () => {
|
||||
FakeCam.script.connect = (_cam, done) => done(null);
|
||||
await expect(camera.getCameraInfo(target())).rejects.toThrow(
|
||||
/^Camera returned no media profiles$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't cache a failed connection", async () => {
|
||||
FakeCam.script.connect = (_cam, done) => done(new Error("boom"));
|
||||
await expect(camera.getCameraInfo(target())).rejects.toThrow("boom");
|
||||
healthyCamera();
|
||||
await camera.getCameraInfo(target());
|
||||
expect(FakeCam.instances).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("testCredentials", () => {
|
||||
it("tries the given login without caching the connection", async () => {
|
||||
await camera.testCredentials(target(), { username: "new", password: "secret" });
|
||||
expect(FakeCam.instances[0].options).toMatchObject({ username: "new", password: "secret" });
|
||||
expect(getCredentials).not.toHaveBeenCalled();
|
||||
|
||||
await camera.getCameraInfo(target());
|
||||
expect(FakeCam.instances).toHaveLength(2);
|
||||
expect(FakeCam.instances[1].options).toMatchObject(LOGIN);
|
||||
});
|
||||
|
||||
it("rejects a login the camera refuses", async () => {
|
||||
FakeCam.script.connect = (_cam, done) => done(new Error("Digest authentication failed 401"));
|
||||
await expect(
|
||||
camera.testCredentials(target(), { username: "bad", password: "x" }),
|
||||
).rejects.toBeInstanceOf(camera.CameraAuthError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSnapshot", () => {
|
||||
it("fetches the frame from the camera's address, not the advertised hostname", async () => {
|
||||
const snap = await camera.getSnapshot(target());
|
||||
expect(snap).toEqual({ contentType: "image/jpeg", body: Buffer.from("JPEGDATA") });
|
||||
expect(snapshotRequests).toEqual([{ url: "/snap.jpg?channel=1", authorization: undefined }]);
|
||||
});
|
||||
|
||||
it("defaults the content type to image/jpeg", async () => {
|
||||
snapshotHandler = (_req, res) => {
|
||||
res.removeHeader("Content-Type");
|
||||
res.end("RAW");
|
||||
};
|
||||
expect((await camera.getSnapshot(target())).contentType).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("asks for the requested profile and caches its URI", async () => {
|
||||
const snapshotUri = vi.fn<typeof FakeCam.script.snapshotUri>((_o, done) =>
|
||||
done(null, { uri: advertisedUri() }),
|
||||
);
|
||||
FakeCam.script.snapshotUri = snapshotUri;
|
||||
|
||||
await camera.getSnapshot(target(), "p2");
|
||||
await camera.getSnapshot(target(), "p2");
|
||||
await camera.getSnapshot(target());
|
||||
|
||||
expect(snapshotUri.mock.calls.map(([options]) => options)).toEqual([
|
||||
{ profileToken: "p2" },
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers a Digest challenge using the camera's digest implementation", async () => {
|
||||
snapshotHandler = (req, res) => {
|
||||
if (!req.headers.authorization) {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Digest realm="IP Camera", nonce="abc", qop="auth"' });
|
||||
return res.end();
|
||||
}
|
||||
jpeg(req, res);
|
||||
};
|
||||
await camera.getSnapshot(target());
|
||||
expect(snapshotRequests[1].authorization).toBe("Digest from=1 GET /snap.jpg?channel=1");
|
||||
});
|
||||
|
||||
it("answers a Basic challenge with the stored login", async () => {
|
||||
snapshotHandler = (req, res) => {
|
||||
if (!req.headers.authorization) {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="cam"' });
|
||||
return res.end();
|
||||
}
|
||||
jpeg(req, res);
|
||||
};
|
||||
await camera.getSnapshot(target());
|
||||
expect(snapshotRequests[1].authorization).toBe(
|
||||
`Basic ${Buffer.from("admin:pw").toString("base64")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends an empty Basic login when none is stored", async () => {
|
||||
let calls = 0;
|
||||
getCredentials.mockImplementation(async () => (calls++ === 0 ? LOGIN : null));
|
||||
snapshotHandler = (req, res) => {
|
||||
if (!req.headers.authorization) {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="cam"' });
|
||||
return res.end();
|
||||
}
|
||||
jpeg(req, res);
|
||||
};
|
||||
await camera.getSnapshot(target());
|
||||
expect(snapshotRequests[1].authorization).toBe(`Basic ${Buffer.from(":").toString("base64")}`);
|
||||
});
|
||||
|
||||
it("rejects a refused login and drops the cached connection", async () => {
|
||||
snapshotHandler = (_req, res) => {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="cam"' });
|
||||
res.end();
|
||||
};
|
||||
await expect(camera.getSnapshot(target())).rejects.toBeInstanceOf(camera.CameraAuthError);
|
||||
|
||||
snapshotHandler = jpeg;
|
||||
await camera.getSnapshot(target());
|
||||
expect(FakeCam.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reports other HTTP failures", async () => {
|
||||
snapshotHandler = (_req, res) => {
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
};
|
||||
await expect(camera.getSnapshot(target())).rejects.toThrow(
|
||||
"Snapshot request failed with HTTP 503",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when the snapshot server can't be reached", async () => {
|
||||
const closed = http.createServer();
|
||||
await new Promise<void>((resolve) => closed.listen(0, "127.0.0.1", resolve));
|
||||
const deadPort = (closed.address() as AddressInfo).port;
|
||||
await new Promise<void>((resolve) => closed.close(() => resolve()));
|
||||
FakeCam.script.snapshotUri = (_o, done) =>
|
||||
done(null, { uri: `http://camera.internal:${deadPort}/snap.jpg` });
|
||||
|
||||
await expect(camera.getSnapshot(target())).rejects.toThrow(/ECONNREFUSED/);
|
||||
});
|
||||
|
||||
describe("when the camera gives no snapshot URI", () => {
|
||||
it.each([
|
||||
["an error", (done: (err: Error | null, v?: { uri?: string }) => void) => done(new Error("fault"))],
|
||||
["an empty answer", (done: (err: Error | null, v?: { uri?: string }) => void) => done(null, {})],
|
||||
])("fails on %s, drops the connection, and retries next time", async (_label, answer) => {
|
||||
FakeCam.script.snapshotUri = (_o, done) => answer(done);
|
||||
await expect(camera.getSnapshot(target())).rejects.toThrow(/fault|no snapshot URI/);
|
||||
|
||||
healthyCamera();
|
||||
await camera.getSnapshot(target());
|
||||
expect(FakeCam.instances).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user