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:
@@ -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==");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createTestDb } from "../../helpers/sqlite.js";
|
||||
import { createTestServer } from "../../helpers/koa.js";
|
||||
import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
|
||||
import { hashPassword } from "../../../src/util/hash.js";
|
||||
import { runUserTests } from "../../shared/users.js";
|
||||
import { runAbodeTests } from "../../shared/abodes.js";
|
||||
import { runResidentTests } from "../../shared/residents.js";
|
||||
import { runApikeyTests } from "../../shared/apikeys.js";
|
||||
|
||||
const AUTH_EMAIL = "api-auth@test.example";
|
||||
const AUTH_PASSWORD = "api-auth-password";
|
||||
|
||||
async function getApiDb() {
|
||||
const { db: sqliteDb, close: closeSqlite } = await createTestDb();
|
||||
const pw = await hashPassword(AUTH_PASSWORD);
|
||||
await sqliteDb.createUser({
|
||||
email: AUTH_EMAIL,
|
||||
name: "API Auth User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
const server = await createTestServer(sqliteDb);
|
||||
const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`);
|
||||
const api = new ApiInterface(server.url, {
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
return {
|
||||
db: api,
|
||||
close: async () => {
|
||||
await server.close();
|
||||
closeSqlite();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
runUserTests("api", getApiDb);
|
||||
runAbodeTests("api", getApiDb);
|
||||
runResidentTests("api", getApiDb);
|
||||
runApikeyTests("api", getApiDb);
|
||||
Reference in New Issue
Block a user