Files
ClaudeMailbox/node/tests/server.test.ts
Mika Kuns 0c06e2cf4b feat(cleanup): hide and prune stale mailboxes
Mailbox listings grew unbounded as old sessions ended without
unregistering. This adds two layers of cleanup, configurable via
mailbox.json or `serve` flags:

- Lazy filter: list responses (REST /v1/list, MCP list_mailboxes)
  drop mailboxes idle longer than hideAfterMinutes (default 24h),
  while always keeping the caller and any sender with messages
  pending for them.
- Background sweep: startServer runs an initial prune on boot and
  schedules an unref'd interval timer that hard-deletes mailboxes
  idle longer than deleteAfterMinutes (default 7d) which have no
  pending messages, and wipes their delivered history.
2026-05-20 13:54:03 +02:00

214 lines
7.0 KiB
TypeScript

import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { MailboxStore } from "../src/db.js";
import { buildServer } from "../src/server.js";
import type { FastifyInstance } from "fastify";
let dir: string;
let dbPath: string;
let store: MailboxStore;
let app: FastifyInstance;
let baseUrl: string;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
dbPath = join(dir, "test.db");
store = new MailboxStore(dbPath);
app = await buildServer(
{
port: 0,
bind: "127.0.0.1",
dbPath,
hideAfterMinutes: 0,
deleteAfterMinutes: 0,
sweepIntervalMinutes: 0,
},
store,
);
await app.listen({ host: "127.0.0.1", port: 0 });
const addr = app.server.address();
if (!addr || typeof addr === "string") throw new Error("no address");
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
await app.close();
store.close();
rmSync(dir, { recursive: true, force: true });
});
async function call(
method: string,
path: string,
init: { headers?: Record<string, string>; body?: unknown } = {},
): Promise<{ status: number; body: unknown }> {
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
let body: string | undefined;
if (init.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(init.body);
}
const res = await fetch(`${baseUrl}${path}`, { method, headers, body });
const text = await res.text();
return { status: res.status, body: text.length ? JSON.parse(text) : null };
}
describe("REST surface", () => {
it("/health is anonymous", async () => {
const r = await call("GET", "/health");
expect(r.status).toBe(200);
expect(r.body).toMatchObject({ status: "ok", dbPath });
});
it("POST /v1/send requires X-Mailbox", async () => {
const r = await call("POST", "/v1/send", { body: { to: "bob", body: "hi" } });
expect(r.status).toBe(400);
});
it("POST /v1/send → /v1/check-inbox round-trip", async () => {
const send = await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "hi bob" },
});
expect(send.status).toBe(200);
expect(send.body).toMatchObject({ id: expect.any(Number), queuedAt: expect.any(String) });
const peek = await call("GET", "/v1/peek?name=bob");
expect(peek.status).toBe(200);
expect(peek.body).toMatchObject({ pending: 1 });
const check = await call("POST", "/v1/check-inbox?name=bob", {
headers: { "X-Mailbox": "bob" },
});
expect(check.status).toBe(200);
expect(Array.isArray(check.body)).toBe(true);
const arr = check.body as Array<{ from: string; body: string }>;
expect(arr).toHaveLength(1);
expect(arr[0]!.from).toBe("alice");
expect(arr[0]!.body).toBe("hi bob");
const peekAfter = await call("GET", "/v1/peek?name=bob");
expect(peekAfter.body).toMatchObject({ pending: 0, oldestAt: null });
});
it("POST /v1/check-inbox rejects mismatched X-Mailbox", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
const wrong = await call("POST", "/v1/check-inbox?name=bob", {
headers: { "X-Mailbox": "alice" },
});
expect(wrong.status).toBe(403);
});
it("POST /v1/rename transfers pending messages and exposes the new name", async () => {
// alice sends to bob-old.
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob-old", body: "hi old bob" },
});
const rename = await call("POST", "/v1/rename", {
headers: { "X-Mailbox": "bob-old" },
body: { to: "bob-new" },
});
expect(rename.status).toBe(200);
expect(rename.body).toMatchObject({
from: "bob-old",
to: "bob-new",
messagesTransferred: 1,
});
// Peek under new name shows the pending msg; old name is empty.
const peekNew = await call("GET", "/v1/peek?name=bob-new");
expect(peekNew.body).toMatchObject({ pending: 1 });
const peekOld = await call("GET", "/v1/peek?name=bob-old");
expect(peekOld.body).toMatchObject({ pending: 0 });
// check-inbox under new name pulls the message.
const check = await call("POST", "/v1/check-inbox?name=bob-new", {
headers: { "X-Mailbox": "bob-new" },
});
const arr = check.body as Array<{ from: string; body: string }>;
expect(arr).toHaveLength(1);
expect(arr[0]!.body).toBe("hi old bob");
});
it("POST /v1/rename returns 409 when target name is taken", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
// 'taken' already exists thanks to upsert on X-Mailbox.
const r = await call("POST", "/v1/rename", {
headers: { "X-Mailbox": "bob" },
body: { to: "alice" },
});
expect(r.status).toBe(409);
expect(r.body).toMatchObject({ reason: "target-exists" });
});
it("POST /v1/rename requires X-Mailbox and body.to", async () => {
const missingHeader = await call("POST", "/v1/rename", { body: { to: "x" } });
expect(missingHeader.status).toBe(400);
const missingTo = await call("POST", "/v1/rename", {
headers: { "X-Mailbox": "alice" },
body: {},
});
expect(missingTo.status).toBe(400);
});
it("/v1/list filters out mailboxes idle beyond hideAfterMinutes", async () => {
await app.close();
store.close();
store = new MailboxStore(dbPath);
store.upsertMailbox("recent");
store.upsertMailbox("stale");
store.close();
const handle = new DatabaseSync(dbPath);
const past = new Date(Date.now() - 120 * 60_000).toISOString();
handle.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(past, "stale");
handle.close();
store = new MailboxStore(dbPath);
app = await buildServer(
{
port: 0,
bind: "127.0.0.1",
dbPath,
hideAfterMinutes: 60,
deleteAfterMinutes: 0,
sweepIntervalMinutes: 0,
},
store,
);
await app.listen({ host: "127.0.0.1", port: 0 });
const addr = app.server.address();
if (!addr || typeof addr === "string") throw new Error("no address");
baseUrl = `http://127.0.0.1:${addr.port}`;
const r = await call("GET", "/v1/list");
expect(r.status).toBe(200);
const names = (r.body as Array<{ name: string }>).map((m) => m.name);
expect(names).toContain("recent");
expect(names).not.toContain("stale");
});
it("/v1/list and /v1/peek are anonymous", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
const list = await call("GET", "/v1/list");
expect(list.status).toBe(200);
expect(Array.isArray(list.body)).toBe(true);
const peek = await call("GET", "/v1/peek?name=bob");
expect(peek.status).toBe(200);
});
});