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; 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((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((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=="); }); });