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:
parent
f39d16a8c0
commit
71d02195ce
1852
package-lock.json
generated
1852
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
79
src/app/api/discover/route.test.ts
Normal file
79
src/app/api/discover/route.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
17
src/app/refresh-rate.test.ts
Normal file
17
src/app/refresh-rate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
129
src/lib/camera-registry.test.ts
Normal file
129
src/lib/camera-registry.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
93
src/lib/camera-route.test.ts
Normal file
93
src/lib/camera-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
167
src/lib/credential-store.test.ts
Normal file
167
src/lib/credential-store.test.ts
Normal 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
2
test/server-only.ts
Normal 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
28
test/temp-data.ts
Normal 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
33
vitest.config.mts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user