feat(tests): add unit and integration test suite (node:test)

Adds 215 tests across three tiers using node:test + node:assert/strict
(no new test framework dependencies):

- test/tools/ — middleware and utility tests (token, hash, authenticate,
  jsonBody, convertError, validators)
- test/shared/ — DbInterface/BackendDbInterface contract suites reusable
  across backends (users, abodes, residents, apikeys, sessions, auth)
- test/backends/sqlite/ — SQLite-private tests (sql builder, WrappedDb,
  migrator) + shared suites via SqliteInterface
- test/backends/api/ — ApiInterface unit tests + shared suites via a
  live Koa server backed by SQLite

Also fixes four bugs uncovered by the tests:
- ApiInterface: path params leaked into query string (slice(1) fix)
- ApiInterface: calling res.json() on 204 No Content responses
- SqliteInterface.updateResident: missing comma in SET clause
- WrappedBetterSqlite3Db: readonly:undefined rejected by better-sqlite3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 11:33:32 +00:00
co-authored by Claude
parent 3b9a6bc85c
commit da4e597f73
24 changed files with 2212 additions and 3 deletions
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
import { apiProtocols, isApiUrl, parseApiUrl } from "../../../src/db/api/url.js";
import {
NotFoundAbodeError,
NotAuthorizedAbodeError,
ReadonlyAbodeError,
InvalidAbodeError,
ConflictAbodeError,
} from "../../../src/db/types/errors.js";
describe("ApiInterface static properties", () => {
it("name is 'api'", () => {
const api = new ApiInterface("http://localhost:9999");
assert.equal(api.name, "api");
});
it("backend is false", () => {
const api = new ApiInterface("http://localhost:9999");
assert.equal(api.backend, false);
});
it("readonly defaults to false", () => {
const api = new ApiInterface("http://localhost:9999");
assert.equal(api.readonly, false);
});
it("readonly is set from options", () => {
const api = new ApiInterface("http://localhost:9999", { readonly: true });
assert.equal(api.readonly, true);
});
});
describe("apiProtocols and isApiUrl", () => {
it("apiProtocols includes expected protocols", () => {
assert.ok(apiProtocols.includes("https:"));
assert.ok(apiProtocols.includes("http:"));
assert.ok(apiProtocols.includes("abode+https:"));
assert.ok(apiProtocols.includes("abode+http:"));
});
it("isApiUrl returns true for http/https urls", () => {
assert.equal(isApiUrl("http://example.com"), true);
assert.equal(isApiUrl("https://example.com/api"), true);
assert.equal(isApiUrl("abode+http://example.com"), true);
assert.equal(isApiUrl("abode+https://example.com"), true);
});
it("isApiUrl returns false for non-http urls", () => {
assert.equal(isApiUrl("sqlite:///db.sqlite"), false);
assert.equal(isApiUrl("not-a-url"), false);
assert.equal(isApiUrl("ftp://example.com"), false);
});
});
describe("parseApiUrl", () => {
it("strips abode+ prefix from protocol", () => {
const [root] = parseApiUrl("abode+http://example.com");
assert.ok(root.startsWith("http://"), `expected http:// got ${root}`);
});
it("extracts Basic auth from URL credentials", () => {
const [, { headers }] = parseApiUrl("http://user:pass@example.com");
assert.ok(headers["Authorization"]?.startsWith("Basic "), "has Basic auth");
const decoded = atob(headers["Authorization"]!.slice("Basic ".length));
assert.equal(decoded, "user:pass");
});
it("strips credentials from root URL", () => {
const [root] = parseApiUrl("http://user:pass@example.com");
assert.ok(!root.includes("user"), "credentials stripped from root");
});
it("extracts readonly flag from query", () => {
const [, { readonly }] = parseApiUrl("http://example.com?readonly=1");
assert.equal(readonly, true);
});
it("defaults readonly to false", () => {
const [, { readonly }] = parseApiUrl("http://example.com");
assert.equal(readonly, false);
});
it("extra query params become headers", () => {
const [, { headers }] = parseApiUrl(
"http://example.com?X-Custom-Header=value"
);
assert.equal(headers["X-Custom-Header"], "value");
});
it("throws for non-api protocol", () => {
assert.throws(
() => parseApiUrl("sqlite:///db.sqlite"),
/Not an \{abode\+,\}http\{s,\}: protocol/
);
});
});
describe("ApiInterface HTTP error mapping", () => {
let serverUrl: string;
let closeServer: () => Promise<void>;
let respondWith: (status: number) => void;
before(async () => {
let nextStatus = 500;
respondWith = (s) => { nextStatus = s; };
const server = createServer((req, res) => {
res.writeHead(nextStatus, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "test" }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
serverUrl = `http://127.0.0.1:${port}`;
closeServer = () =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
});
after(() => closeServer());
it("404 response throws NotFoundAbodeError", async () => {
respondWith(404);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
}
);
});
it("401 response throws NotAuthorizedAbodeError", async () => {
respondWith(401);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof NotAuthorizedAbodeError);
return true;
}
);
});
it("403 response throws ReadonlyAbodeError", async () => {
respondWith(403);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof ReadonlyAbodeError);
return true;
}
);
});
it("400 response throws InvalidAbodeError", async () => {
respondWith(400);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof InvalidAbodeError);
return true;
}
);
});
it("409 response throws ConflictAbodeError", async () => {
respondWith(409);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof ConflictAbodeError);
return true;
}
);
});
});
describe("ApiInterface._ internal helpers", () => {
it("_.url builds correct URL for path params (no leftover query param)", () => {
const api = new ApiInterface("http://example.com");
const url = api._.url("/users/:uid", { uid: "abc-123" });
assert.equal(url, "http://example.com/users/abc-123");
});
it("_.url puts remaining params as query string", () => {
const api = new ApiInterface("http://example.com");
const url = api._.url("/users/by-email", { email: "a@b.com" });
assert.ok(url.includes("email="), `expected query param in ${url}`);
});
it("_.root matches the constructor argument", () => {
const api = new ApiInterface("http://example.com/api");
assert.equal(api._.root, "http://example.com/api");
});
it("_.headers includes Authorization when set", () => {
const api = new ApiInterface("http://example.com", {
headers: { Authorization: "Basic dGVzdA==" },
});
assert.equal(api._.headers["Authorization"], "Basic dGVzdA==");
});
});