Files
codingetandCodex a0a436c4b8
CI / lint (pull_request) Successful in 32s
CI / format (pull_request) Failing after 32s
CI / install-and-build (pull_request) Successful in 1m0s
CI / typecheck-tests (pull_request) Successful in 36s
CI / typecheck-source (pull_request) Successful in 37s
CI / test (pull_request) Successful in 41s
feat(postgres): implement notes CRUD and export import
Co-Authored-By: gpt-5.6-luna <noreply@openai.com>
2026-07-23 18:49:23 +00:00

329 lines
9.8 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { Readable } from "node:stream";
import { PostgresInterface } from "../../../src/db/postgres/PostgresInterface.js";
import type { WrappedPgClient } from "../../../src/db/postgres/pool.js";
import type { SqlCode } from "../../../src/db/postgres/sql.js";
import {
EXPORT_KIND_ORDER,
type ExportKind,
} from "../../../src/db/types/ExportImport.js";
// The postgres backend has no CI database, so these tests drive the real
// PostgresInterface.export/import code paths against an in-memory fake client.
// They verify control flow (NDJSON shape, meta.source, filtering, note
// skipping, counts, insert dispatch, abort -> rollback); the SQL-arg forms are
// the same {uuid}/{text}/{jsonb}/{date} patterns the pg backend's own CRUD
// already exercises against real Postgres.
interface Rows {
users?: Record<string, unknown>[];
abodes?: Record<string, unknown>[];
residents?: Record<string, unknown>[];
apikeys?: Record<string, unknown>[];
notes?: Record<string, unknown>[];
}
class FakePg implements WrappedPgClient {
readonly = false;
inserts: { table: string; vars: unknown[] }[] = [];
committed = false;
rolledBack = false;
#rows: Rows;
constructor(rows: Rows = {}) {
this.#rows = rows;
}
async destroy(): Promise<void> {}
async all<R>(stmt: SqlCode): Promise<R[]> {
const s = stmt._sql;
if (s.includes('FROM "users"')) return (this.#rows.users ?? []) as R[];
if (s.includes('FROM "abodes"')) return (this.#rows.abodes ?? []) as R[];
if (s.includes('FROM "residents"'))
return (this.#rows.residents ?? []) as R[];
if (s.includes('FROM "apikeys"')) return (this.#rows.apikeys ?? []) as R[];
if (s.includes('FROM "notes"')) return (this.#rows.notes ?? []) as R[];
return [] as R[];
}
async get<R>(stmt: SqlCode): Promise<R | null> {
const rows = await this.all<R>(stmt);
return rows[0] ?? null;
}
async run(stmt: SqlCode): Promise<{ changes: number }> {
const table = stmt._sql.match(/INSERT INTO "(\w+)"/)?.[1] ?? "?";
this.inserts.push({ table, vars: stmt._vars });
return { changes: 1 };
}
async multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
try {
const r = await fn(this);
this.committed = true;
return r;
} catch (e) {
this.rolledBack = true;
throw e;
}
}
async rethrow<R>(fn: () => Promise<R>): Promise<R> {
return fn();
}
}
const iso = "2026-01-02T03:04:05.000Z";
function seededRows(): Rows {
return {
users: [
{
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: new Date(iso),
updated_at: new Date(iso),
},
],
abodes: [
{
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Abode One",
created_at: new Date(iso),
created_by: "11111111-1111-1111-1111-111111111111",
updated_at: new Date(iso),
updated_by: null,
},
{
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2",
name: "Abode Two",
created_at: new Date(iso),
created_by: null,
updated_at: new Date(iso),
updated_by: null,
},
],
residents: [
{
uid: "11111111-1111-1111-1111-111111111111",
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
flags: {},
created_at: new Date(iso),
created_by: null,
updated_at: new Date(iso),
updated_by: null,
},
],
apikeys: [
{
uid: "11111111-1111-1111-1111-111111111111",
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
name: "key one",
permissions: {},
created_at: new Date(iso),
expires_at: null,
},
],
notes: [
{
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Note one",
content: "# Content",
properties: { type: "note" },
created_at: new Date(iso),
created_by: "11111111-1111-1111-1111-111111111111",
updated_at: new Date(iso),
updated_by: null,
},
],
};
}
async function streamToString(s: NodeJS.ReadableStream): Promise<string> {
let out = "";
for await (const chunk of s) out += chunk;
return out;
}
function parseLines(ndjson: string): { kind: string; data: any }[] {
return ndjson
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
}
function line(kind: string, data: unknown): string {
return JSON.stringify({ kind, data }) + "\n";
}
describe("postgres export", () => {
it("streams meta and all supported kinds, including notes", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(await streamToString(db.export()));
const meta = lines.find((l) => l.kind === "meta");
assert.ok(meta);
assert.equal(meta!.data.source, "postgres");
assert.equal(meta!.data.v, 1);
const kinds = new Set(lines.map((l) => l.kind));
assert.ok(kinds.has("user"));
assert.ok(kinds.has("abode"));
assert.ok(kinds.has("resident"));
assert.ok(kinds.has("apikey"));
assert.ok(kinds.has("note"));
});
it("emits record kinds grouped in FK-safe EXPORT_KIND_ORDER", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(await streamToString(db.export()));
assert.equal(lines[0]?.kind, "meta", "first line is meta");
const rank = (k: string) => EXPORT_KIND_ORDER.indexOf(k as ExportKind);
let last = -1;
for (const { kind } of lines.slice(1)) {
const r = rank(kind);
assert.notEqual(r, -1, `unexpected kind ${kind}`);
assert.ok(r >= last, `kind ${kind} out of FK-safe order`);
last = r;
}
});
it("applies the kinds filter", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(
await streamToString(db.export({ filter: { kinds: ["abode"] } })),
);
const kinds = new Set(
lines.filter((l) => l.kind !== "meta").map((l) => l.kind),
);
assert.deepEqual(kinds, new Set(["abode"]));
});
it("applies the abodes allowlist to abode/resident records", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(
await streamToString(
db.export({
filter: { abodes: ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] },
}),
),
);
const abodeAids = lines
.filter((l) => l.kind === "abode")
.map((l) => l.data.aid);
assert.deepEqual(abodeAids, ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"]);
});
});
describe("postgres import", () => {
it("dispatches inserts per kind, including note, and commits", async () => {
const fake = new FakePg();
const db = new PostgresInterface(fake);
const source = Readable.from([
line("meta", { v: 1 }),
line("user", {
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: iso,
updated_at: iso,
}),
line("abode", {
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Abode One",
created_at: iso,
created_by: "11111111-1111-1111-1111-111111111111",
updated_at: iso,
updated_by: null,
}),
line("apikey", {
uid: "11111111-1111-1111-1111-111111111111",
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
name: "key one",
permissions: {},
created_at: iso,
expires_at: null,
}),
line("note", {
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Note",
content: "x",
properties: {},
created_at: iso,
created_by: null,
updated_at: iso,
updated_by: null,
}),
]);
const result = await db.import(source);
assert.deepEqual(result.counts, { user: 1, abode: 1, apikey: 1, note: 1 });
assert.ok(fake.committed);
assert.deepEqual(fake.inserts.map((i) => i.table).sort(), [
"abodes",
"apikeys",
"notes",
"users",
]);
// apikey gets a freshly-minted token (never exported)
const apikeyInsert = fake.inserts.find((i) => i.table === "apikeys")!;
assert.ok(
apikeyInsert.vars.some(
(v) => typeof v === "string" && v.startsWith("at_"),
),
"apikey insert carries a fresh token",
);
});
it("rejects and rolls back when the stream carries an error sentinel", async () => {
const fake = new FakePg();
const db = new PostgresInterface(fake);
const source = Readable.from([
line("meta", { v: 1 }),
line("user", {
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: iso,
updated_at: iso,
}),
line("error", { message: "boom" }),
]);
await assert.rejects(() => db.import(source), /boom/);
assert.ok(fake.rolledBack);
assert.ok(!fake.committed);
});
it("rejects and rolls back on signal abort", async () => {
const fake = new FakePg();
const db = new PostgresInterface(fake);
const ac = new AbortController();
const source = Readable.from(
(async function* () {
yield line("meta", { v: 1 });
yield line("user", {
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: iso,
updated_at: iso,
});
ac.abort();
await new Promise((r) => setTimeout(r, 1000));
})(),
);
await assert.rejects(() => db.import(source, { signal: ac.signal }));
assert.ok(fake.rolledBack);
assert.ok(!fake.committed);
});
});