Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 8 additions & 16 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
106 changes: 106 additions & 0 deletions tests/add-search.test.ts
Original file line number Diff line number Diff line change
@@ -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<TenantEnv>;

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<string[]> {
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([]);
});
});
111 changes: 111 additions & 0 deletions tests/distill-tick.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof runDistillTick>;

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<number> {
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);
});
});
103 changes: 103 additions & 0 deletions tests/grants.test.ts
Original file line number Diff line number Diff line change
@@ -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<TenantEnv>;

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<string> {
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 });
});
});
Loading
Loading