From 05d53e9bb848abcb0debbe792e3ab6e7c67c1f86 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 26 Sep 2026 10:00:14 +0200 Subject: [PATCH] test(agent-api): auth, all five routes, and the accept-on-move transition Co-Authored-By: Claude Sonnet 5 --- tests/integration/agent-api.test.ts | 229 ++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/integration/agent-api.test.ts diff --git a/tests/integration/agent-api.test.ts b/tests/integration/agent-api.test.ts new file mode 100644 index 0000000..a88ad9d --- /dev/null +++ b/tests/integration/agent-api.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import PocketBase from "pocketbase"; + +import { APP_URL, DEMO, adminPb, requireServices } from "./helpers"; + +const TOKEN = process.env.AGENT_API_TOKEN; + +let admin: PocketBase; +let projectId: string; +let inboxColumnId: string; +let backlogColumnId: string; + +function authed(path: string, init: RequestInit = {}) { + return fetch(`${APP_URL}${path}`, { + ...init, + headers: { ...(init.headers ?? {}), authorization: `Bearer ${TOKEN}` }, + }); +} + +beforeAll(async () => { + await requireServices(); + if (!TOKEN) { + throw new Error( + "AGENT_API_TOKEN must be set in the environment running this test (see .env.local)." + ); + } + + admin = await adminPb(); + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + + const inbox = await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = true`); + inboxColumnId = inbox.id; + + const backlog = await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = false && is_done = false`); + backlogColumnId = backlog.id; +}); + +describe("authentication", () => { + it("refuses a request with no token", async () => { + const response = await fetch(`${APP_URL}/api/agent/projects`); + expect(response.status).toBe(401); + }); + + it("refuses a request with the wrong token", async () => { + const response = await fetch(`${APP_URL}/api/agent/projects`, { + headers: { authorization: "Bearer not-the-real-token" }, + }); + expect(response.status).toBe(401); + }); + + it("accepts a request with the right token", async () => { + const response = await authed("/api/agent/projects"); + expect(response.status).toBe(200); + }); +}); + +describe("GET /api/agent/projects", () => { + it("includes the demo project with its client", async () => { + const response = await authed("/api/agent/projects"); + const projects = await response.json(); + const found = projects.find((p: { id: string }) => p.id === projectId); + expect(found).toBeTruthy(); + expect(found.client.slug).toBe(DEMO.clientSlug); + }); +}); + +describe("GET /api/agent/projects/:projectId/cards", () => { + let cardId: string; + + afterAll(async () => { + if (cardId) await admin.collection("cards").delete(cardId).catch(() => {}); + }); + + it("lists a newly created Inbox card, and the ?column=inbox filter excludes non-Inbox cards", async () => { + const stamp = Date.now(); + const card = await admin.collection("cards").create({ + project: projectId, + column: inboxColumnId, + title: `Agent API probe ${stamp}`, + description: "", + position: 999999, + priority: "medium", + created_by_role: "owner", + accepted: false, + archived: false, + }); + cardId = card.id; + + const all = await authed(`/api/agent/projects/${projectId}/cards`).then((r) => r.json()); + expect(all.some((c: { id: string }) => c.id === cardId)).toBe(true); + + const inboxOnly = await authed(`/api/agent/projects/${projectId}/cards?column=inbox`).then( + (r) => r.json() + ); + expect(inboxOnly.every((c: { column: { is_inbox: boolean } }) => c.column.is_inbox)).toBe( + true + ); + expect(inboxOnly.some((c: { id: string }) => c.id === cardId)).toBe(true); + }); + + it("404s for an unknown project", async () => { + const response = await authed("/api/agent/projects/does-not-exist/cards"); + expect(response.status).toBe(404); + }); +}); + +describe("card detail, comments, subtasks, and move", () => { + let cardId: string; + + beforeAll(async () => { + const card = await admin.collection("cards").create({ + project: projectId, + column: inboxColumnId, + title: `Agent API detail probe ${Date.now()}`, + description: "A card for the agent API's own integration tests.", + position: 999998, + priority: "medium", + created_by_role: "owner", + accepted: false, + archived: false, + }); + cardId = card.id; + }); + + afterAll(async () => { + await admin.collection("cards").delete(cardId).catch(() => {}); + }); + + it("GET returns the card with empty subtasks/comments", async () => { + const response = await authed(`/api/agent/cards/${cardId}`); + const detail = await response.json(); + expect(detail.id).toBe(cardId); + expect(detail.subtasks).toEqual([]); + expect(detail.comments).toEqual([]); + }); + + it("POST comments prefixes the body and attributes it to the owner", async () => { + const response = await authed(`/api/agent/cards/${cardId}/comments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body: "Here is the plan." }), + }); + expect(response.status).toBe(201); + + const stored = await admin.collection("comments").getFullList({ + filter: `card = "${cardId}"`, + }); + expect(stored).toHaveLength(1); + expect(stored[0].body.startsWith("🤖 Drafted by your agent:")).toBe(true); + expect(stored[0].body).toContain("Here is the plan."); + + const ownerUser = await admin.collection("users").getFirstListItem('role = "owner"'); + expect(stored[0].author).toBe(ownerUser.id); + }); + + it("rejects an empty comment body", async () => { + const response = await authed(`/api/agent/cards/${cardId}/comments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body: " " }), + }); + expect(response.status).toBe(400); + }); + + it("POST subtasks creates them in order after any existing ones", async () => { + const response = await authed(`/api/agent/cards/${cardId}/subtasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ titles: ["Step one", "Step two", "Step three"] }), + }); + expect(response.status).toBe(201); + + const stored = await admin.collection("subtasks").getFullList({ + filter: `card = "${cardId}"`, + sort: "position", + }); + expect(stored.map((s) => s.title)).toEqual(["Step one", "Step two", "Step three"]); + }); + + it("rejects a subtasks request with no usable titles", async () => { + const response = await authed(`/api/agent/cards/${cardId}/subtasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ titles: [" ", ""] }), + }); + expect(response.status).toBe(400); + }); + + it("PATCH move rejects an unknown column name and lists the real ones", async () => { + const response = await authed(`/api/agent/cards/${cardId}/move`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ column: "Not A Real Column" }), + }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Not A Real Column"); + }); + + it("PATCH move to a working column accepts the card and logs it", async () => { + const backlog = await admin.collection("columns").getOne(backlogColumnId); + + const response = await authed(`/api/agent/cards/${cardId}/move`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ column: backlog.name }), + }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.accepted).toBe(true); + + const updated = await admin.collection("cards").getOne(cardId); + expect(updated.column).toBe(backlogColumnId); + expect(updated.accepted).toBe(true); + expect(updated.start_date).not.toBe(""); + + const activity = await admin.collection("activity").getFullList({ + filter: `card = "${cardId}" && action = "card_accepted"`, + }); + expect(activity.length).toBeGreaterThan(0); + }); +});