From 978458f8863ac4520d3b476e0710572e9efbc199 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:41:06 -0700 Subject: [PATCH 1/4] test: add a real-Postgres e2e suite with add and search tests The shared test harness creates a fresh database per suite on the server named by TEST_DATABASE_URL, applies Interchange's migrations and memory's, mounts createMemoryRoutes under a stand-in tenant middleware, and drops the database afterwards. The first suite adds documents over HTTP, checks that search ranks them by relevance, and checks that another tenant sees none of them. bun run test now runs the unit and end-to-end suites, and CI runs it against a pgvector service; without TEST_DATABASE_URL the end-to-end suites skip. --- .github/workflows/ci.yml | 12 +++ CONTRIBUTING.md | 24 ++--- package.json | 2 +- tests/add-search.test.ts | 106 +++++++++++++++++++++ tests/lib/db-harness.ts | 194 +++++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 6 files changed, 322 insertions(+), 18 deletions(-) create mode 100644 tests/add-search.test.ts create mode 100644 tests/lib/db-harness.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5ca3d7..3816d5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,18 @@ on: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_PASSWORD: memory-test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s + --health-retries 10 + env: + TEST_DATABASE_URL: postgres://postgres:memory-test@localhost:5432/postgres steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb6e9bd..0544612 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,23 +24,15 @@ the pieces fit together. bun run typecheck && bun run test ``` -- `bun run test` runs the unit suite (`bun test ./src`) — every `core/*` - module, most services, and the route layer have colocated `*.test.ts` - files. It needs no external services. -- `bun run test:coverage` runs the same suite with lcov + text coverage +- `bun run test` runs the unit suite in `src/` and the end-to-end suite in + `tests/`. The end-to-end tests drive the mounted routes and migrations + against a real pgvector Postgres: set `TEST_DATABASE_URL` to a server the + tests can create and drop databases on (for `docker compose up -d`, + `postgres://memory:memory-dev-password@localhost:5434/memory`). Each suite + creates its own database and drops it afterwards. Without + `TEST_DATABASE_URL` those suites skip. +- `bun run test:coverage` runs the unit suite with lcov + text coverage reports. -- `bun run test:e2e` runs the integration suite (`bun test ./e2e`) — files - under the top-level `e2e/` directory drive the full stack against a - **real** pgvector Postgres and a **real** embedding endpoint. It needs both - reachable: - - `TEST_DATABASE_URL` (defaults to the `docker compose` connection string) - - `TEST_EMBED_BASE_URL` / `TEST_EMBED_MODEL` (default to a local Ollama at - `http://localhost:11434` / `nomic-embed-text`) - - If either is unreachable, the affected tests skip loudly with a logged - reason rather than failing. If you're changing anything in the capture or - search pipeline, run this suite with both dependencies up before opening a - PR. `bun run typecheck` (`tsc --noEmit`) must be clean before any commit. diff --git a/package.json b/package.json index c9ca4db..89a3312 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json", "prepack": "npm run build", - "test": "bun test ./src", + "test": "bun test ./src ./tests", "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text ./src" }, "dependencies": { diff --git a/tests/add-search.test.ts b/tests/add-search.test.ts new file mode 100644 index 0000000..bc31908 --- /dev/null +++ b/tests/add-search.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; +import type { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; + +import type { Memory } from "../src/memory.ts"; +import { + allow, + createTestApp, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +describe.skipIf(testDatabaseUrl() === undefined)("add and search", () => { + let db: TestDb; + let memory: Memory | undefined; + let app: Hono; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "alice"); + await seedPrincipal(db, "globex", "bob"); + const grantStore = createInMemoryGrantStore([ + allow("alice", "add"), + allow("alice", "search"), + allow("bob", "add"), + allow("bob", "search"), + ]); + memory = createTestMemory(db, grantStore); + app = createTestApp({ + memory, + grantStore, + callers: { + alice: { tenantId: "acme", principalId: "alice" }, + bob: { tenantId: "globex", principalId: "bob" }, + }, + }); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + function post(token: string, tenantId: string, path: string, body: unknown) { + return app.request(`/api/tenants/${tenantId}/memory${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + test("search ranks the most relevant added document first", async () => { + const docs = [ + { + title: "Staging deploys", + text: "Staging deploys run from main. Every staging deploy is automatic after merge.", + }, + { + title: "Lunch menu", + text: "Tacos on Tuesday, pizza on Friday, salad on Monday. Catering deploys to the staging lobby.", + }, + { title: "Vacation policy", text: "Request vacation two weeks ahead." }, + ]; + for (const doc of docs) { + const res = await post("alice", "acme", "/add", doc); + expect(res.status).toBe(200); + } + + const res = await post("alice", "acme", "/search", { + query: "staging deploy", + }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { items: { title: string }[] }; + expect(items.map((i) => i.title)).toEqual([ + "Staging deploys", + "Lunch menu", + ]); + }); + + test("another tenant's search returns none of the first tenant's documents", async () => { + const add = await post("alice", "acme", "/add", { + title: "Quarterly roadmap", + text: "The quarterly roadmap covers billing and onboarding.", + }); + expect(add.status).toBe(200); + + async function titles(token: string, tenantId: string): Promise { + const res = await post(token, tenantId, "/search", { + query: "quarterly roadmap", + }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { items: { title: string }[] }; + return items.map((i) => i.title); + } + + expect(await titles("alice", "acme")).toContain("Quarterly roadmap"); + expect(await titles("bob", "globex")).toEqual([]); + }); +}); diff --git a/tests/lib/db-harness.ts b/tests/lib/db-harness.ts new file mode 100644 index 0000000..879f1c4 --- /dev/null +++ b/tests/lib/db-harness.ts @@ -0,0 +1,194 @@ +// Real-Postgres harness for the tests/ suite. Each suite gets a fresh +// database on the server named by TEST_DATABASE_URL (any pgvector Postgres +// whose user can create databases, such as compose.yml's), with Interchange's control-plane tables and the memory +// migrations applied; `close` drops it. + +import { type DBConfig, runMigrations } from "@intx/db"; +import type { GrantRule, GrantStore } from "@intx/authz"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; +import { Hono } from "hono"; +import postgres from "postgres"; + +import { createMemory, type Memory } from "../../src/memory.ts"; +import { runMemoryMigrations } from "../../src/migrations.ts"; +import { createMemoryRoutes } from "../../src/routes/mount.ts"; + +const FTS_LANGUAGE = "english"; + +/** Gate for `describe.skipIf`: the suite skips when no server is configured. */ +export function testDatabaseUrl(): string | undefined { + return process.env["TEST_DATABASE_URL"]; +} + +function dbConfigFromUrl(url: URL, database: string): DBConfig { + return { + host: url.hostname, + port: Number(url.port || 5432), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database, + }; +} + +export type TestDb = { + config: DBConfig; + databaseUrl: string; + sql: postgres.Sql; + close: () => Promise; +}; + +/** An empty database on the test server, with nothing migrated. */ +export async function createEmptyDb(): Promise { + const serverUrl = testDatabaseUrl(); + if (serverUrl === undefined) { + throw new Error("TEST_DATABASE_URL is required for the tests/ suite"); + } + const url = new URL(serverUrl); + const database = `memory_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + const admin = postgres(serverUrl, { max: 1, onnotice: () => undefined }); + try { + await admin.unsafe(`CREATE DATABASE "${database}"`); + } catch (err) { + await admin.end(); + throw err; + } + + const dbUrl = new URL(url); + dbUrl.pathname = `/${database}`; + const sql = postgres(dbUrl.toString(), { max: 1, onnotice: () => undefined }); + return { + config: dbConfigFromUrl(url, database), + databaseUrl: dbUrl.toString(), + sql, + close: async () => { + try { + await sql.end(); + } finally { + await admin.unsafe(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); + await admin.end(); + } + }, + }; +} + +/** A fresh database with Interchange's tables and the memory migrations. */ +export async function createTestDb(): Promise { + const db = await createEmptyDb(); + try { + await runMigrations(db.config, { schema: "public" }); + await runMemoryMigrations(db.config, { + schema: "public", + ftsLanguage: FTS_LANGUAGE, + }); + } catch (err) { + await db.close(); + throw err; + } + return db; +} + +/** Insert the tenant and principal rows memory's foreign keys point at. */ +export async function seedPrincipal( + db: TestDb, + tenantId: string, + principalId: string, +): Promise { + await db.sql` + INSERT INTO public.tenant (id, name, slug, domain) + VALUES (${tenantId}, ${tenantId}, ${tenantId}, ${`${tenantId}.test`}) + ON CONFLICT (id) DO NOTHING`; + await db.sql` + INSERT INTO public.principal (id, tenant_id, kind, ref_id, status) + VALUES (${principalId}, ${tenantId}, 'user', ${principalId}, 'active')`; +} + +/** The engine-backed plane on the test database, lexical-only. */ +export function createTestMemory(db: TestDb, grantStore: GrantStore): Memory { + return createMemory({ + config: { + memory: { + databaseUrl: db.databaseUrl, + dbPoolMax: 4, + ftsLanguage: FTS_LANGUAGE, + rerank: { + baseUrl: undefined, + model: undefined, + apiKey: undefined, + maxDocChars: undefined, + timeoutMs: undefined, + }, + }, + }, + grantStore, + conditionRegistry: {}, + }); +} + +export function allow(principalId: string, action: string): GrantRule { + return { + id: `g-${principalId}-${action}`, + resource: "memory", + action, + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId, + }; +} + +export type TestCaller = { tenantId: string; principalId: string }; + +/** + * A host app with the memory routes mounted under the tenant tree. The + * stand-in tenant middleware seats the caller named by the bearer token and + * refuses a path naming another tenant, the way a hub's session middleware + * would. + */ +export function createTestApp(opts: { + memory: Memory; + grantStore: GrantStore; + callers: Record; +}): Hono { + const app = new Hono(); + app.use("/api/tenants/:tenantId/*", async (c, next) => { + const token = c.req.header("authorization")?.replace(/^Bearer /, ""); + const found = token === undefined ? undefined : opts.callers[token]; + if (found === undefined) return c.json({ error: "unauthenticated" }, 401); + if (c.req.param("tenantId") !== found.tenantId) { + return c.json({ error: "wrong tenant" }, 403); + } + c.set("principal", { + id: found.principalId, + tenantId: found.tenantId, + kind: "user", + refId: found.principalId, + status: "active", + createdAt: new Date(0), + updatedAt: new Date(0), + }); + c.set("tenant", { + id: found.tenantId, + name: found.tenantId, + slug: found.tenantId, + domain: `${found.tenantId}.test`, + parentId: null, + config: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }); + await next(); + }); + app.route( + "/api/tenants/:tenantId/memory", + createMemoryRoutes({ + memory: opts.memory, + requireGrant: createRequireGrant({ + grantStore: opts.grantStore, + conditionRegistry: {}, + }), + }), + ); + return app; +} diff --git a/tsconfig.json b/tsconfig.json index 7c24beb..94f57a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "forceConsistentCasingInFileNames": true, "types": ["bun"] }, - "include": ["src", "scripts"] + "include": ["src", "scripts", "tests"] } From 1e7e710fbecccfb5e4f14d744a1c64830dc2ad3d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:41:07 -0700 Subject: [PATCH 2/4] test(migrations): idempotent and honor the host schema A second run leaves the memory schema unchanged and creates no tables in public, and a replay behind a long reader fails on the lock timeout instead of blocking. With a non-public host schema the foreign keys point at that schema's tenant and principal tables, and replaying against a different host schema fails instead of passing silently. A database the ledger runner left before the temporal model gets the temporal_class backfill exactly once. --- tests/migrations.test.ts | 178 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/migrations.test.ts diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts new file mode 100644 index 0000000..7cdfcb5 --- /dev/null +++ b/tests/migrations.test.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runMigrations } from "@intx/db"; + +import { runMemoryMigrations } from "../src/migrations.ts"; +import { createEmptyDb, testDatabaseUrl, type TestDb } from "./lib/db-harness.ts"; + +const options = { schema: "public", ftsLanguage: "english" }; + +describe.skipIf(testDatabaseUrl() === undefined)("memory migrations", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "public" }); + }); + + afterAll(async () => { + await db.close(); + }); + + async function snapshot(schema: string): Promise { + // Tables, constraints and indexes: pgvector's extension objects land in + // public by design and are not memory's tables. + const rows = await db.sql<{ item: string }[]>` + SELECT 'column ' || table_name || '.' || column_name || ' ' || data_type AS item + FROM information_schema.columns WHERE table_schema = ${schema} + UNION ALL + SELECT 'constraint ' || conrelid::regclass || ' ' || pg_get_constraintdef(c.oid) + FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = ${schema} + UNION ALL + SELECT 'index ' || indexdef FROM pg_indexes WHERE schemaname = ${schema} + ORDER BY 1`; + return rows.map((r) => r.item); + } + + test("a second run is a no-op and no tables land in public", async () => { + const publicBefore = await snapshot("public"); + + await runMemoryMigrations(db.config, options); + const afterFirst = await snapshot("memory"); + await runMemoryMigrations(db.config, options); + const afterSecond = await snapshot("memory"); + + expect(afterFirst.some((i) => i.startsWith("column document.id "))).toBe(true); + expect(afterSecond).toEqual(afterFirst); + expect(await snapshot("public")).toEqual(publicBefore); + }); + + test("a replay behind a long reader waits for it instead of failing", async () => { + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const reader = db.sql.begin(async (tx) => { + await tx`SELECT count(*) FROM memory.version`; + await held; + }); + let settled = false; + const run = runMemoryMigrations(db.config, options).finally(() => { + settled = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + expect(settled).toBe(false); + } finally { + release(); + await reader; + } + await run; + }, 30_000); +}); + +describe.skipIf(testDatabaseUrl() === undefined)("concurrent memory migrations", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "public" }); + }); + + afterAll(async () => { + await db.close(); + }); + + // Every replica of a rolling deploy runs the migrations at boot. + const runThree = () => + Promise.all([0, 1, 2].map(() => runMemoryMigrations(db.config, options))); + + test("three runners on a fresh database all succeed", async () => { + await runThree(); + }, 30_000); + + test("three runners on a migrated database all succeed", async () => { + await runThree(); + }, 30_000); +}); + +describe.skipIf(testDatabaseUrl() === undefined)("memory migrations in a non-public host schema", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "hub" }); + }); + + afterAll(async () => { + await db.close(); + }); + + test("foreign keys point at the host schema, and a replay against another schema fails", async () => { + const hub = { schema: "hub", ftsLanguage: "english" }; + await runMemoryMigrations(db.config, hub); + await runMemoryMigrations(db.config, hub); + + const targets = await db.sql<{ target: string }[]>` + SELECT DISTINCT confrelid::regclass::text AS target + FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = 'memory' AND c.contype = 'f' + AND confrelid::regclass::text NOT LIKE 'memory.%' + ORDER BY 1`; + expect(targets.map((t) => t.target)).toEqual(["hub.principal", "hub.tenant"]); + + await runMigrations(db.config, { schema: "public" }); + await expect(runMemoryMigrations(db.config, options)).rejects.toThrow( + "already exists", + ); + }); +}); + +describe.skipIf(testDatabaseUrl() === undefined)("upgrading a database the ledger runner left before 0004", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "public" }); + const dir = join(import.meta.dirname, "..", "migrations"); + for (const file of (await readdir(dir)).sort()) { + if (file >= "0004") break; + const raw = await readFile(join(dir, file), "utf8"); + await db.sql.unsafe(raw.replaceAll("{{FTS_LANGUAGE}}", "english")); + } + await db.sql` + INSERT INTO memory.document (id, tenant_id, kind, title, adapter, external_ref) + VALUES ('doc-old', 'acme', 'note', 'Old claim', 'test', 'old')`; + await db.sql` + INSERT INTO memory.version + (id, tenant_id, document_id, version, content_hash, occurred_at, + created_by_kind, provenance) + VALUES ('ver-old', 'acme', 'doc-old', 1, 'h', now(), 'agent', 'inferred')`; + await db.sql` + INSERT INTO public.tenant (id, name, slug, domain) + VALUES ('acme', 'acme', 'acme', 'acme.test')`; + }); + + afterAll(async () => { + await db.close(); + }); + + test("the temporal_class backfill applies once and a later replay leaves new rows alone", async () => { + await runMemoryMigrations(db.config, options); + const [old] = await db.sql<{ temporal_class: string }[]>` + SELECT temporal_class FROM memory.version WHERE id = 'ver-old'`; + expect(old?.temporal_class).toBe("state"); + + await db.sql` + INSERT INTO memory.version + (id, tenant_id, document_id, version, content_hash, occurred_at, + created_by_kind, provenance, temporal_class) + VALUES ('ver-new', 'acme', 'doc-old', 2, 'h2', now(), 'agent', 'inferred', 'event')`; + await runMemoryMigrations(db.config, options); + const [fresh] = await db.sql<{ temporal_class: string }[]>` + SELECT temporal_class FROM memory.version WHERE id = 'ver-new'`; + expect(fresh?.temporal_class).toBe("event"); + }); +}); From 2ab193da017c553a6ca6f86901673ff605c60b9b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:41:07 -0700 Subject: [PATCH 3/4] test: grants, forget and purge against real Postgres A caller without memory:search gets 403, only a document's creator can forget it, and purge removes the document with its versions and chunks. --- tests/grants.test.ts | 103 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/grants.test.ts diff --git a/tests/grants.test.ts b/tests/grants.test.ts new file mode 100644 index 0000000..371813f --- /dev/null +++ b/tests/grants.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; +import type { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; + +import type { Memory } from "../src/memory.ts"; +import { + allow, + createTestApp, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +describe.skipIf(testDatabaseUrl() === undefined)("grants, forget and purge", () => { + let db: TestDb; + let memory: Memory | undefined; + let app: Hono; + + beforeAll(async () => { + db = await createTestDb(); + for (const principal of ["alice", "carol", "dave"]) { + await seedPrincipal(db, "acme", principal); + } + const grantStore = createInMemoryGrantStore([ + ...["add", "search", "forget", "purge"].map((a) => allow("alice", a)), + ...["search", "forget", "purge"].map((a) => allow("carol", a)), + allow("dave", "add"), + ]); + memory = createTestMemory(db, grantStore); + app = createTestApp({ + memory, + grantStore, + callers: { + alice: { tenantId: "acme", principalId: "alice" }, + carol: { tenantId: "acme", principalId: "carol" }, + dave: { tenantId: "acme", principalId: "dave" }, + }, + }); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + function post(token: string, path: string, body: unknown = {}) { + return app.request(`/api/tenants/acme/memory${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + async function addDocument(title: string): Promise { + const res = await post("alice", "/add", { title, text: `${title} body` }); + expect(res.status).toBe(200); + return ((await res.json()) as { documentId: string }).documentId; + } + + test("search without the memory:search grant is 403", async () => { + const res = await post("dave", "/search", { query: "anything" }); + expect(res.status).toBe(403); + }); + + test("forget succeeds for the creator and is 403 for anyone else", async () => { + const documentId = await addDocument("Forget me"); + + const other = await post("carol", `/documents/${documentId}/forget`); + expect(other.status).toBe(403); + + const creator = await post("alice", `/documents/${documentId}/forget`); + expect(creator.status).toBe(200); + const [row] = await db.sql<{ status: string }[]>` + SELECT status FROM memory.version WHERE document_id = ${documentId}`; + expect(row?.status).toBe("tombstoned"); + }); + + test("purge removes the document, its versions and its chunks", async () => { + const documentId = await addDocument("Purge me"); + const [before] = await db.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM memory.chunk c + JOIN memory.version v ON v.id = c.version_id + WHERE v.document_id = ${documentId}`; + expect(before?.n).toBeGreaterThan(0); + + const res = await post("alice", `/documents/${documentId}/purge`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ documentId, deleted: true }); + + const [left] = await db.sql<{ docs: number; versions: number; chunks: number }[]>` + SELECT + (SELECT count(*)::int FROM memory.document WHERE id = ${documentId}) AS docs, + (SELECT count(*)::int FROM memory.version WHERE document_id = ${documentId}) AS versions, + (SELECT count(*)::int FROM memory.chunk WHERE document_id = ${documentId}) AS chunks`; + expect(left).toEqual({ docs: 0, versions: 0, chunks: 0 }); + }); +}); From 54070843a31a7bf6614e9caba1ec70086d8d988e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:41:07 -0700 Subject: [PATCH 4/4] test: distill tick against real Postgres A tick over tenant-shared captures writes one inferred claim per document through the HTTP client, and a second tick from the returned cursor writes nothing. --- tests/distill-tick.test.ts | 111 +++++++++++++++++++++++++++++++++++++ tests/lib/db-harness.ts | 11 +++- 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/distill-tick.test.ts diff --git a/tests/distill-tick.test.ts b/tests/distill-tick.test.ts new file mode 100644 index 0000000..891a563 --- /dev/null +++ b/tests/distill-tick.test.ts @@ -0,0 +1,111 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; + +import { runDistillTick } from "../src/distiller/tick.ts"; +import { createMemoryHttpClient } from "../src/http-client.ts"; +import type { Memory } from "../src/memory.ts"; +import { + allow, + createTestApp, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +describe.skipIf(testDatabaseUrl() === undefined)("distill tick", () => { + let db: TestDb; + let memory: Memory | undefined; + let tick: (after: number) => ReturnType; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "alice"); + await seedPrincipal(db, "acme", "distiller"); + const grantStore = createInMemoryGrantStore([ + allow("alice", "add"), + allow("distiller", "add"), + allow("distiller", "search"), + { + id: "g-distiller-tenant-tag", + resource: "memory.tenant:acme", + action: "search", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: "distiller", + }, + ]); + memory = createTestMemory(db, grantStore); + const app = createTestApp({ + memory, + grantStore, + callers: { + alice: { tenantId: "acme", principalId: "alice" }, + distiller: { tenantId: "acme", principalId: "distiller" }, + }, + }); + const client = (authToken: string) => + createMemoryHttpClient({ + baseUrl: "http://hub.test", + tenantId: "acme", + authToken, + fetch: ((input: string, init?: RequestInit) => + app.request(input, init)) as typeof fetch, + }); + + for (const title of ["Standup notes", "Deploy checklist"]) { + await client("alice").add({ + title, + text: `${title} body`, + share: { tenant: true }, + }); + } + tick = (after) => + runDistillTick({ + client: client("distiller"), + after, + distill: async (entry) => ({ + action: "write", + title: `Claim from ${entry.title}`, + text: `Distilled: ${entry.title}`, + }), + }); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + async function documentCount(): Promise { + const [row] = await db.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM memory.document`; + return row?.n ?? 0; + } + + test("a tick writes one distilled claim per captured document, and a replay from its cursor writes nothing", async () => { + const first = await tick(0); + expect(first.wrote).toBe(2); + expect(await documentCount()).toBe(4); + const claims = await db.sql<{ provenance: string; source: string }[]>` + SELECT v.provenance, src_doc.title AS source + FROM memory.version v + JOIN memory.edge e ON e.rel = 'derived_from' AND e.from_ref = v.document_id + JOIN memory.version src ON src.id = e.to_ref + JOIN memory.document src_doc ON src_doc.id = src.document_id + WHERE v.generator_agent_id IS NOT NULL + ORDER BY source`; + expect([...claims]).toEqual([ + { provenance: "inferred", source: "Deploy checklist" }, + { provenance: "inferred", source: "Standup notes" }, + ]); + + const second = await tick(first.nextCursor); + expect(second.wrote).toBe(0); + expect(await documentCount()).toBe(4); + }); +}); diff --git a/tests/lib/db-harness.ts b/tests/lib/db-harness.ts index 879f1c4..64e3921 100644 --- a/tests/lib/db-harness.ts +++ b/tests/lib/db-harness.ts @@ -15,9 +15,16 @@ import { createMemoryRoutes } from "../../src/routes/mount.ts"; const FTS_LANGUAGE = "english"; -/** Gate for `describe.skipIf`: the suite skips when no server is configured. */ +/** + * Gate for `describe.skipIf`: the suite skips when no server is configured, + * except in CI, where a missing server fails the run instead. + */ export function testDatabaseUrl(): string | undefined { - return process.env["TEST_DATABASE_URL"]; + const url = process.env["TEST_DATABASE_URL"]; + if (url === undefined && process.env["CI"] !== undefined) { + throw new Error("TEST_DATABASE_URL is required in CI"); + } + return url; } function dbConfigFromUrl(url: URL, database: string): DBConfig {