Files
kandev/apps/web/lib/api/domains/kanban-api.test.ts
T
2026-09-16 11:16:47 +01:00

415 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
attachTaskWorkspaceSources,
detachTask,
deleteTask,
getTaskDeletePreflight,
listTasksByWorkspace,
moveTask,
previewWorkflowMove,
reorderStepTasks,
updateTask,
updateTaskPortForwarding,
} from "./kanban-api";
import { ApiError } from "../client";
const fetchSpy = vi.fn<typeof fetch>();
const API_BASE_URL = "http://api.test";
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => vi.unstubAllGlobals());
describe("deleteTask", () => {
it("sends cascade and discard consent independently", async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
await deleteTask(
"task-1",
{ cascade: true, discardWorktreeChanges: true },
{ baseUrl: API_BASE_URL },
);
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(
`${API_BASE_URL}/api/v1/tasks/task-1?cascade=true&discard_worktree_changes=true`,
);
expect(init?.method).toBe("DELETE");
});
});
describe("getTaskDeletePreflight", () => {
it("posts the exact scope and bypasses caches", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ requires_discard_consent: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
getTaskDeletePreflight(["task-1", "task-2"], true, { baseUrl: API_BASE_URL }),
).resolves.toEqual({ requires_discard_consent: true });
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/delete-preflight`);
expect(init).toMatchObject({
method: "POST",
body: JSON.stringify({ task_ids: ["task-1", "task-2"], cascade: true }),
cache: "no-store",
});
});
});
describe("detachTask", () => {
it("posts without a body to the canonical detach endpoint", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "child-1", parent_id: "" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await detachTask("child-1", { baseUrl: API_BASE_URL });
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/child-1/detach`);
expect(init?.method).toBe("POST");
expect(init?.body).toBeUndefined();
});
});
describe("updateTask", () => {
it("carries priority as a partial-update field", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "task-1", priority: "critical" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await updateTask("task-1", { priority: "critical" }, { baseUrl: API_BASE_URL });
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/task-1`);
expect(init).toMatchObject({
method: "PATCH",
body: JSON.stringify({ priority: "critical" }),
});
});
});
describe("reorderStepTasks", () => {
it("PUTs the band and ordered ids to the hyphenated collection route", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
workflow_step_id: "step-1",
revision: 1,
tasks: [
{ id: "task-b", position: 0 },
{ id: "task-a", position: 1 },
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
const result = await reorderStepTasks(
"step-1",
{ band: "admitted", ordered_task_ids: ["task-b", "task-a"] },
{ baseUrl: API_BASE_URL },
);
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/workflow-steps/step-1/tasks/reorder`);
expect(init?.method).toBe("PUT");
expect(init?.body).toBe(
JSON.stringify({ band: "admitted", ordered_task_ids: ["task-b", "task-a"] }),
);
expect(result.revision).toBe(1);
expect(result.tasks).toEqual([
{ id: "task-b", position: 0 },
{ id: "task-a", position: 1 },
]);
});
it("surfaces a step_changed conflict as an ApiError carrying the authoritative order", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: "step_changed",
workflow_step_id: "step-1",
revision: 3,
tasks: [{ id: "task-a", position: 0 }],
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
);
await expect(
reorderStepTasks(
"step-1",
{ band: "admitted", ordered_task_ids: ["task-a"] },
{ baseUrl: API_BASE_URL },
),
).rejects.toMatchObject({
status: 409,
body: { code: "step_changed", revision: 3 },
});
});
it("surfaces an invalid_reorder rejection with no task list", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ code: "invalid_reorder" }), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
);
let caught: unknown;
try {
await reorderStepTasks(
"step-1",
{ band: "admitted", ordered_task_ids: [] },
{ baseUrl: API_BASE_URL },
);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(400);
expect((caught as ApiError).body).toEqual({ code: "invalid_reorder" });
});
});
describe("updateTaskPortForwarding", () => {
it("patches only the task-scoped preference endpoint", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "task-1", metadata: { port_forwarding_enabled: true } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
updateTaskPortForwarding("task-1", true, { baseUrl: API_BASE_URL }),
).resolves.toMatchObject({
id: "task-1",
metadata: { port_forwarding_enabled: true },
});
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/task-1/port-forwarding`);
expect(init).toMatchObject({
method: "PATCH",
body: JSON.stringify({ enabled: true }),
});
expect(new Headers(init?.headers).get("Content-Type")).toBe("application/json");
});
});
describe("attachTaskWorkspaceSources", () => {
it("posts the exact mixed-source payload and returns the persisted projection", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
task_id: "task-1",
repositories: [],
workspace_folders: [
{ id: "folder-1", local_path: "/docs", display_name: "docs", position: 0 },
],
workspace_path: "/workspace/task-1",
session_ids: ["session-1"],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
await expect(
attachTaskWorkspaceSources(
"task-1",
{ sources: [{ kind: "folder", local_path: "/docs", display_name: "docs" }] },
{ baseUrl: API_BASE_URL },
),
).resolves.toMatchObject({ task_id: "task-1", workspace_path: "/workspace/task-1" });
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/task-1/workspace-sources`);
expect(init).toMatchObject({
method: "POST",
body: JSON.stringify({
sources: [{ kind: "folder", local_path: "/docs", display_name: "docs" }],
}),
});
expect(new Headers(init?.headers).get("Content-Type")).toBe("application/json");
});
it("preserves normalized API errors for retry UI", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "task has an active turn" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
attachTaskWorkspaceSources("task-1", { sources: [] }, { baseUrl: API_BASE_URL }),
).rejects.toMatchObject({ name: "ApiError", status: 409, message: "task has an active turn" });
});
});
describe("listTasksByWorkspace", () => {
it("requests the archived-only mode without changing the existing list contract", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ tasks: [], total: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await listTasksByWorkspace(
"ws-1",
{ page: 2, pageSize: 100, onlyArchived: true, sort: "updated_desc" },
{ baseUrl: API_BASE_URL },
);
expect(fetchSpy).toHaveBeenCalledOnce();
expect(fetchSpy.mock.calls[0][0]).toBe(
`${API_BASE_URL}/api/v1/workspaces/ws-1/tasks?page=2&page_size=100&only_archived=true&sort=updated_desc`,
);
});
});
describe("moveTask", () => {
const workflowId = "workflow-1";
const workflowStepId = "step-2";
it("normalizes one-shot entry options and omits blank values", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ task: { id: "task-1" }, workflow_step: { id: workflowStepId } }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await moveTask(
"task-1",
{
workflow_id: workflowId,
workflow_step_id: workflowStepId,
position: 0,
entry_options: {
reset_context: true,
instructions: " Start the verification pass. ",
skip_step_prompt: true,
},
},
{ baseUrl: API_BASE_URL },
);
const [, init] = fetchSpy.mock.calls[0];
expect(init?.body).toBe(
JSON.stringify({
workflow_id: workflowId,
workflow_step_id: workflowStepId,
position: 0,
entry_options: {
reset_context: true,
skip_step_prompt: true,
instructions: "Start the verification pass.",
},
}),
);
});
it("keeps destination-only move payloads unchanged", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ task: { id: "task-1" }, workflow_step: { id: workflowStepId } }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await moveTask(
"task-1",
{ workflow_id: workflowId, workflow_step_id: workflowStepId, position: 0 },
{ baseUrl: API_BASE_URL },
);
const [, init] = fetchSpy.mock.calls[0];
expect(init?.body).toBe(
JSON.stringify({ workflow_id: workflowId, workflow_step_id: workflowStepId, position: 0 }),
);
});
});
describe("previewWorkflowMove", () => {
it("posts normalized options with a no-store cache policy", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
task_id: "task-1",
workflow_step_id: "step-2",
evaluated_at: "2026-09-14T00:00:00Z",
outcome: "create_new",
model: {
before: { known: false },
after: { known: true, label: "gpt-5.6-luna" },
},
context_reset: false,
context_reset_state: "unchanged",
source_disposition: "park",
dispatch: "prompt",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
await previewWorkflowMove(
"task-1",
{
workflow_id: "workflow-1",
workflow_step_id: "step-2",
entry_options: {
instructions: " inspect the destination ",
reset_context: true,
},
},
{ baseUrl: API_BASE_URL },
);
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/task-1/move-preview`);
expect(init).toMatchObject({
method: "POST",
body: JSON.stringify({
workflow_id: "workflow-1",
workflow_step_id: "step-2",
entry_options: {
reset_context: true,
instructions: "inspect the destination",
},
}),
});
expect((init as RequestInit).cache).toBe("no-store");
});
});