Add Vitest test suite with coverage reporting

Sets up Vitest (jsdom, Testing Library, v8 coverage) per the Next 16
testing guide, using Vite's native tsconfig path resolution instead of
vite-tsconfig-paths. A server-only stub and a temp-data helper let
server modules run in isolation. @types/node moves to ^24 to match the
Node 24 runtime (a vitest 5 peer requirement).

First 68 tests cover the discover route (including iss-dbwgww8: no raw
errors in responses), the credential store, the camera registry,
camera-route helpers and the refresh-rate schema. Line coverage is
31.2%, toward the 95% goal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 08:49:50 -05:00
parent f39d16a8c0
commit 71d02195ce
10 changed files with 2403 additions and 12 deletions

1852
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",
@ -19,12 +22,18 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^24.13.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^5.0.1",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"jsdom": "^29.1.1",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^5.0.1"
}
}

View File

@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const discoverCameras = vi.fn();
const recordDiscovered = vi.fn();
vi.mock("@/lib/onvif", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/onvif")>()),
discoverCameras,
}));
vi.mock("@/lib/camera-registry", () => ({ recordDiscovered }));
const { POST } = await import("./route");
function post(body?: unknown) {
return POST(
new Request("http://localhost/api/discover", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
);
}
const camera = { id: "abc", urn: "urn:uuid:abc", hostname: "192.168.1.10", port: 80 };
describe("POST /api/discover", () => {
beforeEach(() => {
discoverCameras.mockReset().mockResolvedValue([camera]);
recordDiscovered.mockReset().mockResolvedValue(undefined);
});
it("scans with defaults when the body is empty, and records the result", async () => {
const res = await post();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ cameras: [camera] });
expect(discoverCameras).toHaveBeenCalledWith({ timeoutMs: 5000, unicastSweep: true });
expect(recordDiscovered).toHaveBeenCalledWith([camera]);
});
it("passes validated options through", async () => {
await post({ timeout: 2000, unicastSweep: false });
expect(discoverCameras).toHaveBeenCalledWith({ timeoutMs: 2000, unicastSweep: false });
});
it.each([
["non-numeric timeout", { timeout: "x" }],
["timeout above 30 s", { timeout: 99_999 }],
["timeout below 1 s", { timeout: 10 }],
["non-boolean sweep flag", { unicastSweep: "yes" }],
])("rejects %s with 400 and does not scan", async (_label, body) => {
const res = await post(body);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid scan options" });
expect(discoverCameras).not.toHaveBeenCalled();
});
// iss-dbwgww8: OS and socket errors must stay in the server log.
describe("when the scan fails", () => {
const secret = "EISDIR: illegal operation on a directory, open '/secret/path/cameras.json'";
it.each([
["discovery throws", () => discoverCameras.mockRejectedValue(new Error(secret))],
["recording results throws", () => recordDiscovered.mockRejectedValue(new Error(secret))],
["a non-Error is thrown", () => discoverCameras.mockRejectedValue(secret)],
])("returns a generic 500 when %s", async (_label, arrange) => {
arrange();
const log = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await post({});
const text = await res.text();
expect(res.status).toBe(500);
expect(JSON.parse(text)).toEqual({ error: "Network scan failed" });
expect(text).not.toContain("secret");
expect(text).not.toContain("EISDIR");
expect(log).toHaveBeenCalledWith("Network scan failed", expect.anything());
});
});
});

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_REFRESH_MS, REFRESH_OPTIONS_MS, refreshMsSchema } from "./refresh-rate";
describe("refreshMsSchema", () => {
it.each(REFRESH_OPTIONS_MS)("accepts the offered interval %i ms", (ms) => {
expect(refreshMsSchema.parse(String(ms))).toBe(ms);
});
it.each([
["missing", undefined],
["not a number", "abc"],
["not an offered interval", "1234"],
["repeated param", ["500", "1000"]],
])("falls back to the default when %s", (_label, value) => {
expect(refreshMsSchema.parse(value)).toBe(DEFAULT_REFRESH_MS);
});
});

View File

@ -0,0 +1,129 @@
import { readFile, writeFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import { withTempDataDir } from "../../test/temp-data";
vi.mock("next/server", () => ({ connection: vi.fn(async () => {}) }));
withTempDataDir();
const load = () => import("./camera-registry");
const found = (over: Partial<{ id: string; hostname: string; name: string }> = {}) => ({
id: "11111111-2222-3333-4444-555555555555",
urn: "urn:uuid:11111111-2222-3333-4444-555555555555",
hostname: "192.168.1.10",
port: 80,
...over,
});
describe("cameraIdFromUrn", () => {
it("uses the lower-cased UUID from a urn:uuid endpoint reference", async () => {
const { cameraIdFromUrn } = await load();
expect(cameraIdFromUrn("urn:uuid:AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")).toBe(
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
);
});
it("hashes any other reference to a stable 32-hex-char ID", async () => {
const { cameraIdFromUrn, isValidCameraId } = await load();
const id = cameraIdFromUrn("http://192.168.1.10/onvif/device_service");
expect(id).toMatch(/^[0-9a-f]{32}$/);
expect(cameraIdFromUrn("http://192.168.1.10/onvif/device_service")).toBe(id);
expect(isValidCameraId(id)).toBe(true);
});
});
describe("isValidCameraId", () => {
it.each([
["a UUID", "11111111-2222-3333-4444-555555555555", true],
["a 32-char hash", "0123456789abcdef0123456789abcdef", true],
["upper case", "0123456789ABCDEF0123456789ABCDEF", false],
["too short", "abc", false],
["path traversal", "../../../../etc/passwd", false],
["empty", "", false],
])("%s → %s", async (_label, id, valid) => {
const { isValidCameraId } = await load();
expect(isValidCameraId(id)).toBe(valid);
});
});
describe("registry", () => {
it("is empty when no file exists yet", async () => {
const { listCameras, getCameraRecord } = await load();
expect(await listCameras()).toEqual([]);
expect(await getCameraRecord("11111111-2222-3333-4444-555555555555")).toBeNull();
});
it("does nothing for an empty scan", async () => {
const { recordDiscovered } = await load();
await recordDiscovered([]);
await expect(readFile(process.env.CAMERA_REGISTRY_FILE!, "utf8")).rejects.toThrow(/ENOENT/);
});
it("records discovered cameras and persists them to disk", async () => {
const { recordDiscovered, getCameraRecord } = await load();
await recordDiscovered([found({ name: "Porch" })]);
const record = await getCameraRecord("11111111-2222-3333-4444-555555555555");
expect(record).toMatchObject({ host: "192.168.1.10", port: 80, name: "Porch" });
expect(Date.parse(record!.lastSeen)).not.toBeNaN();
// A fresh module instance reads it back from the file.
vi.resetModules();
const fresh = await load();
expect(await fresh.getCameraRecord(record!.id)).toEqual(record);
});
it("keeps the camera's ID when its IP changes", async () => {
const { recordDiscovered, listCameras } = await load();
await recordDiscovered([found()]);
await recordDiscovered([found({ hostname: "192.168.1.99" })]);
const cameras = await listCameras();
expect(cameras).toHaveLength(1);
expect(cameras[0].host).toBe("192.168.1.99");
});
it("lists cameras as client-safe summaries sorted by name, then address", async () => {
const { recordDiscovered, listCameras } = await load();
await recordDiscovered([
found({ id: "0000000000000000000000000000000b", hostname: "192.168.1.10" }),
found({ id: "0000000000000000000000000000000c", hostname: "192.168.1.9" }),
found({ id: "0000000000000000000000000000000a", hostname: "192.168.1.5", name: "Yard" }),
]);
const cameras = await listCameras();
expect(cameras.map((c) => c.host)).toEqual(["192.168.1.9", "192.168.1.10", "192.168.1.5"]);
// The URN is internal and must not reach the client.
expect(cameras[0]).not.toHaveProperty("urn");
});
it("migrates a login stored under the old host:port key to the camera ID", async () => {
const creds = await import("./credential-store");
await creds.setCredentials("192.168.1.10:80", { username: "admin", password: "pw" });
const { recordDiscovered } = await load();
await recordDiscovered([found()]);
expect(await creds.getCredentials("11111111-2222-3333-4444-555555555555")).toEqual({
username: "admin",
password: "pw",
});
expect(await creds.getCredentials("192.168.1.10:80")).toBeNull();
});
it("still records cameras when the credential store can't be read", async () => {
vi.stubEnv("CAMERA_CREDENTIALS_KEY", "");
const { recordDiscovered, listCameras } = await load();
await recordDiscovered([found()]);
expect(await listCameras()).toHaveLength(1);
});
it("surfaces a registry file it can't parse, and recovers once it is fixed", async () => {
await writeFile(process.env.CAMERA_REGISTRY_FILE!, "{not json");
const { listCameras } = await load();
await expect(listCameras()).rejects.toThrow(SyntaxError);
await writeFile(process.env.CAMERA_REGISTRY_FILE!, "{}");
expect(await listCameras()).toEqual([]);
});
});

View File

@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraRecord } from "./camera-registry";
const getCameraRecord = vi.fn<(id: string) => Promise<CameraRecord | null>>();
vi.mock("./camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("./camera-registry")>()),
getCameraRecord,
}));
const { cameraTarget, cameraErrorResponse } = await import("./camera-route");
const { CameraAuthError, CameraInactiveError } = await import("./camera");
const { CredentialStoreError } = await import("./credential-store");
const ID = "11111111-2222-3333-4444-555555555555";
const record = (host: string): CameraRecord => ({
id: ID,
urn: `urn:uuid:${ID}`,
host,
port: 8080,
lastSeen: "2026-09-19T00:00:00.000Z",
});
async function expectError(result: unknown, status: number, error: string) {
expect(result).toBeInstanceOf(Response);
const res = result as Response;
expect(res.status).toBe(status);
expect(await res.json()).toEqual({ error });
}
describe("cameraTarget", () => {
beforeEach(() => {
getCameraRecord.mockReset();
});
it("resolves a known camera to its registry address", async () => {
getCameraRecord.mockResolvedValue(record("192.168.1.10"));
expect(await cameraTarget(Promise.resolve({ id: ID }))).toEqual({
id: ID,
host: "192.168.1.10",
port: 8080,
});
});
it("rejects a malformed ID without touching the registry", async () => {
await expectError(await cameraTarget(Promise.resolve({ id: "../etc" })), 400, "Invalid camera ID");
expect(getCameraRecord).not.toHaveBeenCalled();
});
it("returns 404 for an unknown camera", async () => {
getCameraRecord.mockResolvedValue(null);
await expectError(
await cameraTarget(Promise.resolve({ id: ID })),
404,
"Unknown camera; scan the network again",
);
});
it.each(["8.8.8.8", "127.0.0.1", "camera.example.com"])(
"refuses a registry entry pointing at non-private host %s",
async (host) => {
getCameraRecord.mockResolvedValue(record(host));
await expectError(
await cameraTarget(Promise.resolve({ id: ID })),
400,
"Camera address is not a private IPv4 address",
);
},
);
});
describe("cameraErrorResponse", () => {
it.each([
["inactive camera", new CameraInactiveError("not activated"), 409, "inactive"],
["rejected login", new CameraAuthError("401"), 401, "auth"],
["credential store problem", new CredentialStoreError("no key"), 500, "store"],
])("maps a %s to status %i with code %s", async (_label, err, status, code) => {
const res = cameraErrorResponse(err);
expect(res.status).toBe(status);
expect(await res.json()).toMatchObject({ code });
});
it("maps any other failure to 502 without a code", async () => {
const res = cameraErrorResponse(new Error("socket hang up"));
expect(res.status).toBe(502);
expect(await res.json()).not.toHaveProperty("code");
});
it("accepts non-Error values", async () => {
const res = cameraErrorResponse("boom");
expect(res.status).toBe(502);
});
});

View File

@ -0,0 +1,167 @@
import { readFile, stat, writeFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import { withTempDataDir } from "../../test/temp-data";
withTempDataDir();
const load = () => import("./credential-store");
const storeFile = () => process.env.CAMERA_CREDENTIALS_FILE!;
const ID = "11111111-2222-3333-4444-555555555555";
describe("credential store", () => {
it("has no credentials before anything is saved", async () => {
const { getCredentials, describeCredentials } = await load();
expect(await getCredentials(ID)).toBeNull();
expect(await describeCredentials(ID)).toEqual({
source: "none",
username: null,
hasPassword: false,
});
});
it("saves, reads back and deletes a login", async () => {
const { setCredentials, getCredentials, deleteCredentials } = await load();
await setCredentials(ID, { username: "admin", password: "hunter2" });
expect(await getCredentials(ID)).toEqual({ username: "admin", password: "hunter2" });
await deleteCredentials(ID);
expect(await getCredentials(ID)).toBeNull();
});
it("writes the file encrypted, owner-only, with no plaintext password", async () => {
const { setCredentials } = await load();
await setCredentials(ID, { username: "admin", password: "hunter2" });
const text = await readFile(storeFile(), "utf8");
expect(text).not.toContain("hunter2");
expect(text).not.toContain("admin");
expect(JSON.parse(text)).toMatchObject({ version: 1, kdf: "scrypt" });
expect((await stat(storeFile())).mode & 0o777).toBe(0o600);
});
it("decrypts the file in a fresh process with the same key", async () => {
await (await load()).setCredentials(ID, { username: "admin", password: "hunter2" });
vi.resetModules();
expect(await (await load()).getCredentials(ID)).toEqual({
username: "admin",
password: "hunter2",
});
});
it("refuses to read the file with the wrong key, without revealing contents", async () => {
await (await load()).setCredentials(ID, { username: "admin", password: "hunter2" });
vi.resetModules();
vi.stubEnv("CAMERA_CREDENTIALS_KEY", "wrong-key");
const { getCredentials, CredentialStoreError } = await load();
const err = await getCredentials(ID).catch((e) => e);
expect(err).toBeInstanceOf(CredentialStoreError);
expect(err.message).toMatch(/CAMERA_CREDENTIALS_KEY is wrong or the file is corrupt/);
expect(err.message).not.toContain("hunter2");
});
it("fails with a CredentialStoreError when no key is configured", async () => {
vi.stubEnv("CAMERA_CREDENTIALS_KEY", "");
const { setCredentials, CredentialStoreError } = await load();
await expect(setCredentials(ID, { username: "a", password: "b" })).rejects.toBeInstanceOf(
CredentialStoreError,
);
});
it("re-saves an old plaintext file encrypted", async () => {
await writeFile(storeFile(), JSON.stringify({ [ID]: { username: "admin", password: "pw" } }));
const { getCredentials } = await load();
expect(await getCredentials(ID)).toEqual({ username: "admin", password: "pw" });
expect(await readFile(storeFile(), "utf8")).not.toContain('"pw"');
});
it("surfaces a file it can't parse, and retries on the next read", async () => {
await writeFile(storeFile(), "{not json");
const { getCredentials } = await load();
await expect(getCredentials(ID)).rejects.toThrow(SyntaxError);
await writeFile(storeFile(), "{}");
expect(await getCredentials(ID)).toBeNull();
});
describe("environment fallback", () => {
it("uses ONVIF_USERNAME / ONVIF_PASSWORD when nothing is stored", async () => {
vi.stubEnv("ONVIF_USERNAME", "envuser");
vi.stubEnv("ONVIF_PASSWORD", "envpass");
const { getCredentials, describeCredentials } = await load();
expect(await getCredentials(ID)).toEqual({ username: "envuser", password: "envpass" });
expect(await describeCredentials(ID)).toEqual({
source: "env",
username: "envuser",
hasPassword: true,
});
});
it("treats a lone password as a login with an empty username", async () => {
vi.stubEnv("ONVIF_PASSWORD", "envpass");
const { getCredentials } = await load();
expect(await getCredentials(ID)).toEqual({ username: "", password: "envpass" });
});
it("prefers a stored login over the environment", async () => {
vi.stubEnv("ONVIF_USERNAME", "envuser");
const { setCredentials, describeCredentials } = await load();
await setCredentials(ID, { username: "admin", password: "" });
expect(await describeCredentials(ID)).toEqual({
source: "stored",
username: "admin",
hasPassword: false,
});
});
});
describe("describeCredentials", () => {
it("never includes the password", async () => {
const { setCredentials, describeCredentials } = await load();
await setCredentials(ID, { username: "admin", password: "hunter2" });
expect(JSON.stringify(await describeCredentials(ID))).not.toContain("hunter2");
});
});
describe("moveCredentials", () => {
it("re-keys an entry", async () => {
const { setCredentials, moveCredentials, getCredentials } = await load();
await setCredentials("old", { username: "a", password: "b" });
await moveCredentials("old", ID);
expect(await getCredentials(ID)).toEqual({ username: "a", password: "b" });
expect(await getCredentials("old")).toBeNull();
});
it("does nothing when the source is missing", async () => {
const { moveCredentials, getCredentials } = await load();
await moveCredentials("old", ID);
expect(await getCredentials(ID)).toBeNull();
});
it("never overwrites an existing login at the destination", async () => {
const { setCredentials, moveCredentials, getCredentials } = await load();
await setCredentials("old", { username: "old", password: "x" });
await setCredentials(ID, { username: "current", password: "y" });
await moveCredentials("old", ID);
expect(await getCredentials(ID)).toEqual({ username: "current", password: "y" });
expect(await getCredentials("old")).not.toBeNull();
});
});
describe("credentialsSchema", () => {
it.each([
[{ username: "admin", password: "" }, true],
[{ username: "admin", password: "pw" }, true],
[{ username: "", password: "pw" }, false],
[{ username: 5, password: "pw" }, false],
[{ username: "admin" }, false],
[{ username: "a".repeat(65), password: "" }, false],
[{ username: "admin", password: "p".repeat(257) }, false],
[null, false],
])("%j → %s", async (input, ok) => {
const { credentialsSchema } = await load();
expect(credentialsSchema.safeParse(input).success).toBe(ok);
});
});
});

2
test/server-only.ts Normal file
View File

@ -0,0 +1,2 @@
// Stand-in for the "server-only" package under Vitest (see vitest.config.mts).
export {};

28
test/temp-data.ts Normal file
View File

@ -0,0 +1,28 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
/**
* Points the registry and credential store at a fresh temp directory for each test and
* resets the module cache, so modules that read env at import time pick it up.
*/
export function withTempDataDir() {
const dir = { path: "" };
beforeEach(async () => {
dir.path = await mkdtemp(path.join(os.tmpdir(), "cameras-test-"));
vi.stubEnv("CAMERA_REGISTRY_FILE", path.join(dir.path, "cameras.json"));
vi.stubEnv("CAMERA_CREDENTIALS_FILE", path.join(dir.path, "credentials.json"));
vi.stubEnv("CAMERA_CREDENTIALS_KEY", "test-key");
vi.stubEnv("ONVIF_USERNAME", "");
vi.stubEnv("ONVIF_PASSWORD", "");
vi.resetModules();
});
afterEach(async () => {
await rm(dir.path, { recursive: true, force: true });
});
return dir;
}

33
vitest.config.mts Normal file
View File

@ -0,0 +1,33 @@
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
// Resolves the "@/*" path alias from tsconfig.json.
tsconfigPaths: true,
alias: {
// The real package throws unless bundled for React Server Components.
"server-only": fileURLToPath(new URL("./test/server-only.ts", import.meta.url)),
},
},
test: {
// Server code runs in Node; component tests opt in with `// @vitest-environment jsdom`.
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
restoreMocks: true,
unstubEnvs: true,
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.d.ts",
// Async Server Component: unit runners can't render it (Next docs, testing/vitest.md).
"src/app/page.tsx",
],
reporter: ["text", "json-summary"],
},
},
});