Reach 99.8% test coverage and enforce a 95% floor

- Route tests for camera info, snapshot and credentials (vrek
  iss-nc2tj7c): input validation, store-only-after-verify, reconnects
  after login changes, and fixed error messages with no upstream text.
- Providers and layout tests (iss-nc5w0j8).
- Discovery tests for onvif.ts (iss-3bg4r6e): multicast parsing,
  unicast sweep batching and subnet limits, de-duplication and
  merging. They run on a fake network, replacing onvif Discovery,
  node:dgram and node:os, per new vrek principle pri-e14bahk
  (replaceable transport; tests never touch the real network).
- coverage.thresholds.lines = 95, so `npm run coverage` fails below
  the goal (iss-zjpc22k).

200 tests, 99.77% line coverage. Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 09:17:55 -05:00
parent 31026559b7
commit 4931c3af83
10 changed files with 776 additions and 3 deletions

View File

@ -235,3 +235,26 @@
{"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"}
{"id":"evt-d7btd1jh3x6m","type":"node.created","subject":"ver-8ah7n8j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The info, snapshot and credentials routes are at 100% line coverage. Tests cover bad id → 400, unknown → 404, info passthrough, snapshot bytes plus Content-Type, Cache-Control no-store and ?profile, GET without a password, PUT validation with zod (5 bad-body cases, no camera contact), testCredentials before setCredentials, a rejected login never stored, resetConnection, DELETE, and fixed error messages that never echo upstream text.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:10:26.665Z","parents":["evt-f17cnwzvhjqf"],"hash":"b08d82d4a692c8484e5f4ea05e65cfc854f8dd32077c9d7d095a6365e87c8592"}
{"id":"evt-hbz7w2ryaycd","type":"edge.added","subject":"ver-8ah7n8j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-8ah7n8j","to":"iss-nc2tj7c"},"at":"2026-09-19T14:10:26.667Z","parents":["evt-d7btd1jh3x6m"],"hash":"b6154eeba8b209b02119b534cea98ef1e943d96ecafa0be356bcc7f00905cc52"}
{"id":"evt-fwvf11neacg5","type":"verification.recorded","subject":"ver-8ah7n8j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/api/cameras/[id]/{info,snapshot,credentials}/route.test.ts with shared fixtures in test/camera-routes.ts; `npx vitest run src/app/api/cameras` passes. Full suite 170/170, coverage 82.42% lines, tsc and eslint clean, 2026-09-19."},"at":"2026-09-19T14:10:26.668Z","parents":["evt-hbz7w2ryaycd"],"hash":"655d82d5f8c58b7ecdabb8e3a4bf3c6467a30c22aab7d081d05fa6509689e353"}
{"id":"evt-jhdv7dna7xdb","type":"node.created","subject":"ver-kvtf8ek","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"providers.tsx and layout.tsx are at 100% line coverage: Providers gives children a QueryClient with staleTime 0 and retry 1, stable across re-renders; RootLayout applies both font variables, wraps children in Providers, and exports the page metadata.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:10:28.551Z","parents":["evt-fwvf11neacg5"],"hash":"21b79d3b3b272c2273e3617ce157899b0062c166be43983bce1857d5a101406e"}
{"id":"evt-3gjva3dbwp6s","type":"edge.added","subject":"ver-kvtf8ek","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-kvtf8ek","to":"iss-nc5w0j8"},"at":"2026-09-19T14:10:28.553Z","parents":["evt-jhdv7dna7xdb"],"hash":"e7ff4025cc3047584fe405151ac225678c5069f6e41f66d242956e99dbd51f36"}
{"id":"evt-99j1aj1ga9yq","type":"verification.recorded","subject":"ver-kvtf8ek","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/providers.test.tsx (jsdom) and src/app/layout.test.tsx (renderToStaticMarkup with next/font/google, globals.css and ./providers mocked). Full suite 170/170, 2026-09-19."},"at":"2026-09-19T14:10:28.554Z","parents":["evt-3gjva3dbwp6s"],"hash":"ed7d00c2419cb8900552e9143fc8c68208893d36b3405896e3f798717faf1ab9"}
{"id":"evt-ryb2rh1a8xb9","type":"node.status_changed","subject":"iss-nc2tj7c","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:10:29.575Z","parents":["evt-99j1aj1ga9yq"],"hash":"ded9c0616ec8364ded4539303ac715fea08a291935eb0cee6466614774e7f5b7"}
{"id":"evt-8qw1mcgcjck1","type":"node.status_changed","subject":"iss-nc5w0j8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:10:30.712Z","parents":["evt-ryb2rh1a8xb9"],"hash":"4c1c6328d74246f4a8244414db67faac3a498f07429f24da71ea42bc913acb73"}
{"id":"evt-bmpbynnw50md","type":"node.created","subject":"mea-hbkgsng","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 the route, layout and providers tests (iss-nc2tj7c, iss-nc5w0j8): 361/438 lines, 170 tests. Every file is at 100% except onvif.ts (7.3%, 76 lines) and camera-registry.ts (96.8%, 1 line).","status":"recorded","owner":null,"attrs":{"value":82.42,"applies_at":"2026-09-19T14:10:31.945Z"}},"at":"2026-09-19T14:10:31.945Z","parents":["evt-8qw1mcgcjck1"],"hash":"1db162dfb571958670e0b0b30d3204b2e8a74fed3b4d82aa4af201b9a7b2ffe8"}
{"id":"evt-9cst8tvwh4rv","type":"edge.added","subject":"mea-hbkgsng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-hbkgsng","to":"gol-9zxah3p"},"at":"2026-09-19T14:10:31.947Z","parents":["evt-bmpbynnw50md"],"hash":"f29ad1acdae0375d7d3ebeebc8da21d5661243cbce804e722b1df3af00cfb580"}
{"id":"evt-t0mkh4jxh4xg","type":"node.created","subject":"pri-e14bahk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"principle","title":"Network and device code uses a replaceable transport; tests never touch the real network or real cameras","body":"Code that talks to the network or to hardware (ONVIF/SOAP, WS-Discovery UDP, snapshot HTTP, interface enumeration) reaches it only through a transport tests can replace: an imported module (onvif, node:dgram, node:http, node:os) that a test swaps with vi.mock, or an explicitly injected parameter where module replacement isn't enough. Logic never hard-wires a socket in a way a test can't intercept. Tests use scripted fakes, such as a fake Cam, a FakeSocket that records sends, fixed interface lists, or local loopback HTTP servers, and never send traffic to the LAN or to real devices. Each test file that does this says so in a header comment naming this principle. Why: the app's core value is device I/O, which must be tested deterministically and quickly, and a test that probes the real LAN is flaky, slow and can disturb real cameras. Rules out: tests that need a real camera or network, and production code that opens sockets in ways a test can't replace. Accepted by the user on 2026-09-19. Current implementation: module replacement in src/lib/camera.test.ts and src/lib/onvif.test.ts, with no test-only parameters in production code.","status":"active","owner":"prn-q80g8mz","attrs":{},"weight":null},"at":"2026-09-19T14:16:30.087Z","parents":["evt-9cst8tvwh4rv"],"hash":"fc6c7888d94fa4bc9b5660111c816d89c3db1d943ccaca9631bfc76378d4c43d"}
{"id":"evt-xqqyfer28b0w","type":"edge.added","subject":"pri-e14bahk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-e14bahk","to":"gol-9zxah3p"},"at":"2026-09-19T14:16:30.088Z","parents":["evt-t0mkh4jxh4xg"],"hash":"e96a3df29fd539fdd802b88603edad1e50a750d0c88ad4a9f9b70d4ca304f394"}
{"id":"evt-86yc27j0mn11","type":"edge.added","subject":"pri-e14bahk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-e14bahk","to":"gol-6q1q5mr"},"at":"2026-09-19T14:16:30.089Z","parents":["evt-xqqyfer28b0w"],"hash":"e7b1c05610a16870dec21de87d0ba30035ea9ee824a7a41bb9e14cd0c83743ac"}
{"id":"evt-nrj7b4kpagfy","type":"node.created","subject":"ver-eyg08v6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"onvif.ts is at 100% line, branch and function coverage, with no real network traffic. Covered: multicast probe parsing (IPv4 XAddr preference, default port, scopes as a string or text node, endpoint fallback, name/hardware/location decoding, skipping bad shapes, partial parse errors, socket errors, the single error listener); the unicast sweep (hosts excluding self, port 3702, the probe message, batches of 64 per 15 ms, the /20 limit and /31 refusal, skipping loopback/IPv6/link-local/CIDR-less interfaces, de-duplicating hosts, parsing real ProbeMatches XML, duplicate and malformed replies, a socket error closing once and stopping sends); merging multicast and unicast results; the 5 s default; discoverRequestSchema.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:16:54.197Z","parents":["evt-86yc27j0mn11"],"hash":"65c0ad4ba94cd7337fd0240051ca784a3cabe8bfb54eb9dc7709bcbf4620a709"}
{"id":"evt-tym6m3ee1hty","type":"edge.added","subject":"ver-eyg08v6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-eyg08v6","to":"iss-3bg4r6e"},"at":"2026-09-19T14:16:54.200Z","parents":["evt-nrj7b4kpagfy"],"hash":"2a3d27d9faabddbd77a94882e7553b81c4a26ec0c35cb1186c6e716ab98e231d"}
{"id":"evt-17mwajn90f51","type":"verification.recorded","subject":"ver-eyg08v6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/onvif.test.ts, 30 tests; `npx vitest run src/lib/onvif.test.ts --coverage --coverage.include=src/lib/onvif.ts` gives 100/100/100, 2026-09-19. The network is replaced per pri-e14bahk: onvif Discovery, node:dgram (FakeSocket) and node:os networkInterfaces via vi.mock, with no production code change. Full suite 200/200, tsc and eslint clean."},"at":"2026-09-19T14:16:54.201Z","parents":["evt-tym6m3ee1hty"],"hash":"b638224eda01459955020e8d5f6f0775145529cdfc8acea9b87b3d8ce6a2b657"}
{"id":"evt-n0wsrk8gcjam","type":"node.status_changed","subject":"iss-3bg4r6e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:16:55.511Z","parents":["evt-17mwajn90f51"],"hash":"7c38b317239e7b5a1c78344a62770662d9712931976a0be6bc0f3209996009e4"}
{"id":"evt-xyakqc0x1w21","type":"node.created","subject":"mea-a5fjckt","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 the discovery tests (iss-3bg4r6e): 437/438 lines, 200 tests. The only uncovered line is in camera-registry.ts (96.8%). The 95% target is met; iss-zjpc22k (enforce the threshold) is now unblocked in practice.","status":"recorded","owner":null,"attrs":{"value":99.77,"applies_at":"2026-09-19T14:16:56.623Z"}},"at":"2026-09-19T14:16:56.623Z","parents":["evt-n0wsrk8gcjam"],"hash":"c641ecaf5a2f3de7ea54af6b7f2001c7d57e6d01ca19de6495e508ee26604d5d"}
{"id":"evt-pcaq1kmtr4bt","type":"edge.added","subject":"mea-a5fjckt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-a5fjckt","to":"gol-9zxah3p"},"at":"2026-09-19T14:16:56.624Z","parents":["evt-xyakqc0x1w21"],"hash":"d7d08d75d1290064414a35e60e5dde61af9c727923ceb374192d0fdf3ef05897"}
{"id":"evt-m1kfa935btnf","type":"node.created","subject":"ver-ata01qf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"vitest.config.mts sets coverage.thresholds.lines = 95; `npm run coverage` exits 0 at the current coverage and non-zero when coverage is below the threshold.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:17:42.237Z","parents":["evt-pcaq1kmtr4bt"],"hash":"3e18b5e97fd7879201938766444783ae98c43739c642c48225028e35c8bc3a7a"}
{"id":"evt-2xzed3xn0deh","type":"edge.added","subject":"ver-ata01qf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-ata01qf","to":"iss-zjpc22k"},"at":"2026-09-19T14:17:42.238Z","parents":["evt-m1kfa935btnf"],"hash":"78885766ed2ce868f7a734174ff90de45073c05061ccc499b76221e2076ef182"}
{"id":"evt-3prszpdhev6p","type":"verification.recorded","subject":"ver-ata01qf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"2026-09-19: `npm run coverage` exit=0 at 99.77% lines. `npx vitest run --coverage --coverage.thresholds.lines=100` exit=1 with \"ERROR: Coverage for lines (99.77%) does not meet global threshold (100%)\", which shows the check fails the run."},"at":"2026-09-19T14:17:42.239Z","parents":["evt-2xzed3xn0deh"],"hash":"0a506113db391f0bc0cda852d58e807b5a4e4a5bceca7d689171c28fdf807ea3"}
{"id":"evt-3s0p3p5jac04","type":"node.status_changed","subject":"iss-zjpc22k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:17:43.311Z","parents":["evt-3prszpdhev6p"],"hash":"af762768a2a8b2255f400662c7a83742c5f9e4039fdb7aa498de25fc922a0425"}

View File

@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ctx, ID, LEAKY, record, target, url } from "../../../../../../test/camera-routes";
const getCameraRecord = vi.fn();
const testCredentials = vi.fn();
const resetConnection = vi.fn();
const store = {
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
describeCredentials: vi.fn(),
};
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord,
}));
vi.mock("@/lib/camera", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera")>()),
testCredentials,
resetConnection,
}));
vi.mock("@/lib/credential-store", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/credential-store")>()),
...store,
}));
const { GET, PUT, DELETE } = await import("./route");
const { CameraAuthError } = await import("@/lib/camera");
const { CredentialStoreError } = await import("@/lib/credential-store");
const stored = { source: "stored", username: "admin", hasPassword: true };
function put(body: unknown, id?: string) {
return PUT(
new Request(url("credentials"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: typeof body === "string" ? body : JSON.stringify(body),
}),
ctx(id),
);
}
describe("/api/cameras/[id]/credentials", () => {
beforeEach(() => {
getCameraRecord.mockReset().mockResolvedValue(record);
testCredentials.mockReset().mockResolvedValue(undefined);
resetConnection.mockReset();
store.setCredentials.mockReset().mockResolvedValue(undefined);
store.deleteCredentials.mockReset().mockResolvedValue(undefined);
store.describeCredentials.mockReset().mockResolvedValue(stored);
});
describe("GET", () => {
it("describes the login without the password", async () => {
const res = await GET(new Request(url("credentials")), ctx());
expect(res.status).toBe(200);
expect(await res.json()).toEqual(stored);
expect(store.describeCredentials).toHaveBeenCalledWith(ID);
});
it("rejects an unknown camera", async () => {
getCameraRecord.mockResolvedValue(null);
expect((await GET(new Request(url("credentials")), ctx())).status).toBe(404);
});
it("reports an unreadable store with a fixed message", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
store.describeCredentials.mockRejectedValue(new CredentialStoreError(LEAKY));
const res = await GET(new Request(url("credentials")), ctx());
const text = await res.text();
expect(res.status).toBe(500);
expect(JSON.parse(text)).toMatchObject({ code: "store" });
expect(text).not.toContain("hunter2");
});
});
describe("PUT", () => {
it("tests the login on the camera, stores it, and drops the old connection", async () => {
const login = { username: "admin", password: "pw" };
const res = await put(login);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(stored);
expect(testCredentials).toHaveBeenCalledWith(target, login);
expect(store.setCredentials).toHaveBeenCalledWith(ID, login);
expect(resetConnection).toHaveBeenCalledWith(target);
expect(testCredentials.mock.invocationCallOrder[0]).toBeLessThan(
store.setCredentials.mock.invocationCallOrder[0],
);
});
it("never stores a login the camera rejects", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
testCredentials.mockRejectedValue(new CameraAuthError(LEAKY));
const res = await put({ username: "admin", password: "wrong" });
const text = await res.text();
expect(res.status).toBe(401);
expect(JSON.parse(text)).toEqual({
error: "The camera rejected the saved login",
code: "auth",
});
expect(text).not.toContain("hunter2");
expect(store.setCredentials).not.toHaveBeenCalled();
expect(resetConnection).not.toHaveBeenCalled();
});
it.each([
["an empty username", { username: "", password: "pw" }],
["a missing password", { username: "admin" }],
["wrong types", { username: 1, password: 2 }],
["an over-long username", { username: "a".repeat(65), password: "" }],
["a body that isn't JSON", "not json"],
])("rejects %s with 400 without contacting the camera", async (_label, body) => {
const res = await put(body);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Username is required" });
expect(testCredentials).not.toHaveBeenCalled();
expect(store.setCredentials).not.toHaveBeenCalled();
});
it("rejects a malformed ID before reading the body", async () => {
expect((await put({ username: "a", password: "b" }, "bad")).status).toBe(400);
expect(testCredentials).not.toHaveBeenCalled();
});
});
describe("DELETE", () => {
it("clears the stored login and drops the connection", async () => {
store.describeCredentials.mockResolvedValue({
source: "none",
username: null,
hasPassword: false,
});
const res = await DELETE(new Request(url("credentials"), { method: "DELETE" }), ctx());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ source: "none" });
expect(store.deleteCredentials).toHaveBeenCalledWith(ID);
expect(resetConnection).toHaveBeenCalledWith(target);
});
it("rejects an unknown camera", async () => {
getCameraRecord.mockResolvedValue(null);
const res = await DELETE(new Request(url("credentials"), { method: "DELETE" }), ctx());
expect(res.status).toBe(404);
expect(store.deleteCredentials).not.toHaveBeenCalled();
});
it("reports store failures with a fixed message", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
store.deleteCredentials.mockRejectedValue(new CredentialStoreError(LEAKY));
const res = await DELETE(new Request(url("credentials"), { method: "DELETE" }), ctx());
expect(res.status).toBe(500);
expect(await res.text()).not.toContain("hunter2");
});
});
});

View File

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-routes";
const getCameraRecord = vi.fn();
const getCameraInfo = vi.fn();
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord,
}));
vi.mock("@/lib/camera", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera")>()),
getCameraInfo,
}));
const { GET } = await import("./route");
const { CameraAuthError } = await import("@/lib/camera");
const get = (id?: string) => GET(new Request(url("info")), ctx(id));
describe("GET /api/cameras/[id]/info", () => {
beforeEach(() => {
getCameraRecord.mockReset().mockResolvedValue(record);
getCameraInfo.mockReset();
});
it("returns the camera's info for the registry address", async () => {
const info = { manufacturer: "Hikvision", model: "DS-2CD", profiles: [] };
getCameraInfo.mockResolvedValue(info);
const res = await get();
expect(res.status).toBe(200);
expect(await res.json()).toEqual(info);
expect(getCameraInfo).toHaveBeenCalledWith(target);
});
it("rejects a malformed ID before touching the camera", async () => {
const res = await get("not-an-id");
expect(res.status).toBe(400);
expect(getCameraInfo).not.toHaveBeenCalled();
});
it("returns 404 for an unknown camera", async () => {
getCameraRecord.mockResolvedValue(null);
expect((await get()).status).toBe(404);
expect(getCameraInfo).not.toHaveBeenCalled();
});
it("maps camera errors to fixed messages", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
getCameraInfo.mockRejectedValue(new CameraAuthError(LEAKY));
const res = await get();
const text = await res.text();
expect(res.status).toBe(401);
expect(JSON.parse(text)).toMatchObject({ code: "auth" });
expect(text).not.toContain("hunter2");
});
});

View File

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-routes";
const getCameraRecord = vi.fn();
const getSnapshot = vi.fn();
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord,
}));
vi.mock("@/lib/camera", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera")>()),
getSnapshot,
}));
const { GET } = await import("./route");
const get = (query = "", id?: string) => GET(new Request(url(`snapshot${query}`)), ctx(id));
describe("GET /api/cameras/[id]/snapshot", () => {
beforeEach(() => {
getCameraRecord.mockReset().mockResolvedValue(record);
getSnapshot
.mockReset()
.mockResolvedValue({ contentType: "image/jpeg", body: Buffer.from("JPEGDATA") });
});
it("streams the frame's bytes uncached", async () => {
const res = await get();
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(Buffer.from(await res.arrayBuffer()).toString()).toBe("JPEGDATA");
expect(getSnapshot).toHaveBeenCalledWith(target, undefined);
});
it("passes the ?profile param through", async () => {
await get("?profile=p2");
expect(getSnapshot).toHaveBeenCalledWith(target, "p2");
});
it("rejects a malformed ID before touching the camera", async () => {
expect((await get("", "../x")).status).toBe(400);
expect(getSnapshot).not.toHaveBeenCalled();
});
it("returns a generic 502 for unexpected failures", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
getSnapshot.mockRejectedValue(new Error(LEAKY));
const res = await get();
const text = await res.text();
expect(res.status).toBe(502);
expect(text).not.toContain("hunter2");
expect(text).not.toContain("SOAP");
});
});

38
src/app/layout.test.tsx Normal file
View File

@ -0,0 +1,38 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/font/google", () => ({
Geist: (opts: { variable: string }) => ({ variable: `v(${opts.variable})` }),
Geist_Mono: (opts: { variable: string }) => ({ variable: `v(${opts.variable})` }),
}));
vi.mock("./globals.css", () => ({}));
vi.mock("./providers", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="providers">{children}</div>
),
}));
const { default: RootLayout, metadata } = await import("./layout");
describe("RootLayout", () => {
const html = renderToStaticMarkup(
<RootLayout params={Promise.resolve({})}>
<main>page</main>
</RootLayout>,
);
it("applies both font variables to <html>", () => {
expect(html).toMatch(/<html lang="en" class="v\(--font-geist-sans\) v\(--font-geist-mono\) /);
});
it("wraps the page in the client Providers", () => {
expect(html).toContain('<div data-testid="providers"><main>page</main></div>');
});
it("sets the page metadata", () => {
expect(metadata).toEqual({
title: "ONVIF Cameras",
description: "Discover ONVIF cameras on the local network",
});
});
});

View File

@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import Providers from "./providers";
afterEach(cleanup);
describe("Providers", () => {
const seen: QueryClient[] = [];
function Probe() {
seen.push(useQueryClient());
return <span>child</span>;
}
it("renders children inside a QueryClient with the device-data defaults", () => {
const { getByText } = render(
<Providers>
<Probe />
</Providers>,
);
expect(getByText("child")).toBeTruthy();
expect(seen.at(-1)!.getDefaultOptions().queries).toMatchObject({ staleTime: 0, retry: 1 });
});
it("keeps the same client across re-renders", () => {
seen.length = 0;
const { rerender } = render(
<Providers>
<Probe />
</Providers>,
);
rerender(
<Providers>
<Probe />
</Providers>,
);
expect(seen).toHaveLength(2);
expect(seen[0]).toBe(seen[1]);
});
});

View File

@ -4,9 +4,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
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.
* FAKE NETWORK: these tests never reach a real camera (vrek principle pri-e14bahk,
* "replaceable transport"). 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 loopback 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.

371
src/lib/onvif.test.ts Normal file
View File

@ -0,0 +1,371 @@
/**
* FAKE NETWORK: these tests never send a packet (vrek principle pri-e14bahk, "replaceable
* transport"). The three things onvif.ts touches the network through are replaced:
* - onvif's Discovery (multicast probe) → scripted `probe`
* - node:dgram (unicast sweep socket) → FakeSocket that records sends
* - node:os networkInterfaces → a fixed list of interfaces
* Replies are real WS-Discovery XML, parsed by the real onvif/lib/utils parser.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const net = await vi.hoisted(async () => {
const { EventEmitter } = await import("node:events");
class FakeSocket extends EventEmitter {
static instances: FakeSocket[] = [];
sent: { message: string; port: number; host: string }[] = [];
closed = 0;
constructor(readonly type: string) {
super();
FakeSocket.instances.push(this);
}
bind(_port: number, cb: () => void) {
queueMicrotask(cb);
}
send(message: Buffer, port: number, host: string, cb: () => void) {
this.sent.push({ message: message.toString(), port, host });
cb();
}
close() {
this.closed++;
}
/** Simulates a camera answering the probe. */
reply(xml: string) {
this.emit("message", Buffer.from(xml));
}
}
return {
FakeSocket,
probe: vi.fn(),
on: vi.fn(),
networkInterfaces: vi.fn(),
};
});
vi.mock("onvif", () => ({ Discovery: { probe: net.probe, on: net.on } }));
vi.mock("node:dgram", () => ({
default: { createSocket: (type: string) => new net.FakeSocket(type) },
}));
vi.mock("node:os", () => ({ default: { networkInterfaces: net.networkInterfaces } }));
const { FakeSocket } = net;
type Onvif = typeof import("./onvif");
let onvif: Onvif;
// --- fixtures --------------------------------------------------------------------------
const UUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const scopes = [
"onvif://www.onvif.org/type/video_encoder",
"onvif://www.onvif.org/name/Porch%20Cam",
"onvif://www.onvif.org/hardware/DS-2CD2143",
"onvif://www.onvif.org/location/Front%20door",
].join(" ");
/** What onvif's Discovery.probe yields per device with resolve:false (already linerased). */
const multicastDevice = (over: Record<string, unknown> = {}) => ({
probeMatches: {
probeMatch: {
endpointReference: { address: `urn:uuid:${UUID}` },
XAddrs: "http://[fe80::1]/onvif/device_service http://192.168.1.10:8080/onvif/device_service",
scopes,
...over,
},
},
});
/** A real WS-Discovery ProbeMatches reply, as a camera sends it over UDP. */
const probeMatchXml = ({
urn = `urn:uuid:${UUID}`,
xaddrs = "http://192.168.1.10:8080/onvif/device_service",
} = {}) => `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing"
xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery">
<s:Header><a:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/ProbeMatches</a:Action></s:Header>
<s:Body><d:ProbeMatches><d:ProbeMatch>
<a:EndpointReference><a:Address>${urn}</a:Address></a:EndpointReference>
<d:Scopes>${scopes}</d:Scopes>
<d:XAddrs>${xaddrs}</d:XAddrs>
</d:ProbeMatch></d:ProbeMatches></s:Body>
</s:Envelope>`;
const iface = (address: string, cidr: string | null, over: Record<string, unknown> = {}) => ({
address,
cidr,
family: "IPv4",
internal: false,
netmask: "",
mac: "00:00:00:00:00:00",
...over,
});
/** A /29 with this machine at .5: the sweep should probe the other five hosts. */
const smallLan = { en0: [iface("192.168.1.5", "192.168.1.5/29")] };
const multicastFinds = (devices: unknown[], err: unknown = null) =>
net.probe.mockImplementation((_opts, cb) => cb(err, devices));
async function run(options: Parameters<Onvif["discoverCameras"]>[0]) {
const pending = onvif.discoverCameras(options);
await vi.advanceTimersByTimeAsync(options?.timeoutMs ?? 5000);
return pending;
}
beforeEach(async () => {
vi.useFakeTimers();
FakeSocket.instances = [];
net.probe.mockReset();
net.on.mockReset();
net.networkInterfaces.mockReset().mockReturnValue(smallLan);
multicastFinds([]);
vi.resetModules();
onvif = await import("./onvif");
});
afterEach(() => {
vi.useRealTimers();
});
// --- multicast -------------------------------------------------------------------------
describe("multicast probe", () => {
it("parses a probe match into a camera", async () => {
multicastFinds([multicastDevice()]);
const [camera] = await run({ timeoutMs: 1000, unicastSweep: false });
expect(camera).toEqual({
id: UUID,
urn: `urn:uuid:${UUID}`,
// IPv4 XAddr preferred over the IPv6 one listed first.
hostname: "192.168.1.10",
port: 8080,
xaddrs: [
"http://[fe80::1]/onvif/device_service",
"http://192.168.1.10:8080/onvif/device_service",
],
name: "Porch Cam",
hardware: "DS-2CD2143",
location: "Front door",
scopes: scopes.split(" "),
foundBy: ["multicast"],
});
expect(net.probe).toHaveBeenCalledWith(
{ timeout: 1000, resolve: false },
expect.any(Function),
);
});
it("defaults the port to 80 and falls back to the first XAddr", async () => {
multicastFinds([
multicastDevice({ XAddrs: "http://[fe80::1]/onvif/device_service", scopes: undefined }),
]);
const [camera] = await run({ timeoutMs: 1000, unicastSweep: false });
expect(camera).toMatchObject({ hostname: "[fe80::1]", port: 80, scopes: [] });
expect(camera.name).toBeUndefined();
});
it("reads scopes given as an XML text node, and uses the XAddr when there's no endpoint", async () => {
multicastFinds([
multicastDevice({ scopes: { _: scopes }, endpointReference: undefined, XAddrs: "http://10.0.0.2/x" }),
]);
const [camera] = await run({ timeoutMs: 1000, unicastSweep: false });
expect(camera.name).toBe("Porch Cam");
expect(camera.urn).toBe("http://10.0.0.2/x");
expect(camera.id).toMatch(/^[0-9a-f]{32}$/);
});
it("skips replies without an XAddr or in an unknown shape", async () => {
multicastFinds([multicastDevice({ XAddrs: undefined }), {}, null]);
expect(await run({ timeoutMs: 1000, unicastSweep: false })).toEqual([]);
});
it("keeps good devices when some replies failed to parse", async () => {
multicastFinds([multicastDevice()], [new Error("bad XML from one device")]);
expect(await run({ timeoutMs: 1000, unicastSweep: false })).toHaveLength(1);
});
it("treats a missing device list as empty", async () => {
net.probe.mockImplementation((_opts, cb) => cb(null, undefined));
expect(await run({ timeoutMs: 1000, unicastSweep: false })).toEqual([]);
});
it("fails on a socket-level error", async () => {
net.probe.mockImplementation((_opts, cb) => cb(new Error("EADDRINUSE")));
await expect(onvif.discoverCameras({ unicastSweep: false })).rejects.toThrow("EADDRINUSE");
});
it("attaches one error listener that logs instead of crashing the process", async () => {
await run({ timeoutMs: 1000, unicastSweep: false });
await run({ timeoutMs: 1000, unicastSweep: false });
expect(net.on).toHaveBeenCalledTimes(1);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const [event, listener] = net.on.mock.calls[0];
expect(event).toBe("error");
listener("malformed");
expect(warn).toHaveBeenCalledWith("[onvif] discovery error:", "malformed");
});
});
// --- unicast sweep ---------------------------------------------------------------------
describe("unicast sweep", () => {
it("probes every other host on the local subnet at the WS-Discovery port", async () => {
await run({ timeoutMs: 1000 });
const [socket] = FakeSocket.instances;
expect(socket.type).toBe("udp4");
expect(socket.sent.map((s) => s.host)).toEqual([
"192.168.1.1",
"192.168.1.2",
"192.168.1.3",
"192.168.1.4",
"192.168.1.6",
]);
expect(socket.sent.every((s) => s.port === 3702)).toBe(true);
expect(socket.sent[0].message).toMatch(/<d:Probe><d:Types>dn:NetworkVideoTransmitter/);
expect(socket.sent[0].message).toMatch(/<a:MessageID>urn:uuid:[0-9a-f-]+<\/a:MessageID>/);
expect(socket.closed).toBe(1);
});
it("collects cameras that answer before the timeout, once each", async () => {
const pending = onvif.discoverCameras({ timeoutMs: 1000 });
await vi.advanceTimersByTimeAsync(0);
const [socket] = FakeSocket.instances;
socket.reply(probeMatchXml());
socket.reply(probeMatchXml()); // the same camera answering twice
socket.reply(probeMatchXml({ urn: "urn:uuid:11111111-2222-3333-4444-555555555555", xaddrs: "http://192.168.1.11/onvif" }));
socket.reply("not xml at all");
socket.reply(probeMatchXml({ xaddrs: "" })); // no XAddrs: not a camera
await vi.advanceTimersByTimeAsync(1000);
const cameras = await pending;
expect(cameras.map((c) => [c.hostname, c.port, c.foundBy])).toEqual([
["192.168.1.10", 8080, ["unicast"]],
["192.168.1.11", 80, ["unicast"]],
]);
expect(cameras[0]).toMatchObject({ id: UUID, name: "Porch Cam", location: "Front door" });
});
it("sends in batches of 64 to avoid flooding the network", async () => {
// A /25 holds 126 hosts; minus this machine, 125 probes.
net.networkInterfaces.mockReturnValue({ en0: [iface("10.0.0.1", "10.0.0.1/25")] });
const pending = onvif.discoverCameras({ timeoutMs: 1000 });
await vi.advanceTimersByTimeAsync(0);
const [socket] = FakeSocket.instances;
expect(socket.sent).toHaveLength(64);
await vi.advanceTimersByTimeAsync(15);
expect(socket.sent).toHaveLength(125);
await vi.advanceTimersByTimeAsync(1000);
await pending;
});
it.each([
["loopback", { lo0: [iface("127.0.0.1", "127.0.0.1/8", { internal: true })] }],
["IPv6-only", { en0: [iface("fe80::1", "fe80::1/64", { family: "IPv6" })] }],
["link-local", { en0: [iface("169.254.3.4", "169.254.3.4/16")] }],
["an interface without a CIDR", { en0: [iface("192.168.1.5", null)] }],
["no interface list", { en0: undefined }],
])("skips %s interfaces and opens no socket", async (_label, interfaces) => {
net.networkInterfaces.mockReturnValue(interfaces);
expect(await run({ timeoutMs: 1000 })).toEqual([]);
expect(FakeSocket.instances).toHaveLength(0);
});
it.each([
["larger than a /20", "10.0.0.1/16"],
["a /31 point-to-point link", "10.0.0.1/31"],
])("refuses to sweep a subnet %s", async (_label, cidr) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
net.networkInterfaces.mockReturnValue({ en0: [iface(cidr.split("/")[0], cidr)] });
await run({ timeoutMs: 1000 });
expect(FakeSocket.instances).toHaveLength(0);
expect(warn).toHaveBeenCalledWith(`[onvif] skipping unicast sweep of ${cidr}`);
});
it("sweeps a /20, the largest allowed subnet", async () => {
net.networkInterfaces.mockReturnValue({ en0: [iface("10.0.0.1", "10.0.0.1/20")] });
// 4094 hosts at 64 per 15 ms batch takes ~960 ms to send.
await run({ timeoutMs: 2000 });
expect(FakeSocket.instances[0].sent).toHaveLength(4093);
});
it("probes each host once when interfaces share a subnet", async () => {
net.networkInterfaces.mockReturnValue({
en0: [iface("192.168.1.5", "192.168.1.5/29")],
en1: [iface("192.168.1.6", "192.168.1.6/29")],
});
await run({ timeoutMs: 1000 });
const hosts = FakeSocket.instances[0].sent.map((s) => s.host);
expect(new Set(hosts).size).toBe(hosts.length);
});
it("fails on a socket error, closes once, and stops sending", async () => {
net.networkInterfaces.mockReturnValue({ en0: [iface("10.0.0.1", "10.0.0.1/25")] });
const pending = onvif.discoverCameras({ timeoutMs: 1000 });
const failed = expect(pending).rejects.toThrow("EACCES");
await vi.advanceTimersByTimeAsync(0);
const [socket] = FakeSocket.instances;
socket.emit("error", new Error("EACCES"));
socket.emit("error", new Error("again"));
await vi.advanceTimersByTimeAsync(1000);
await failed;
expect(socket.closed).toBe(1);
expect(socket.sent).toHaveLength(64);
});
});
// --- combined --------------------------------------------------------------------------
describe("discoverCameras", () => {
it("merges a camera found both ways, keeping every other camera", async () => {
multicastFinds([
multicastDevice(),
multicastDevice({ endpointReference: { address: "urn:uuid:only-multicast" }, XAddrs: "http://192.168.1.20/onvif" }),
]);
const pending = onvif.discoverCameras({ timeoutMs: 1000 });
await vi.advanceTimersByTimeAsync(0);
FakeSocket.instances[0].reply(probeMatchXml());
await vi.advanceTimersByTimeAsync(1000);
const cameras = await pending;
expect(cameras).toHaveLength(2);
expect(cameras[0].foundBy).toEqual(["multicast", "unicast"]);
expect(cameras[1].foundBy).toEqual(["multicast"]);
});
it("defaults to a 5 s timeout with the unicast sweep on", async () => {
const pending = onvif.discoverCameras();
await vi.advanceTimersByTimeAsync(4999);
expect(FakeSocket.instances[0].closed).toBe(0);
await vi.advanceTimersByTimeAsync(1);
await pending;
expect(FakeSocket.instances[0].closed).toBe(1);
expect(net.probe).toHaveBeenCalledWith({ timeout: 5000, resolve: false }, expect.any(Function));
});
});
describe("discoverRequestSchema", () => {
it.each([
[{}, { timeout: 5000, unicastSweep: true }],
[{ timeout: 1000, unicastSweep: false }, { timeout: 1000, unicastSweep: false }],
[{ timeout: 30_000 }, { timeout: 30_000, unicastSweep: true }],
])("accepts %j", (input, output) => {
expect(onvif.discoverRequestSchema.parse(input)).toEqual(output);
});
it.each([[{ timeout: 999 }], [{ timeout: 30_001 }], [{ timeout: 1.5e3 + 0.5 }], [{ unicastSweep: 1 }]])(
"rejects %j",
(input) => {
expect(onvif.discoverRequestSchema.safeParse(input).success).toBe(false);
},
);
});

21
test/camera-routes.ts Normal file
View File

@ -0,0 +1,21 @@
import type { CameraRecord } from "@/lib/camera-registry";
/** Shared fixtures for the /api/cameras/[id]/* route tests. */
export const ID = "11111111-2222-3333-4444-555555555555";
export const record: CameraRecord = {
id: ID,
urn: `urn:uuid:${ID}`,
host: "192.168.1.10",
port: 80,
lastSeen: "2026-09-19T00:00:00.000Z",
};
export const target = { id: ID, host: "192.168.1.10", port: 80 };
export const ctx = (id = ID) => ({ params: Promise.resolve({ id }) });
export const url = (path: string) => `http://localhost/api/cameras/${ID}/${path}`;
/** An upstream error message that must never reach the client. */
export const LEAKY = "SOAP fault from http://admin:hunter2@192.168.1.10/onvif/device_service";

View File

@ -28,6 +28,8 @@ export default defineConfig({
"src/app/page.tsx",
],
reporter: ["text", "json-summary"],
// Goal gol-9zxah3p in vrek: `npm run coverage` fails if line coverage drops below 95%.
thresholds: { lines: 95 },
},
},
});