From 5ed809f81ebb4bd75e558258bf85fe8d31409f54 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:34:26 -0700 Subject: [PATCH] test: real-Postgres harness and an HTTP send-and-read e2e A suite gets its own migrated database behind a host that plays the tenant middleware, so a test drives the routes the way a hub does rather than calling internals. Closes CL-9325, CL-9072. --- .github/workflows/test.yml | 3 +- CONTRIBUTING.md | 3 + README.md | 2 +- package.json | 2 +- tests/lib/db-harness.ts | 107 ++++++++++++++++++++++++++++++++++++ tests/send-and-read.test.ts | 77 ++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 tests/lib/db-harness.ts create mode 100644 tests/send-and-read.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 227ca81..a87f4fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,6 @@ jobs: env: MAILBOX_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5433/mailbox_core - MAILBOX_DATABASE_URL: postgres://postgres:postgres@localhost:5433/mailbox_core steps: - uses: actions/checkout@v4 @@ -44,7 +43,7 @@ jobs: run: bun run typecheck - name: unit + integration tests - run: bun test src + run: bun test src tests # A consumer on plain Node must be able to install and import the # tarball; Node cannot strip types, so a src-pointing manifest would die diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 374c0d8..1bddeb2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,9 @@ concurrency are asserted against a live server, because that is the only place t true. Database-touching tests clean up after themselves and must not assume they are alone — concurrency behavior is part of the contract here. +The suite connects to `MAILBOX_TEST_DATABASE_URL`, which defaults to +`postgres://postgres:postgres@localhost:5433/mailbox_core` (the CI service's port). + ## Acceptance scenarios live in corbitsdev/examples The end-to-end acceptance scenarios that mount this package on a real diff --git a/README.md b/README.md index 12cefc8..39e362a 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ bun run test bun run build ``` -Tests expect `postgres://postgres:postgres@localhost:5433/mailbox_core` (override with `MAILBOX_TEST_DATABASE_URL` / `MAILBOX_DATABASE_URL`). See [CONTRIBUTING.md](./CONTRIBUTING.md). +Tests expect `postgres://postgres:postgres@localhost:5433/mailbox_core` (override with `MAILBOX_TEST_DATABASE_URL`). The end-to-end suites in `tests/` create and drop a database each, so that role needs `CREATEDB`. See [CONTRIBUTING.md](./CONTRIBUTING.md). ## License diff --git a/package.json b/package.json index 0d09aff..6589b77 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepack": "bun run build", "typecheck": "tsc --noEmit", - "test": "bun test src" + "test": "bun test src tests" }, "dependencies": { "@hono/standard-validator": "0.2.3", diff --git a/tests/lib/db-harness.ts b/tests/lib/db-harness.ts new file mode 100644 index 0000000..0af809b --- /dev/null +++ b/tests/lib/db-harness.ts @@ -0,0 +1,107 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { Hono } from "hono"; +import postgres from "postgres"; +import type { TenantEnv } from "@intx/hub-api"; +import { + createInMemoryMailboxEventBus, + createMailboxRoutes, + runMailboxMigrations, + type CreateMailboxRoutesDeps, + type MailboxDb, +} from "../../src/index.js"; +import { + allowAllGrants, + createHostControlPlane, + TEST_DATABASE_URL, +} from "../../src/test-helpers.js"; + +export type TestDb = { + db: MailboxDb; + close: () => Promise; +}; + +// The mailbox pins its tables to the "mailbox" schema and its FKs to +// "public", so a suite is isolated by database rather than by schema. +async function admin( + run: (sql: postgres.Sql) => Promise, +): Promise { + const sql = postgres(TEST_DATABASE_URL, { max: 1, onnotice: () => {} }); + try { + return await run(sql); + } finally { + await sql.end(); + } +} + +/** A fresh database with the host control plane and every mailbox migration applied. */ +export async function createTestDb(): Promise { + const name = `mailbox_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; + await admin((sql) => sql.unsafe(`CREATE DATABASE "${name}"`)); + const url = new URL(TEST_DATABASE_URL); + url.pathname = `/${name}`; + const client = postgres(url.toString(), { onnotice: () => {} }); + const db = drizzle(client); + const close = async () => { + await client.end(); + await admin((sql) => sql.unsafe(`DROP DATABASE "${name}" WITH (FORCE)`)); + }; + try { + await createHostControlPlane(db); + await runMailboxMigrations(db); + } catch (err) { + await close(); + throw err; + } + return { db, close }; +} + +export const TEST_TENANT = { + id: "t1", + name: "t1", + slug: "t1", + domain: "t1.example", + parentId: null, + config: null, + createdAt: new Date(0), + updatedAt: new Date(0), +}; + +/** Request header naming the principal the test host authenticates the request as. */ +export const PRINCIPAL_HEADER = "x-test-principal"; + +/** + * A host app that plays the tenant middleware: every request runs as + * `TEST_TENANT` and the principal named by `PRINCIPAL_HEADER`, and the + * mailbox routes are mounted under `/mailbox`. + */ +export function createTestApp( + deps: Pick, +): Hono { + const app = new Hono(); + app.use(async (c, next) => { + const principalId = c.req.header(PRINCIPAL_HEADER); + if (principalId === undefined) return c.json({ error: "unauthenticated" }, 401); + c.set("tenant", TEST_TENANT); + c.set("principal", { + id: principalId, + tenantId: TEST_TENANT.id, + kind: "user", + refId: principalId, + status: "active", + createdAt: new Date(0), + updatedAt: new Date(0), + }); + await next(); + }); + app.route( + "/mailbox", + createMailboxRoutes({ + db: deps.db, + bus: createInMemoryMailboxEventBus(), + requireGrant: allowAllGrants, + senderAddressFor: deps.senderAddressFor, + deliver: deps.deliver, + }), + ); + return app; +} diff --git a/tests/send-and-read.test.ts b/tests/send-and-read.test.ts new file mode 100644 index 0000000..5d6161d --- /dev/null +++ b/tests/send-and-read.test.ts @@ -0,0 +1,77 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import { createMailboxPersist } from "../src/index.js"; +import { seedScope } from "../src/test-helpers.js"; +import { + createTestApp, + createTestDb, + PRINCIPAL_HEADER, + TEST_TENANT, + type TestDb, +} from "./lib/db-harness.js"; + +let testDb: TestDb | undefined; +let app: Hono; + +beforeAll(async () => { + const { db } = (testDb = await createTestDb()); + await seedScope(db, TEST_TENANT.id, "alice", "bob"); + // The host's transport files each sent message into its recipients' inboxes. + const persist = createMailboxPersist(db, { + upstream: async () => {}, + authorizeSender: () => ({ tenantId: TEST_TENANT.id, domain: TEST_TENANT.domain }), + }); + app = createTestApp({ + db, + senderAddressFor: ({ principalId }) => `${principalId}@${TEST_TENANT.domain}`, + deliver: ({ from, to, raw }) => persist({ senderAddress: from, recipients: to, raw }), + }); +}); + +afterAll(async () => { + await testDb?.close(); +}); + +type ListBody = { + messages: { + uid: number; + envelope: { from: string; subject: string }; + raw: string; + }[]; +}; + +test("a message sent over HTTP is listed and readable in the recipient's inbox", async () => { + const sent = await app.request("/mailbox/me/inbox/send", { + method: "POST", + headers: { "content-type": "application/json", [PRINCIPAL_HEADER]: "alice" }, + body: JSON.stringify({ to: ["bob@t1.example"], subject: "Lunch", body: "Noon?" }), + }); + expect(sent.status).toBe(200); + + const inbox = await app.request("/mailbox/me/inbox", { + headers: { [PRINCIPAL_HEADER]: "bob" }, + }); + expect(inbox.status).toBe(200); + const { messages } = (await inbox.json()) as ListBody; + expect(messages).toHaveLength(1); + const [message] = messages; + expect(message!.envelope.from).toBe("alice@t1.example"); + expect(message!.envelope.subject).toBe("Lunch"); + expect(Buffer.from(message!.raw, "base64").toString()).toContain("Noon?"); + + const thread = await app.request(`/mailbox/me/inbox/threads/${message!.uid}`, { + headers: { [PRINCIPAL_HEADER]: "bob" }, + }); + expect(thread.status).toBe(200); + const { thread: root } = (await thread.json()) as { + thread: { uid: number; envelope: { subject: string }; children: unknown[] }; + }; + expect(root.uid).toBe(message!.uid); + expect(root.envelope.subject).toBe("Lunch"); + + const aliceInbox = await app.request("/mailbox/me/inbox", { + headers: { [PRINCIPAL_HEADER]: "alice" }, + }); + expect(((await aliceInbox.json()) as ListBody).messages).toHaveLength(0); +});