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"); // The whole point of this error is that a caller can self-correct from it — // assert it actually lists a real column name, not just echoes the bad input. const columns = await admin.collection("columns").getFullList({ filter: `project = "${projectId}"`, }); for (const column of columns) { expect(body.error).toContain(column.name); } }); 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); }); }); describe("authentication refuses all six routes", () => { let authCardId: string; beforeAll(async () => { const card = await admin.collection("cards").create({ project: projectId, column: inboxColumnId, title: `Agent API auth-refusal probe ${Date.now()}`, description: "", position: 999997, priority: "medium", created_by_role: "owner", accepted: false, archived: false, }); authCardId = card.id; }); afterAll(async () => { if (authCardId) await admin.collection("cards").delete(authCardId).catch(() => {}); }); it("returns 401 for a missing or wrong token on every route, and writes nothing", async () => { const backlog = await admin.collection("columns").getOne(backlogColumnId); type RouteCase = { method: string; path: string; body?: unknown; assertNoWrite?: () => Promise; }; const routes: RouteCase[] = [ { method: "GET", path: "/api/agent/projects" }, { method: "GET", path: `/api/agent/projects/${projectId}/cards` }, { method: "GET", path: `/api/agent/cards/${authCardId}` }, { method: "POST", path: `/api/agent/cards/${authCardId}/comments`, body: { body: "should never be created" }, assertNoWrite: async () => { const comments = await admin.collection("comments").getFullList({ filter: `card = "${authCardId}"`, }); expect(comments).toHaveLength(0); }, }, { method: "POST", path: `/api/agent/cards/${authCardId}/subtasks`, body: { titles: ["should never be created"] }, assertNoWrite: async () => { const subtasks = await admin.collection("subtasks").getFullList({ filter: `card = "${authCardId}"`, }); expect(subtasks).toHaveLength(0); }, }, { method: "PATCH", path: `/api/agent/cards/${authCardId}/move`, body: { column: backlog.name }, assertNoWrite: async () => { const card = await admin.collection("cards").getOne(authCardId); expect(card.column).toBe(inboxColumnId); }, }, ]; for (const route of routes) { const baseHeaders: HeadersInit | undefined = route.body ? { "content-type": "application/json" } : undefined; const requestBody = route.body ? JSON.stringify(route.body) : undefined; const noToken = await fetch(`${APP_URL}${route.path}`, { method: route.method, headers: baseHeaders, body: requestBody, }); expect(noToken.status, `${route.method} ${route.path} (no token)`).toBe(401); const wrongToken = await fetch(`${APP_URL}${route.path}`, { method: route.method, headers: { ...(baseHeaders ?? {}), authorization: "Bearer not-the-real-token" }, body: requestBody, }); expect(wrongToken.status, `${route.method} ${route.path} (wrong token)`).toBe(401); if (route.assertNoWrite) await route.assertNoWrite(); } }); }); describe("PATCH /api/agent/cards/:cardId/move column-name case-insensitivity", () => { let cardId: string; beforeAll(async () => { const card = await admin.collection("cards").create({ project: projectId, column: inboxColumnId, title: `Agent API case-insensitivity probe ${Date.now()}`, description: "", position: 999996, priority: "medium", created_by_role: "owner", accepted: false, archived: false, }); cardId = card.id; }); afterAll(async () => { if (cardId) await admin.collection("cards").delete(cardId).catch(() => {}); }); it("resolves the destination column regardless of case", async () => { const backlog = await admin.collection("columns").getOne(backlogColumnId); const differentCase = backlog.name === backlog.name.toUpperCase() ? backlog.name.toLowerCase() : backlog.name.toUpperCase(); expect(differentCase).not.toBe(backlog.name); const response = await authed(`/api/agent/cards/${cardId}/move`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ column: differentCase }), }); expect(response.status).toBe(200); const updated = await admin.collection("cards").getOne(cardId); expect(updated.column).toBe(backlogColumnId); }); }); describe("PATCH /api/agent/cards/:cardId/move does not double-log acceptance", () => { let cardId: string; let secondWorkingColumnId: string; beforeAll(async () => { const secondWorking = await admin .collection("columns") .getFirstListItem( `project = "${projectId}" && is_inbox = false && is_done = false && id != "${backlogColumnId}"` ); secondWorkingColumnId = secondWorking.id; const card = await admin.collection("cards").create({ project: projectId, column: inboxColumnId, title: `Agent API double-accept probe ${Date.now()}`, description: "", position: 999995, priority: "medium", created_by_role: "owner", accepted: false, archived: false, }); cardId = card.id; }); afterAll(async () => { if (cardId) await admin.collection("cards").delete(cardId).catch(() => {}); }); it("does not add a second card_accepted row when an already-accepted card moves again", async () => { const backlog = await admin.collection("columns").getOne(backlogColumnId); const secondWorking = await admin.collection("columns").getOne(secondWorkingColumnId); const firstMove = await authed(`/api/agent/cards/${cardId}/move`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ column: backlog.name }), }); expect(firstMove.status).toBe(200); const firstBody = await firstMove.json(); expect(firstBody.accepted).toBe(true); const activityAfterFirst = await admin.collection("activity").getFullList({ filter: `card = "${cardId}" && action = "card_accepted"`, }); expect(activityAfterFirst.length).toBe(1); const secondMove = await authed(`/api/agent/cards/${cardId}/move`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ column: secondWorking.name }), }); expect(secondMove.status).toBe(200); const activityAfterSecond = await admin.collection("activity").getFullList({ filter: `card = "${cardId}" && action = "card_accepted"`, }); expect(activityAfterSecond.length).toBe(activityAfterFirst.length); }); }); describe("PATCH /api/agent/cards/:cardId/move into Inbox never accepts", () => { let cardId: string; beforeAll(async () => { const card = await admin.collection("cards").create({ project: projectId, column: backlogColumnId, title: `Agent API inbox-move probe ${Date.now()}`, description: "", position: 999994, priority: "medium", created_by_role: "owner", accepted: false, archived: false, }); cardId = card.id; }); afterAll(async () => { if (cardId) await admin.collection("cards").delete(cardId).catch(() => {}); }); it("never sets accepted true when moving a card into the Inbox", async () => { const inbox = await admin.collection("columns").getOne(inboxColumnId); const response = await authed(`/api/agent/cards/${cardId}/move`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ column: inbox.name }), }); expect(response.status).toBe(200); const body = await response.json(); expect(body.accepted).toBe(false); const updated = await admin.collection("cards").getOne(cardId); expect(updated.column).toBe(inboxColumnId); expect(updated.accepted).toBe(false); }); }); describe("404s for a nonexistent card id", () => { const bogusId = "does-not-exist-xyz"; it("GET card detail 404s", async () => { const response = await authed(`/api/agent/cards/${bogusId}`); expect(response.status).toBe(404); }); it("POST comments 404s", async () => { const response = await authed(`/api/agent/cards/${bogusId}/comments`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ body: "test" }), }); expect(response.status).toBe(404); }); it("POST subtasks 404s", async () => { const response = await authed(`/api/agent/cards/${bogusId}/subtasks`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ titles: ["test"] }), }); expect(response.status).toBe(404); }); it("PATCH move 404s", async () => { const response = await authed(`/api/agent/cards/${bogusId}/move`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ column: "Backlog" }), }); expect(response.status).toBe(404); }); });