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>
253 lines
8.7 KiB
TypeScript
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");
|
|
});
|
|
});
|