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
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions tests/lib/db-harness.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
};

// 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<T>(
run: (sql: postgres.Sql) => Promise<T>,
): Promise<T> {
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<TestDb> {
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<CreateMailboxRoutesDeps, "db" | "senderAddressFor" | "deliver">,
): Hono<TenantEnv> {
const app = new Hono<TenantEnv>();
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;
}
77 changes: 77 additions & 0 deletions tests/send-and-read.test.ts
Original file line number Diff line number Diff line change
@@ -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<TenantEnv>;

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