Files
ClaudeMailbox/node/tests/server.test.ts
mika kuns 1f7585152e feat(hook): close mailbox on SessionEnd
New SessionEnd plugin hook runs `claude-mailbox session-end`, which derives the session's auto-name from stdin and asks the daemon to delete the mailbox if it has no pending messages either direction. Renamed mailboxes are preserved (the auto-name no longer exists, so DELETE is a no-op). The daemon-side `SKIP_UPSERT_PATHS` prevents the request from auto-recreating the mailbox. Sweeper remains the safety net for sessions that exit ungracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:39:29 +02:00

253 lines
8.7 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);
});
it("POST /v1/session-end deletes an empty mailbox and does not auto-recreate it", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
await call("POST", "/v1/check-inbox?name=bob", { headers: { "X-Mailbox": "bob" } });
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } });
expect(r.status).toBe(200);
expect(r.body).toMatchObject({ name: "bob", deleted: true, reason: "deleted" });
const list = await call("GET", "/v1/list");
const names = (list.body as Array<{ name: string }>).map((m) => m.name);
expect(names).not.toContain("bob");
});
it("POST /v1/session-end refuses to delete a mailbox with pending messages", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "still pending" },
});
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } });
expect(r.status).toBe(200);
expect(r.body).toMatchObject({ name: "bob", deleted: false, reason: "has-pending" });
const peek = await call("GET", "/v1/peek?name=bob");
expect(peek.body).toMatchObject({ pending: 1 });
});
it("POST /v1/session-end is a no-op for an unknown (e.g. renamed) name", async () => {
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "renamed-away" } });
expect(r.status).toBe(200);
expect(r.body).toMatchObject({ name: "renamed-away", deleted: false, reason: "not-found" });
const list = await call("GET", "/v1/list");
const names = (list.body as Array<{ name: string }>).map((m) => m.name);
expect(names).not.toContain("renamed-away");
});
});