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,38 @@
|
||||
import type { BackendDbInterface } from "../../../src/db/types/DbInterface.js";
|
||||
import { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js";
|
||||
import { createTestDb } from "../../helpers/sqlite.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";
|
||||
import { runSessionTests } from "../../shared/sessions.js";
|
||||
import { runAuthTests } from "../../shared/auth.js";
|
||||
|
||||
async function createExpiredApikey(
|
||||
db: BackendDbInterface,
|
||||
uid: string
|
||||
): Promise<`at_${string}`> {
|
||||
const si = db as SqliteInterface;
|
||||
const token = (`at_${"e".repeat(32)}`) as `at_${string}`;
|
||||
const kid = crypto.randomUUID();
|
||||
const { sql } = si._;
|
||||
si._.db.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: uid }},
|
||||
${{ uuid: kid }},
|
||||
${{ text: token }},
|
||||
${{ text: "Expired Key" }},
|
||||
${{ jsonb: {} }},
|
||||
${{ date: new Date(Date.now() - 10000).toISOString() }}
|
||||
)
|
||||
`);
|
||||
return token;
|
||||
}
|
||||
|
||||
runUserTests("sqlite", createTestDb);
|
||||
runAbodeTests("sqlite", createTestDb);
|
||||
runResidentTests("sqlite", createTestDb);
|
||||
runApikeyTests("sqlite", createTestDb);
|
||||
runSessionTests("sqlite", createTestDb);
|
||||
runAuthTests("sqlite", createTestDb, createExpiredApikey);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js";
|
||||
import { SqliteMigrator } from "../../../src/db/sqlite/SqliteMigrator.js";
|
||||
import { migrations } from "../../../src/db/sqlite/migrations/index.js";
|
||||
|
||||
describe("SqliteMigrator", () => {
|
||||
it("listAvailableMigrations returns all known migrations", () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
const available = migrator.listAvailableMigrations();
|
||||
assert.ok(Array.isArray(available));
|
||||
assert.ok(available.length >= 3, "at least 3 migrations");
|
||||
const ids = available.map((m) => m.id);
|
||||
assert.ok(ids.includes(1), "migration 1 present");
|
||||
assert.ok(ids.includes(2), "migration 2 present");
|
||||
assert.ok(ids.includes(3), "migration 3 present");
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("listAppliedMigrations returns empty array on fresh db", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.deepEqual(applied, []);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo(1) applies first migration", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await migrator.migrateTo(1);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.equal(applied.length, 1);
|
||||
assert.equal(applied[0].id, 1);
|
||||
assert.ok(applied[0].name, "migration has a name");
|
||||
assert.ok(applied[0].applied_at, "migration has applied_at");
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo(3) applies all three migrations in order", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await migrator.migrateTo(3);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.equal(applied.length, 3);
|
||||
assert.deepEqual(
|
||||
applied.map((m) => m.id),
|
||||
[1, 2, 3]
|
||||
);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo(3) twice is idempotent (nothing to do)", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await migrator.migrateTo(3);
|
||||
await migrator.migrateTo(3);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.equal(applied.length, 3);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo with unknown id throws", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await assert.rejects(
|
||||
() => migrator.migrateTo(9999),
|
||||
/No known migration with id 9999/
|
||||
);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("listAvailableMigrations names match migration objects", () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
const available = migrator.listAvailableMigrations();
|
||||
for (const { id, name } of available) {
|
||||
const migration = migrations.find((m) => m.id === id);
|
||||
assert.ok(migration, `migration ${id} exists`);
|
||||
assert.equal(migration.name, name);
|
||||
}
|
||||
db.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { sql, catSql, joinSql, calcUpdates, unsafeSql } from "../../../src/db/sqlite/sql.js";
|
||||
|
||||
describe("sql template tag", () => {
|
||||
it("produces correct sql and empty vars for plain text", () => {
|
||||
const result = sql`SELECT 1`;
|
||||
assert.equal(result._sql, "SELECT 1");
|
||||
assert.deepEqual(result._vars, []);
|
||||
});
|
||||
|
||||
it("binds text args with ?", () => {
|
||||
const result = sql`WHERE name = ${{ text: "alice" }}`;
|
||||
assert.equal(result._sql, "WHERE name = ?");
|
||||
assert.deepEqual(result._vars, ["alice"]);
|
||||
});
|
||||
|
||||
it("binds uuid args with ? and converts to Buffer", () => {
|
||||
const uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
const result = sql`WHERE uid = ${{ uuid }}`;
|
||||
assert.equal(result._sql, "WHERE uid = ?");
|
||||
assert.equal(result._vars.length, 1);
|
||||
assert.ok(result._vars[0] instanceof Buffer, "uuid is stored as Buffer");
|
||||
});
|
||||
|
||||
it("binds jsonb args with jsonb(?) wrapper", () => {
|
||||
const result = sql`SET flags = ${{ jsonb: { admin: true } }}`;
|
||||
assert.equal(result._sql, "SET flags = jsonb(?)");
|
||||
assert.deepEqual(result._vars, [JSON.stringify({ admin: true })]);
|
||||
});
|
||||
|
||||
it("binds date args with datetime(?, ...) wrapper", () => {
|
||||
const date = "2024-01-01T00:00:00.000Z";
|
||||
const result = sql`SET ts = ${{ date }}`;
|
||||
assert.ok(result._sql.startsWith("SET ts = datetime("), result._sql);
|
||||
assert.equal(result._vars.length, 1);
|
||||
});
|
||||
|
||||
it("binds int args with ?", () => {
|
||||
const result = sql`LIMIT ${{ int: 10 }}`;
|
||||
assert.equal(result._sql, "LIMIT ?");
|
||||
assert.deepEqual(result._vars, [10]);
|
||||
});
|
||||
|
||||
it("throws for non-integer int value", () => {
|
||||
assert.throws(() => sql`LIMIT ${{ int: 10.5 }}`, /Not an integer/);
|
||||
});
|
||||
|
||||
it("emits NULL for null args", () => {
|
||||
const result = sql`= ${{ null: true }}`;
|
||||
assert.equal(result._sql, "= NULL");
|
||||
assert.deepEqual(result._vars, []);
|
||||
});
|
||||
|
||||
it("splices nested SqlCode", () => {
|
||||
const inner = sql`AND x = ${{ text: "foo" }}`;
|
||||
const outer = sql`WHERE 1=1 ${inner}`;
|
||||
assert.equal(outer._sql, "WHERE 1=1 AND x = ?");
|
||||
assert.deepEqual(outer._vars, ["foo"]);
|
||||
});
|
||||
|
||||
it("handles multiple args", () => {
|
||||
const result = sql`INSERT INTO t(a,b) VALUES(${{ text: "x" }}, ${{ int: 42 }})`;
|
||||
assert.equal(result._sql, "INSERT INTO t(a,b) VALUES(?, ?)");
|
||||
assert.deepEqual(result._vars, ["x", 42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catSql", () => {
|
||||
it("concatenates sql and vars", () => {
|
||||
const a = sql`SELECT * FROM t`;
|
||||
const b = sql` WHERE x = ${{ text: "y" }}`;
|
||||
const result = catSql(a, b);
|
||||
assert.equal(result._sql, "SELECT * FROM t WHERE x = ?");
|
||||
assert.deepEqual(result._vars, ["y"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinSql", () => {
|
||||
it("joins multiple parts with separator", () => {
|
||||
const parts = [
|
||||
sql`a = ${{ text: "1" }}`,
|
||||
sql`b = ${{ text: "2" }}`,
|
||||
sql`c = ${{ text: "3" }}`,
|
||||
];
|
||||
const result = joinSql(parts, sql`, `);
|
||||
assert.equal(result._sql, "a = ?, b = ?, c = ?");
|
||||
assert.deepEqual(result._vars, ["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("returns single part unchanged (no separator)", () => {
|
||||
const result = joinSql([sql`x = ${{ int: 1 }}`], sql`, `);
|
||||
assert.equal(result._sql, "x = ?");
|
||||
assert.deepEqual(result._vars, [1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcUpdates", () => {
|
||||
it("returns only keys present in the object", () => {
|
||||
const calc = calcUpdates({
|
||||
name: (v: string) => sql`name = ${{ text: v }}`,
|
||||
email: (v: string) => sql`email = ${{ text: v }}`,
|
||||
});
|
||||
const updates = calc({ name: "alice" });
|
||||
assert.equal(updates.length, 1);
|
||||
assert.equal(updates[0]._sql, "name = ?");
|
||||
assert.deepEqual(updates[0]._vars, ["alice"]);
|
||||
});
|
||||
|
||||
it("returns all keys when all are present", () => {
|
||||
const calc = calcUpdates({
|
||||
a: (v: string) => sql`a = ${{ text: v }}`,
|
||||
b: (v: string) => sql`b = ${{ text: v }}`,
|
||||
});
|
||||
const updates = calc({ a: "x", b: "y" });
|
||||
assert.equal(updates.length, 2);
|
||||
});
|
||||
|
||||
it("returns empty array when no keys match", () => {
|
||||
const calc = calcUpdates({
|
||||
name: (v: string) => sql`name = ${{ text: v }}`,
|
||||
});
|
||||
const updates = calc({});
|
||||
assert.equal(updates.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsafeSql", () => {
|
||||
it("wraps a raw sql string with no vars", () => {
|
||||
const result = unsafeSql("CREATE TABLE t (id INTEGER)");
|
||||
assert.equal(result._sql, "CREATE TABLE t (id INTEGER)");
|
||||
assert.deepEqual(result._vars, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js";
|
||||
import { sql, unsafeSql } from "../../../src/db/sqlite/sql.js";
|
||||
import type { WrappedDb } from "../../../src/db/sqlite/impl/types.js";
|
||||
|
||||
function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
|
||||
describe(`${name}: WrappedDb`, () => {
|
||||
let db: WrappedDb;
|
||||
|
||||
before(() => {
|
||||
db = makeDb();
|
||||
db.run(unsafeSql("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)"));
|
||||
});
|
||||
|
||||
after(() => db.destroy());
|
||||
|
||||
it("run INSERT returns changes count", () => {
|
||||
const { changes } = db.run(sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`);
|
||||
assert.equal(changes, 1);
|
||||
});
|
||||
|
||||
it("all SELECT returns all rows", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "a" }})`);
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "b" }})`);
|
||||
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test ORDER BY val"));
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].val, "a");
|
||||
assert.equal(rows[1].val, "b");
|
||||
});
|
||||
|
||||
it("get returns single row or null", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "one" }})`);
|
||||
const row = db.get<{ val: string }>(unsafeSql("SELECT val FROM test"));
|
||||
assert.ok(row !== null);
|
||||
assert.equal(row.val, "one");
|
||||
|
||||
const none = db.get<{ val: string }>(
|
||||
sql`SELECT val FROM test WHERE val = ${{ text: "none" }}`
|
||||
);
|
||||
assert.equal(none, null);
|
||||
});
|
||||
|
||||
it("get throws when multiple rows match", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup1" }})`);
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`);
|
||||
assert.throws(
|
||||
() => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")),
|
||||
/Multiple results/
|
||||
);
|
||||
});
|
||||
|
||||
it("multi commits on success", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
db.multi(() => {
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "in-tx" }})`);
|
||||
});
|
||||
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].val, "in-tx");
|
||||
});
|
||||
|
||||
it("multi rolls back on error", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
assert.throws(() =>
|
||||
db.multi(() => {
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`);
|
||||
throw new Error("abort!");
|
||||
})
|
||||
);
|
||||
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
|
||||
assert.equal(rows.length, 0);
|
||||
});
|
||||
|
||||
it("rethrow passes through return value", () => {
|
||||
const result = db.rethrow(() => 42);
|
||||
assert.equal(result, 42);
|
||||
});
|
||||
|
||||
it("rethrow propagates non-SQLite errors unchanged", () => {
|
||||
const err = new Error("custom error");
|
||||
assert.throws(() => db.rethrow(() => { throw err; }), (e) => e === err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
runWrappedDbSuite("node-sqlite", () => new WrappedNodeSqliteDb(":memory:"));
|
||||
|
||||
describe("better-sqlite3 WrappedDb", async () => {
|
||||
let bs3Ctor: (new (path: string) => WrappedDb) | null = null;
|
||||
|
||||
try {
|
||||
const mod = await import(
|
||||
"../../../src/db/sqlite/impl/better-sqlite3.js"
|
||||
);
|
||||
bs3Ctor = mod.WrappedBetterSqlite3Db;
|
||||
} catch {
|
||||
// better-sqlite3 not available, skip
|
||||
}
|
||||
|
||||
if (bs3Ctor) {
|
||||
runWrappedDbSuite("better-sqlite3", () => new bs3Ctor!(":memory:"));
|
||||
} else {
|
||||
it("better-sqlite3 is not available - skipped", (t) => {
|
||||
t.skip("better-sqlite3 optional dependency not found");
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user