no-double-accept, Inbox-never-accepts, and 404s Adds the whole-branch-review test gaps: a single parametrised test looping over all 6 (method, path, body) combinations asserting 401 for missing and wrong tokens plus no-write-occurred for the 3 write routes; a move-route case-insensitivity assertion (real column name uppercased); a negative test that moving an already-accepted card again does not re-log card_accepted; a negative test that moving a card into Inbox never sets accepted: true; and 404 checks for a nonexistent card id across GET/POST/POST/PATCH. Each new describe block creates and cleans up its own dedicated fixture card(s), since the existing cardId fixtures from earlier describes in this file are already deleted by their own afterAll by the time these run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
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<void>;
|
|
};
|
|
|
|
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);
|
|
});
|
|
});
|