logicsrc/apps/commandboard-api/src/contract.test.ts
Anthony Ettinger f27210a192
feat(commandboard-api): expose AgentMail (agentbbs mailbox) routes (#86)
Mount @logicsrc/plugin-agentmail in the CommandBoard API so members can
list/read/search/send mail over the existing agentbbs Mailu server.

- src/agentmail.ts: inject imapflow + nodemailer + mailparser drivers into
  the plugin's Mailu transport (kept out of the plugin by design); env-driven
  builder (AGENTMAIL_BACKEND=mailu → bbs Mailu, else in-memory singleton for
  dev/test). Handles STARTTLS cert-vs-loopback via AGENTMAIL_SMTP_TLS_SERVERNAME.
- index.ts: register agentMailPlugin + 7 routes under /api/plugins/agentmail/*
  (mailboxes, list, read, search, send, PATCH flags, delete). Acting member
  from x-agentmail-member header (else AGENTMAIL_MEMBER), paid-gated;
  MailAccessError->402, DraftError->422.
- 6 route tests (in-memory backend) + contract test plugin-id list updated.
  commandboard-api 20/20, builds clean.

Note: run `npm install` to refresh the lockfile for the 3 new deps.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:54:00 -07:00

229 lines
11 KiB
TypeScript

import type { Server } from "node:http";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createCommandBoardServer, readPort } from "./index.js";
let server: Server;
let baseUrl: string;
beforeAll(async () => {
server = createCommandBoardServer();
await new Promise<void>((resolve) => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected API server to bind to a local port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
describe("CommandBoard API contracts", () => {
it("falls back when PORT is invalid", () => {
expect(readPort("not-a-port", 4010)).toBe(4010);
expect(readPort("0", 4010)).toBe(4010);
expect(readPort("65536", 4010)).toBe(4010);
expect(readPort("4020", 4010)).toBe(4020);
});
it("exposes root API index", async () => {
const response = await fetch(`${baseUrl}/`);
const body = await response.json() as { ok: boolean; service: string; endpoints: string[] };
expect(response.status).toBe(200);
expect(body).toMatchObject({ ok: true, service: "commandboard-api" });
expect(body.endpoints).toEqual(expect.arrayContaining(["/health", "/api/boards", "/api/plugins"]));
});
it("exposes health contract", async () => {
const response = await fetch(`${baseUrl}/health`);
const body = await response.json() as { ok: boolean; service: string };
expect(response.status).toBe(200);
expect(body).toEqual({ ok: true, service: "commandboard-api" });
});
it("rejects malformed task JSON with a client error", async () => {
const response = await fetch(`${baseUrl}/api/tasks`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not-json"
});
const body = await response.json() as { error: string };
expect(response.status).toBe(400);
expect(body).toEqual({ error: "Invalid JSON body" });
});
it("exposes default plugin contract including product plugins", async () => {
const response = await fetch(`${baseUrl}/api/plugins`);
const body = await response.json() as {
plugins: Array<{ id: string; enabled: boolean; capabilities: string[] }>;
capabilities: Record<string, string[]>;
};
expect(response.status).toBe(200);
expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute", "feed-discovery", "social-accounts", "email-accounts", "agentmail"]);
expect(body.plugins.find((plugin) => plugin.id === "sh1pt")).toMatchObject({
enabled: true,
capabilities: expect.arrayContaining(["projects.sync", "actions.publish", "deployments.status"])
});
expect(body.plugins.find((plugin) => plugin.id === "c0mpute")).toMatchObject({
enabled: true,
capabilities: expect.arrayContaining(["compute.jobs.sync", "compute.jobs.dispatch", "compute.workers.sync"])
});
expect(body.capabilities["actions.publish"]).toEqual(["sh1pt"]);
expect(body.capabilities["compute.jobs.dispatch"]).toEqual(["c0mpute"]);
expect(body.capabilities["feeds.discover"]).toEqual(["feed-discovery"]);
expect(body.capabilities["social.post.publish"]).toEqual(["social-accounts"]);
expect(body.capabilities["email.send"]).toEqual(["email-accounts"]);
});
it("exposes communication account provider contracts", async () => {
const accountsResponse = await fetch(`${baseUrl}/api/accounts/providers`);
const accountsBody = await accountsResponse.json() as { providers: Array<{ id: string; kind: string }> };
const socialResponse = await fetch(`${baseUrl}/api/social/providers`);
const socialBody = await socialResponse.json() as { providers: Array<{ id: string; kind: string }> };
const emailResponse = await fetch(`${baseUrl}/api/email/providers`);
const emailBody = await emailResponse.json() as { providers: Array<{ id: string; kind: string }> };
expect(accountsResponse.status).toBe(200);
expect(accountsBody.providers.map((provider) => provider.id)).toEqual(expect.arrayContaining(["mastodon", "gmail", "imap-smtp"]));
expect(socialResponse.status).toBe(200);
expect(socialBody.providers[0]).toMatchObject({ id: "mastodon", kind: "social" });
expect(emailResponse.status).toBe(200);
expect(emailBody.providers[0]).toMatchObject({ id: "imap-smtp", kind: "email" });
});
it("exposes feed discovery plugin endpoints", async () => {
const providersResponse = await fetch(`${baseUrl}/api/feeds/providers`);
const providersBody = await providersResponse.json() as { providers: Array<{ id: string; enabledByDefault: boolean }> };
const discoverResponse = await fetch(`${baseUrl}/api/feeds/discover?q=microsaas&providers=manual-curated&includeUnvalidated=true`);
const discoverBody = await discoverResponse.json() as { query: string; results: Array<{ feedUrl: string; provider: string }> };
const invalidLimitResponse = await fetch(`${baseUrl}/api/feeds/discover?q=microsaas&limit=-1&providers=manual-curated&includeUnvalidated=true`);
const invalidLimitBody = await invalidLimitResponse.json() as { results: Array<{ feedUrl: string; provider: string }> };
const rssResponse = await fetch(`${baseUrl}/rss/discover/microsaas.xml?providers=manual-curated&includeUnvalidated=true`);
const rssBody = await rssResponse.text();
const opmlResponse = await fetch(`${baseUrl}/opml/discover/microsaas.xml?providers=manual-curated&includeUnvalidated=true`);
const opmlBody = await opmlResponse.text();
expect(providersResponse.status).toBe(200);
expect(providersBody.providers.map((provider) => provider.id)).toContain("manual-curated");
expect(discoverResponse.status).toBe(200);
expect(discoverBody.query).toBe("microsaas");
expect(discoverBody.results[0]).toMatchObject({ provider: "manual-curated" });
expect(invalidLimitResponse.status).toBe(200);
expect(invalidLimitBody.results[0]).toMatchObject({ provider: "manual-curated" });
expect(rssResponse.status).toBe(200);
expect(rssBody).toContain("<rss");
expect(opmlResponse.status).toBe(200);
expect(opmlBody).toContain("<opml");
});
it("rejects malformed encoded feed discovery paths as client errors", async () => {
const response = await fetch(`${baseUrl}/rss/discover/%E0%A4%A.xml`);
const body = await response.json() as { error: string };
expect(response.status).toBe(400);
expect(body).toEqual({ error: "Invalid path encoding" });
});
it("exposes sh1pt project and action contracts", async () => {
const projectsResponse = await fetch(`${baseUrl}/api/plugins/sh1pt/projects`);
const projectsBody = await projectsResponse.json() as { projects: Array<{ id: string; board: string; status: string; actions: number }> };
const actionsResponse = await fetch(`${baseUrl}/api/plugins/sh1pt/actions`);
const actionsBody = await actionsResponse.json() as { actions: Array<{ id: string; title: string; publishable: boolean }> };
expect(projectsResponse.status).toBe(200);
expect(projectsBody.projects[0]).toMatchObject({ id: "sh1pt_project_1", board: "/projects/sh1pt", status: "active" });
expect(actionsResponse.status).toBe(200);
expect(actionsBody.actions[0]).toMatchObject({ id: "action_release_checklist", publishable: true });
});
it("accepts sh1pt action publish requests", async () => {
const response = await fetch(`${baseUrl}/api/plugins/sh1pt/actions/publish`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action_id: "action_release_checklist" })
});
const body = await response.json() as { accepted: boolean; action_id: string; board: string };
expect(response.status).toBe(202);
expect(body).toEqual({
accepted: true,
action_id: "action_release_checklist",
board: "/projects/sh1pt"
});
});
it("exposes work-in-progress c0mpute jobs and worker contracts", async () => {
const jobsResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/jobs`);
const jobsBody = await jobsResponse.json() as { jobs: Array<{ id: string; board: string; status: string; provider: string }> };
const workersResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/workers`);
const workersBody = await workersResponse.json() as { workers: Array<{ id: string; status: string; capacity: string }> };
expect(jobsResponse.status).toBe(200);
expect(jobsBody.jobs[0]).toMatchObject({ id: "compute_job_1", board: "/projects/c0mpute", status: "draft", provider: "c0mpute.com" });
expect(workersResponse.status).toBe(200);
expect(workersBody.workers[0]).toMatchObject({ id: "worker_pool_1", status: "preview", capacity: "wip" });
});
it("accepts work-in-progress c0mpute dispatch and quote requests", async () => {
const dispatchResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/jobs/dispatch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ job_id: "compute_job_1" })
});
const dispatchBody = await dispatchResponse.json() as { accepted: boolean; job_id: string; status: string; board: string };
const quoteResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/quotes`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workload: "agent-run-smoke-test" })
});
const quoteBody = await quoteResponse.json() as { accepted: boolean; workload: string; provider: string; status: string };
expect(dispatchResponse.status).toBe(202);
expect(dispatchBody).toEqual({
accepted: true,
job_id: "compute_job_1",
status: "queued",
board: "/projects/c0mpute"
});
expect(quoteResponse.status).toBe(202);
expect(quoteBody).toMatchObject({
accepted: true,
workload: "agent-run-smoke-test",
provider: "c0mpute.com",
status: "draft"
});
});
it("rejects invalid LogicSRC task payloads", async () => {
const response = await fetch(`${baseUrl}/api/tasks`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "logicsrc.task", title: "Incomplete" })
});
const body = await response.json() as { errors: unknown[] };
expect(response.status).toBe(422);
expect(Array.isArray(body.errors)).toBe(true);
});
it("rejects malformed JSON request bodies as client errors", async () => {
const response = await fetch(`${baseUrl}/api/tasks`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{bad"
});
const body = await response.json() as { error: string };
expect(response.status).toBe(400);
expect(body).toEqual({ error: "Invalid JSON body" });
});
});