- Expand fn agent import to accept directories, .tar.gz/.tgz/.zip archives, single .md manifests, and legacy .sh manifests - Update /api/agents/import to handle agents array, source path, or manifest string inputs with dry-run previews and legacy fallback parsing - Refresh AgentImportModal and dashboard API typing for new preview metadata, directory uploads, and improved error handling - Add coverage for CLI import paths, dashboard import routes, modal behavior, and assignment route timeout stability - Add a minor @gsxdsm/fusion changeset documenting Agent Companies import support
8622 lines
287 KiB
TypeScript
8622 lines
287 KiB
TypeScript
// @vitest-environment node
|
||
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
|
||
import express from "express";
|
||
import http from "node:http";
|
||
import { EventEmitter } from "node:events";
|
||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { execFileSync } from "node:child_process";
|
||
import { createApiRoutes } from "./routes.js";
|
||
import { GitHubClient } from "./github.js";
|
||
import { githubRateLimiter } from "./github-poll.js";
|
||
import type { TaskStore, TaskAttachment } from "@fusion/core";
|
||
import type { TaskDetail } from "@fusion/core";
|
||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||
import { __resetBatchImportRateLimiter } from "./routes.js";
|
||
import { __resetPlanningState, __setCreateKbAgent, planningStreamManager } from "./planning.js";
|
||
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "./subtask-breakdown.js";
|
||
import * as terminalServiceModule from "./terminal-service.js";
|
||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||
|
||
// Mock @fusion/core for gh CLI auth checks
|
||
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
|
||
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
|
||
vi.mock("@fusion/core", async () => {
|
||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||
return {
|
||
...actual,
|
||
isGhAuthenticated: vi.fn(),
|
||
CentralCore: vi.fn().mockImplementation(() => ({
|
||
init: mockCentralInit,
|
||
close: mockCentralClose,
|
||
listProjects: mockCentralListProjects,
|
||
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
|
||
})),
|
||
};
|
||
});
|
||
|
||
import { isGhAuthenticated } from "@fusion/core";
|
||
|
||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||
|
||
function createMockGlobalSettingsStore() {
|
||
return {
|
||
getSettings: vi.fn().mockResolvedValue({}),
|
||
updateSettings: vi.fn().mockResolvedValue({}),
|
||
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/fusion/settings.json"),
|
||
init: vi.fn().mockResolvedValue(false),
|
||
};
|
||
}
|
||
|
||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||
return {
|
||
getTask: vi.fn(),
|
||
listTasks: vi.fn().mockResolvedValue([]),
|
||
createTask: vi.fn(),
|
||
moveTask: vi.fn(),
|
||
updateTask: vi.fn(),
|
||
deleteTask: vi.fn(),
|
||
mergeTask: vi.fn(),
|
||
archiveTask: vi.fn(),
|
||
unarchiveTask: vi.fn(),
|
||
getSettings: vi.fn().mockResolvedValue({}),
|
||
updateSettings: vi.fn(),
|
||
updateGlobalSettings: vi.fn(),
|
||
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||
addSteeringComment: vi.fn(),
|
||
addTaskComment: vi.fn(),
|
||
updateTaskComment: vi.fn(),
|
||
deleteTaskComment: vi.fn(),
|
||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||
createWorkflowStep: vi.fn(),
|
||
getWorkflowStep: vi.fn(),
|
||
updateWorkflowStep: vi.fn(),
|
||
deleteWorkflowStep: vi.fn(),
|
||
getMissionStore: vi.fn().mockReturnValue({
|
||
listMissions: vi.fn().mockReturnValue([]),
|
||
createMission: vi.fn(),
|
||
getMissionWithHierarchy: vi.fn(),
|
||
updateMission: vi.fn(),
|
||
getMission: vi.fn(),
|
||
deleteMission: vi.fn(),
|
||
listMilestonesByMission: vi.fn().mockReturnValue([]),
|
||
createMilestone: vi.fn(),
|
||
updateMilestone: vi.fn(),
|
||
getMilestone: vi.fn(),
|
||
deleteMilestone: vi.fn(),
|
||
listTasksByMilestone: vi.fn().mockReturnValue([]),
|
||
createMissionTask: vi.fn(),
|
||
updateMissionTask: vi.fn(),
|
||
getMissionTask: vi.fn(),
|
||
deleteMissionTask: vi.fn(),
|
||
}),
|
||
...overrides,
|
||
} as unknown as TaskStore;
|
||
}
|
||
|
||
const FAKE_TASK_DETAIL: TaskDetail = {
|
||
id: "FN-001",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [],
|
||
currentStep: 0,
|
||
log: [],
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
prompt: "# KB-001\n\nTest task",
|
||
};
|
||
|
||
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
|
||
const res = await performGet(app, path);
|
||
return { status: res.status, body: res.body };
|
||
}
|
||
|
||
async function REQUEST(
|
||
app: express.Express,
|
||
method: string,
|
||
path: string,
|
||
body?: Buffer | string,
|
||
headers?: Record<string, string>,
|
||
): Promise<{ status: number; body: any }> {
|
||
const res = await performRequest(app, method, path, body, headers);
|
||
return { status: res.status, body: res.body };
|
||
}
|
||
|
||
/** Build a minimal multipart/form-data body */
|
||
function buildMultipart(fieldName: string, filename: string, contentType: string, content: Buffer): { body: Buffer; boundary: string } {
|
||
const boundary = "----TestBoundary" + Date.now();
|
||
const header = `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`;
|
||
const footer = `\r\n--${boundary}--\r\n`;
|
||
const body = Buffer.concat([Buffer.from(header), content, Buffer.from(footer)]);
|
||
return { body, boundary };
|
||
}
|
||
|
||
describe("GET /tasks", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns tasks with optional pagination params", async () => {
|
||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks?limit=10&offset=5");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(1);
|
||
expect(store.listTasks).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
||
});
|
||
|
||
it("returns 400 for invalid pagination params", async () => {
|
||
const res = await GET(buildApp(), "/api/tasks?limit=-1");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("limit");
|
||
});
|
||
});
|
||
|
||
describe("GET /projects", () => {
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(createMockStore()));
|
||
return app;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
mockCentralListProjects.mockReset().mockResolvedValue([]);
|
||
mockCentralInit.mockReset().mockResolvedValue(undefined);
|
||
mockCentralClose.mockReset().mockResolvedValue(undefined);
|
||
});
|
||
|
||
it("prioritizes the project for the current working directory", async () => {
|
||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/workspace/current-project");
|
||
mockCentralListProjects.mockResolvedValueOnce([
|
||
{
|
||
id: "proj_other",
|
||
name: "Other Project",
|
||
path: "/workspace/other-project",
|
||
status: "active",
|
||
isolationMode: "in-process",
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
},
|
||
{
|
||
id: "proj_current",
|
||
name: "Current Project",
|
||
path: "/workspace/current-project",
|
||
status: "active",
|
||
isolationMode: "in-process",
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
},
|
||
]);
|
||
|
||
const res = await GET(buildApp(), "/api/projects");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
expect((res.body as Array<{ id: string }>).map((project) => project.id)).toEqual([
|
||
"proj_current",
|
||
"proj_other",
|
||
]);
|
||
cwdSpy.mockRestore();
|
||
});
|
||
|
||
it("prefers the deepest matching ancestor when cwd is nested inside a project", async () => {
|
||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/workspace/current-project/packages/dashboard");
|
||
mockCentralListProjects.mockResolvedValueOnce([
|
||
{
|
||
id: "proj_parent",
|
||
name: "Parent",
|
||
path: "/workspace",
|
||
status: "active",
|
||
isolationMode: "in-process",
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
},
|
||
{
|
||
id: "proj_current",
|
||
name: "Current Project",
|
||
path: "/workspace/current-project",
|
||
status: "active",
|
||
isolationMode: "in-process",
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
},
|
||
{
|
||
id: "proj_other",
|
||
name: "Other Project",
|
||
path: "/workspace/other-project",
|
||
status: "active",
|
||
isolationMode: "in-process",
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
},
|
||
]);
|
||
|
||
const res = await GET(buildApp(), "/api/projects");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect((res.body as Array<{ id: string }>).map((project) => project.id)).toEqual([
|
||
"proj_current",
|
||
"proj_parent",
|
||
"proj_other",
|
||
]);
|
||
cwdSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
describe("GET /tasks/:id", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns task detail on success", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.id).toBe("FN-001");
|
||
expect(res.body.prompt).toBe("# KB-001\n\nTest task");
|
||
});
|
||
|
||
it("returns 404 when task genuinely does not exist (ENOENT)", async () => {
|
||
const err: NodeJS.ErrnoException = new Error("ENOENT: no such file or directory");
|
||
err.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-999");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("returns 500 on transient/unexpected errors (non-ENOENT)", async () => {
|
||
const err = new Error("Unexpected end of JSON input");
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Unexpected end of JSON input");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("creates a task and forwards breakIntoSubtasks", async () => {
|
||
const createdTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
column: "triage",
|
||
breakIntoSubtasks: true,
|
||
};
|
||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({
|
||
description: "Big initiative",
|
||
breakIntoSubtasks: true,
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createTask).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
title: undefined,
|
||
description: "Big initiative",
|
||
column: undefined,
|
||
dependencies: undefined,
|
||
breakIntoSubtasks: true,
|
||
summarize: false,
|
||
}),
|
||
expect.objectContaining({
|
||
settings: { autoSummarizeTitles: undefined },
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("forwards model overrides when both provider and id are supplied", async () => {
|
||
const createdTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
column: "triage",
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
};
|
||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({
|
||
description: "Use explicit models",
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createTask).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
title: undefined,
|
||
description: "Use explicit models",
|
||
column: undefined,
|
||
dependencies: undefined,
|
||
breakIntoSubtasks: undefined,
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
summarize: false,
|
||
}),
|
||
expect.objectContaining({
|
||
settings: { autoSummarizeTitles: undefined },
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("normalizes partial model overrides back to defaults", async () => {
|
||
const createdTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
column: "triage",
|
||
};
|
||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({
|
||
description: "Ignore partial model selection",
|
||
modelProvider: "anthropic",
|
||
validatorModelId: "gpt-4o",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createTask).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
title: undefined,
|
||
description: "Ignore partial model selection",
|
||
column: undefined,
|
||
dependencies: undefined,
|
||
breakIntoSubtasks: undefined,
|
||
modelProvider: undefined,
|
||
modelId: undefined,
|
||
validatorModelProvider: undefined,
|
||
validatorModelId: undefined,
|
||
summarize: false,
|
||
}),
|
||
expect.objectContaining({
|
||
settings: { autoSummarizeTitles: undefined },
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("returns 400 when model fields are not strings", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({
|
||
description: "Invalid model payload",
|
||
modelProvider: ["anthropic"],
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("modelProvider must be a string");
|
||
expect(store.createTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when description is missing", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({ breakIntoSubtasks: true }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("description is required");
|
||
expect(store.createTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when breakIntoSubtasks is not a boolean", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({ description: "Big initiative", breakIntoSubtasks: "yes" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("breakIntoSubtasks must be a boolean");
|
||
expect(store.createTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("forwards thinkingLevel when provided", async () => {
|
||
const createdTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
column: "triage",
|
||
thinkingLevel: "high",
|
||
};
|
||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({
|
||
description: "Deep reasoning task",
|
||
thinkingLevel: "high",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createTask).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
description: "Deep reasoning task",
|
||
thinkingLevel: "high",
|
||
}),
|
||
expect.objectContaining({
|
||
settings: { autoSummarizeTitles: undefined },
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("returns 400 for invalid thinkingLevel value", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({
|
||
description: "Bad thinking level",
|
||
thinkingLevel: "ultra",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("thinkingLevel must be one of");
|
||
expect(store.createTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("forwards planningModelProvider and planningModelId when provided", async () => {
|
||
const createdTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
column: "triage",
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
};
|
||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks",
|
||
JSON.stringify({
|
||
description: "Use planning model",
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createTask).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
description: "Use planning model",
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
}),
|
||
expect.objectContaining({
|
||
settings: { autoSummarizeTitles: undefined },
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("POST /subtasks/*", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
__resetPlanningState();
|
||
__resetSubtaskBreakdownState();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("starts a subtask streaming session and returns sessionId", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(typeof res.body.sessionId).toBe("string");
|
||
});
|
||
|
||
it("replays buffered subtask events using lastEventId query param", async () => {
|
||
const start = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "Replay buffered subtask stream" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const sessionId = start.body.sessionId as string;
|
||
|
||
// Reset any initial stream manager state from background generation.
|
||
subtaskStreamManager.cleanupSession(sessionId);
|
||
|
||
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
|
||
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
|
||
|
||
setTimeout(() => {
|
||
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
|
||
}, 0);
|
||
|
||
const streamRes = await REQUEST(
|
||
buildApp(),
|
||
"GET",
|
||
`/api/subtasks/${sessionId}/stream?lastEventId=1`,
|
||
);
|
||
|
||
expect(streamRes.status).toBe(200);
|
||
expect(streamRes.body).toContain("id: 2");
|
||
expect(streamRes.body).toContain("event: thinking");
|
||
expect(streamRes.body).toContain("event: complete");
|
||
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
|
||
});
|
||
|
||
it("replays buffered subtask events using Last-Event-ID header", async () => {
|
||
const start = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "Replay buffered subtask stream from header" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const sessionId = start.body.sessionId as string;
|
||
|
||
subtaskStreamManager.cleanupSession(sessionId);
|
||
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
|
||
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
|
||
|
||
setTimeout(() => {
|
||
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
|
||
}, 0);
|
||
|
||
const streamRes = await REQUEST(
|
||
buildApp(),
|
||
"GET",
|
||
`/api/subtasks/${sessionId}/stream`,
|
||
undefined,
|
||
{ "Last-Event-ID": "1" },
|
||
);
|
||
|
||
expect(streamRes.status).toBe(200);
|
||
expect(streamRes.body).toContain("id: 2");
|
||
expect(streamRes.body).toContain("event: thinking");
|
||
expect(streamRes.body).toContain("event: complete");
|
||
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
|
||
});
|
||
|
||
it("skips subtask replay when Last-Event-ID is missing", async () => {
|
||
const start = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "No subtask replay without header" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const sessionId = start.body.sessionId as string;
|
||
|
||
subtaskStreamManager.cleanupSession(sessionId);
|
||
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
|
||
|
||
setTimeout(() => {
|
||
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
|
||
}, 0);
|
||
|
||
const streamRes = await REQUEST(
|
||
buildApp(),
|
||
"GET",
|
||
`/api/subtasks/${sessionId}/stream`,
|
||
);
|
||
|
||
expect(streamRes.status).toBe(200);
|
||
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
|
||
expect(streamRes.body).toContain("id: 2");
|
||
expect(streamRes.body).toContain("event: complete");
|
||
});
|
||
|
||
it("gracefully ignores invalid Last-Event-ID values for subtask streams", async () => {
|
||
const start = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "Invalid subtask last event id" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const sessionId = start.body.sessionId as string;
|
||
|
||
subtaskStreamManager.cleanupSession(sessionId);
|
||
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
|
||
|
||
setTimeout(() => {
|
||
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
|
||
}, 0);
|
||
|
||
const streamRes = await REQUEST(
|
||
buildApp(),
|
||
"GET",
|
||
`/api/subtasks/${sessionId}/stream`,
|
||
undefined,
|
||
{ "Last-Event-ID": "not-a-number" },
|
||
);
|
||
|
||
expect(streamRes.status).toBe(200);
|
||
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
|
||
expect(streamRes.body).toContain("id: 2");
|
||
expect(streamRes.body).toContain("event: complete");
|
||
});
|
||
|
||
it("creates tasks from a breakdown and resolves dependencies", async () => {
|
||
(store.createTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage" })
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage" });
|
||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage", size: "S" })
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage", size: "M" })
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage", dependencies: ["FN-101"] });
|
||
|
||
const start = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const createRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/create-tasks",
|
||
JSON.stringify({
|
||
sessionId: start.body.sessionId,
|
||
subtasks: [
|
||
{ tempId: "subtask-1", title: "First", description: "Do first", size: "S", dependsOn: [] },
|
||
{ tempId: "subtask-2", title: "Second", description: "Do second", size: "M", dependsOn: ["subtask-1"] },
|
||
],
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(createRes.status).toBe(201);
|
||
expect(createRes.body.tasks).toHaveLength(2);
|
||
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ title: "First", dependencies: undefined }));
|
||
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ title: "Second", dependencies: undefined }));
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-102", { dependencies: ["FN-101"] });
|
||
});
|
||
|
||
it("returns 404 for invalid subtask session during batch creation", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/create-tasks",
|
||
JSON.stringify({
|
||
sessionId: "missing-session",
|
||
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("inherits parent task model settings when creating subtasks", async () => {
|
||
const parentTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-100",
|
||
title: "Parent Task",
|
||
column: "triage",
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
};
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parentTask);
|
||
(store.createTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage" })
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage" });
|
||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage", size: "S" })
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage", size: "M" });
|
||
|
||
const start = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const createRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/create-tasks",
|
||
JSON.stringify({
|
||
sessionId: start.body.sessionId,
|
||
parentTaskId: "FN-100",
|
||
subtasks: [
|
||
{ tempId: "subtask-1", title: "First", description: "Do first", size: "S", dependsOn: [] },
|
||
{ tempId: "subtask-2", title: "Second", description: "Do second", size: "M", dependsOn: ["subtask-1"] },
|
||
],
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(createRes.status).toBe(201);
|
||
expect(store.getTask).toHaveBeenCalledWith("FN-100");
|
||
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||
title: "First",
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
}));
|
||
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||
title: "Second",
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
}));
|
||
});
|
||
|
||
it("handles missing parent task gracefully when creating subtasks", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Task not found"));
|
||
(store.createTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage" });
|
||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage", size: "S" });
|
||
|
||
const start = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/start-streaming",
|
||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const createRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/subtasks/create-tasks",
|
||
JSON.stringify({
|
||
sessionId: start.body.sessionId,
|
||
parentTaskId: "FN-NONEXISTENT",
|
||
subtasks: [
|
||
{ tempId: "subtask-1", title: "First", description: "Do first", size: "S", dependsOn: [] },
|
||
],
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(createRes.status).toBe(201);
|
||
expect(store.getTask).toHaveBeenCalledWith("FN-NONEXISTENT");
|
||
// Subtask created without model inheritance (undefined values)
|
||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||
title: "First",
|
||
modelProvider: undefined,
|
||
modelId: undefined,
|
||
validatorModelProvider: undefined,
|
||
validatorModelId: undefined,
|
||
}));
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/retry", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("retries a failed task and moves it to todo", async () => {
|
||
const failedTask = { ...FAKE_TASK_DETAIL, status: "failed" };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
status: null,
|
||
error: null,
|
||
worktree: null,
|
||
branch: null,
|
||
});
|
||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||
});
|
||
|
||
it("returns 400 when task is not in a retryable state", async () => {
|
||
const activeTask = { ...FAKE_TASK_DETAIL, status: "executing" };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(activeTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not in a retryable state");
|
||
});
|
||
|
||
it("retries a failed task in any column (not just in-progress)", async () => {
|
||
const failedTaskInTodo = { ...FAKE_TASK_DETAIL, column: "todo", status: "failed" };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTaskInTodo);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTaskInTodo);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
status: null,
|
||
error: null,
|
||
worktree: null,
|
||
branch: null,
|
||
});
|
||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||
});
|
||
|
||
it("retries a stuck-killed task and moves it to todo", async () => {
|
||
const stuckTask = { ...FAKE_TASK_DETAIL, status: "stuck-killed", column: "in-progress" };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(stuckTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(stuckTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
status: null,
|
||
error: null,
|
||
worktree: null,
|
||
branch: null,
|
||
});
|
||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/duplicate", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
duplicateTask: vi.fn(),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("duplicates a task and returns 201 with new task", async () => {
|
||
const newTask = { ...FAKE_TASK_DETAIL, id: "FN-002", column: "triage" };
|
||
(store.duplicateTask as ReturnType<typeof vi.fn>).mockResolvedValue(newTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/duplicate", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.id).toBe("FN-002");
|
||
expect(res.body.column).toBe("triage");
|
||
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001");
|
||
});
|
||
|
||
it("returns 404 when source task not found", async () => {
|
||
const error = new Error("Task not found") as NodeJS.ErrnoException;
|
||
error.code = "ENOENT";
|
||
(store.duplicateTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/duplicate", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.duplicateTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/duplicate", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Database error");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/refine", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
refineTask: vi.fn(),
|
||
logEntry: vi.fn(),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("creates refinement task from done task and returns 201", async () => {
|
||
const refinedTask = { ...FAKE_TASK_DETAIL, id: "FN-002", column: "triage", title: "Refinement: KB-001" };
|
||
(store.refineTask as ReturnType<typeof vi.fn>).mockResolvedValue(refinedTask);
|
||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.id).toBe("FN-002");
|
||
expect(res.body.column).toBe("triage");
|
||
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements");
|
||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements");
|
||
});
|
||
|
||
it("creates refinement task from in-review task and returns 201", async () => {
|
||
const refinedTask = { ...FAKE_TASK_DETAIL, id: "FN-002", column: "triage", title: "Refinement: My Feature" };
|
||
(store.refineTask as ReturnType<typeof vi.fn>).mockResolvedValue(refinedTask);
|
||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Fix edge cases" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.column).toBe("triage");
|
||
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Fix edge cases");
|
||
});
|
||
|
||
it("returns 400 when task is not in done or in-review column", async () => {
|
||
(store.refineTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot refine FN-001: task is in 'triage', must be in 'done' or 'in-review'"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must be in 'done' or 'in-review'");
|
||
});
|
||
|
||
it("returns 400 when feedback is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("feedback is required");
|
||
expect(store.refineTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when feedback is empty string", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("feedback is required");
|
||
expect(store.refineTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when feedback exceeds 2000 characters", async () => {
|
||
const longFeedback = "x".repeat(2001);
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: longFeedback }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("feedback must be between 1 and 2000 characters");
|
||
expect(store.refineTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 404 when source task not found", async () => {
|
||
const error = new Error("Task not found") as NodeJS.ErrnoException;
|
||
error.code = "ENOENT";
|
||
(store.refineTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("returns 400 when feedback is whitespace only (caught at validation)", async () => {
|
||
// Route-level validation now catches whitespace-only input before it reaches the store
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: " " }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("feedback must be between 1 and 2000 characters");
|
||
expect(store.refineTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.refineTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Database error");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/archive", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
archiveTask: vi.fn(),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("archives a done task and returns the updated task", async () => {
|
||
const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" };
|
||
(store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.column).toBe("archived");
|
||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
|
||
});
|
||
|
||
it("returns 400 when task is not in done column", async () => {
|
||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive FN-001: task is in 'triage', must be in 'done'"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must be in 'done'");
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Database error");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/unarchive", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
unarchiveTask: vi.fn(),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("unarchives an archived task and returns the updated task", async () => {
|
||
const unarchivedTask = { ...FAKE_TASK_DETAIL, column: "done" };
|
||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(unarchivedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.column).toBe("done");
|
||
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001");
|
||
});
|
||
|
||
it("returns 400 when task is not in archived column", async () => {
|
||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot unarchive FN-001: task is in 'done', must be in 'archived'"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must be in 'archived'");
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Database error");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/archive-all-done", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
archiveAllDone: vi.fn(),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("archives all done tasks and returns the archived array", async () => {
|
||
const archivedTasks = [
|
||
{ ...FAKE_TASK_DETAIL, id: "FN-001", column: "archived" },
|
||
{ ...FAKE_TASK_DETAIL, id: "FN-002", column: "archived" },
|
||
];
|
||
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTasks);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.archived).toHaveLength(2);
|
||
expect(res.body.archived[0].column).toBe("archived");
|
||
expect(res.body.archived[1].column).toBe("archived");
|
||
expect(store.archiveAllDone).toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns empty array when no done tasks exist", async () => {
|
||
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.archived).toEqual([]);
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Database error");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/batch-update-models", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("updates multiple tasks with executor and validator models", async () => {
|
||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||
const task2 = { ...FAKE_TASK_DETAIL, id: "FN-002" };
|
||
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o", validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5" };
|
||
const updated2 = { ...task2, modelProvider: "openai", modelId: "gpt-4o", validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5" };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce(task1)
|
||
.mockResolvedValueOnce(task2);
|
||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce(updated1)
|
||
.mockResolvedValueOnce(updated2);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001", "FN-002"],
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
validatorModelProvider: "anthropic",
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.count).toBe(2);
|
||
expect(res.body.updated).toHaveLength(2);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
validatorModelProvider: "anthropic",
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
});
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-002", {
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
validatorModelProvider: "anthropic",
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
});
|
||
});
|
||
|
||
it("updates only executor model when only executor fields provided", async () => {
|
||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o" };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.count).toBe(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
});
|
||
});
|
||
|
||
it("updates only validator model when only validator fields provided", async () => {
|
||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||
const updated1 = { ...task1, validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5" };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
validatorModelProvider: "anthropic",
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.count).toBe(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
validatorModelProvider: "anthropic",
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
});
|
||
});
|
||
|
||
it("clears models when null values provided", async () => {
|
||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001", modelProvider: "openai", modelId: "gpt-4o" };
|
||
const updated1 = { ...task1, modelProvider: undefined, modelId: undefined };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
modelProvider: null,
|
||
modelId: null,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
modelProvider: null,
|
||
modelId: null,
|
||
});
|
||
});
|
||
|
||
it("returns 400 when taskIds is not an array", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: "FN-001",
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("taskIds must be an array");
|
||
});
|
||
|
||
it("returns 400 when taskIds is empty", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: [],
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("at least one task ID");
|
||
});
|
||
|
||
it("returns 400 when taskIds contains non-string values", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001", 123],
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("non-empty strings");
|
||
});
|
||
|
||
it("returns 400 when no model fields provided", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("At least one model field");
|
||
});
|
||
|
||
it("returns 400 when only executor provider provided (missing modelId)", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
modelProvider: "openai",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Executor model must include both provider and modelId");
|
||
});
|
||
|
||
it("returns 400 when only executor modelId provided (missing provider)", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
modelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Executor model must include both provider and modelId");
|
||
});
|
||
|
||
it("returns 400 when only validator provider provided (missing modelId)", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
validatorModelProvider: "anthropic",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Validator model must include both provider and modelId");
|
||
});
|
||
|
||
it("returns 400 when only validator modelId provided (missing provider)", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Validator model must include both provider and modelId");
|
||
});
|
||
|
||
it("returns 404 when task does not exist", async () => {
|
||
const err = new Error("Task KB-999 not found") as Error & { code: string };
|
||
err.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValueOnce(err);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["KB-999"],
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("KB-999 not found");
|
||
});
|
||
|
||
it("continues with other tasks when individual update fails", async () => {
|
||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||
const task2 = { ...FAKE_TASK_DETAIL, id: "FN-002" };
|
||
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o" };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce(task1)
|
||
.mockResolvedValueOnce(task2);
|
||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce(updated1)
|
||
.mockRejectedValueOnce(new Error("Update failed"));
|
||
|
||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001", "FN-002"],
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.count).toBe(1);
|
||
expect(res.body.updated).toHaveLength(1);
|
||
expect(consoleSpy).toHaveBeenCalled();
|
||
|
||
consoleSpy.mockRestore();
|
||
});
|
||
|
||
it("updates only planning model when only planning fields provided", async () => {
|
||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||
const updated1 = { ...task1, planningModelProvider: "google", planningModelId: "gemini-2.5-pro" };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.count).toBe(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
});
|
||
});
|
||
|
||
it("updates executor, validator, and planning models together", async () => {
|
||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o", validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5", planningModelProvider: "google", planningModelId: "gemini-2.5-pro" };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
validatorModelProvider: "anthropic",
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.count).toBe(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
modelProvider: "openai",
|
||
modelId: "gpt-4o",
|
||
validatorModelProvider: "anthropic",
|
||
validatorModelId: "claude-sonnet-4-5",
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
});
|
||
});
|
||
|
||
it("returns 400 when only planning provider provided (missing modelId)", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||
taskIds: ["FN-001"],
|
||
planningModelProvider: "google",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Planning model must include both provider and modelId");
|
||
});
|
||
});
|
||
|
||
describe("PATCH /tasks/:id", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("forwards dependencies to store.updateTask", async () => {
|
||
const updatedTask = { ...FAKE_TASK_DETAIL, dependencies: ["FN-002"] };
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ dependencies: ["FN-002"] }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: ["FN-002"],
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: undefined,
|
||
});
|
||
expect(res.body.dependencies).toEqual(["FN-002"]);
|
||
});
|
||
|
||
it("forwards title and description without dependencies", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ title: "New" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: "New",
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: undefined,
|
||
});
|
||
});
|
||
|
||
it("forwards model override fields to store.updateTask", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
validatorModelProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: undefined,
|
||
});
|
||
});
|
||
|
||
it("returns 400 for invalid modelProvider type", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
modelProvider: 123,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("modelProvider must be a string");
|
||
});
|
||
|
||
it("returns 400 for invalid modelId type", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
modelId: true,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("modelId must be a string");
|
||
});
|
||
|
||
it("accepts null to clear model fields", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
modelProvider: undefined,
|
||
modelId: undefined,
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
modelProvider: null,
|
||
modelId: null,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: undefined,
|
||
});
|
||
});
|
||
|
||
it("forwards planning model override fields to store.updateTask", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
thinkingLevel: undefined,
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
thinkingLevel: undefined,
|
||
});
|
||
});
|
||
|
||
it("returns 400 for invalid planningModelProvider type", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
planningModelProvider: 123,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("planningModelProvider must be a string");
|
||
});
|
||
|
||
it("returns 400 for invalid planningModelId type", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
planningModelId: true,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("planningModelId must be a string");
|
||
});
|
||
|
||
it("accepts null to clear planning model fields", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
planningModelProvider: undefined,
|
||
planningModelId: undefined,
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: undefined,
|
||
});
|
||
});
|
||
|
||
it("forwards enabledWorkflowSteps to store.updateTask", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
enabledWorkflowSteps: ["browser-verification"],
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
enabledWorkflowSteps: ["browser-verification"],
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: ["browser-verification"],
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: undefined,
|
||
});
|
||
});
|
||
|
||
it("returns 400 for invalid enabledWorkflowSteps type", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
enabledWorkflowSteps: [123],
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("enabledWorkflowSteps must be an array of strings");
|
||
});
|
||
|
||
it("forwards thinkingLevel to store.updateTask", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
thinkingLevel: "high",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
thinkingLevel: "high",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: "high",
|
||
});
|
||
});
|
||
|
||
it("accepts null to clear thinkingLevel via PATCH", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
thinkingLevel: undefined,
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
thinkingLevel: null,
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||
title: undefined,
|
||
description: undefined,
|
||
prompt: undefined,
|
||
dependencies: undefined,
|
||
enabledWorkflowSteps: undefined,
|
||
modelProvider: null,
|
||
modelId: null,
|
||
validatorModelProvider: null,
|
||
validatorModelId: null,
|
||
planningModelProvider: null,
|
||
planningModelId: null,
|
||
thinkingLevel: null,
|
||
});
|
||
});
|
||
|
||
it("returns 400 for invalid thinkingLevel value via PATCH", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||
thinkingLevel: "invalid",
|
||
}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("thinkingLevel must be one of");
|
||
});
|
||
});
|
||
|
||
|
||
describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
|
||
let tempDir: string;
|
||
let fusionDir: string;
|
||
let agentId: string;
|
||
let store: TaskStore;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-task-assign-"));
|
||
fusionDir = join(tempDir, ".fusion");
|
||
mkdirSync(fusionDir, { recursive: true });
|
||
|
||
const { AgentStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||
await agentStore.init();
|
||
const agent = await agentStore.createAgent({
|
||
name: "Assignment test agent",
|
||
role: "executor",
|
||
});
|
||
agentId = agent.id;
|
||
|
||
store = createMockStore({
|
||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||
updateTask: vi.fn(),
|
||
listTasks: vi.fn().mockResolvedValue([]),
|
||
} as any);
|
||
}, 30_000);
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("assigns a task to an existing agent", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-200",
|
||
assignedAgentId: agentId,
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/FN-200/assign", JSON.stringify({ agentId }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: agentId });
|
||
expect(res.body.assignedAgentId).toBe(agentId);
|
||
}, 20000);
|
||
|
||
it("returns 404 when assigning to a non-existent agent", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PATCH",
|
||
"/api/tasks/FN-200/assign",
|
||
JSON.stringify({ agentId: "agent-missing" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toBe("Agent not found");
|
||
expect(store.updateTask).not.toHaveBeenCalled();
|
||
}, 20000);
|
||
|
||
it("unassigns a task when agentId is null", async () => {
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-200",
|
||
assignedAgentId: undefined,
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PATCH",
|
||
"/api/tasks/FN-200/assign",
|
||
JSON.stringify({ agentId: null }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: null });
|
||
expect(res.body.assignedAgentId).toBeUndefined();
|
||
}, 20000);
|
||
|
||
it("returns tasks assigned to the specified agent", async () => {
|
||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||
{ ...FAKE_TASK_DETAIL, id: "FN-001", assignedAgentId: agentId },
|
||
{ ...FAKE_TASK_DETAIL, id: "FN-002", assignedAgentId: "agent-other" },
|
||
{ ...FAKE_TASK_DETAIL, id: "FN-003" },
|
||
]);
|
||
|
||
const res = await GET(buildApp(), `/api/agents/${agentId}/tasks`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.map((task: { id: string }) => task.id)).toEqual(["FN-001"]);
|
||
}, 20000);
|
||
|
||
it("returns 404 for /api/agents/:id/tasks when agent does not exist", async () => {
|
||
const res = await GET(buildApp(), "/api/agents/agent-missing/tasks");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toBe("Agent not found");
|
||
expect(store.listTasks).not.toHaveBeenCalled();
|
||
}, 30_000);
|
||
});
|
||
|
||
describe("Attachment routes", () => {
|
||
const FAKE_ATTACHMENT: TaskAttachment = {
|
||
filename: "1234-screenshot.png",
|
||
originalName: "screenshot.png",
|
||
mimeType: "image/png",
|
||
size: 100,
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
};
|
||
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
addAttachment: vi.fn().mockResolvedValue(FAKE_ATTACHMENT),
|
||
getAttachment: vi.fn(),
|
||
deleteAttachment: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, attachments: [] }),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("POST /tasks/:id/attachments — uploads a valid image", async () => {
|
||
const content = Buffer.from("fake png content");
|
||
const { body, boundary } = buildMultipart("file", "screenshot.png", "image/png", content);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/attachments", body, {
|
||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.filename).toBe("1234-screenshot.png");
|
||
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||
"KB-001",
|
||
"screenshot.png",
|
||
expect.any(Buffer),
|
||
"image/png",
|
||
);
|
||
});
|
||
|
||
it("POST /tasks/:id/attachments — returns 400 for invalid mime type", async () => {
|
||
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||
new Error("Invalid mime type 'text/plain'. Allowed: image/png, image/jpeg, image/gif, image/webp"),
|
||
);
|
||
|
||
const content = Buffer.from("not an image");
|
||
const { body, boundary } = buildMultipart("file", "file.txt", "text/plain", content);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/attachments", body, {
|
||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid mime type");
|
||
});
|
||
|
||
it("POST /tasks/:id/attachments — returns 400 for oversized file", async () => {
|
||
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||
new Error("File too large"),
|
||
);
|
||
|
||
const content = Buffer.from("small but store rejects");
|
||
const { body, boundary } = buildMultipart("file", "big.png", "image/png", content);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/attachments", body, {
|
||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("File too large");
|
||
});
|
||
|
||
it("DELETE /tasks/:id/attachments/:filename — deletes attachment", async () => {
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/1234-screenshot.png");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("KB-001", "1234-screenshot.png");
|
||
});
|
||
|
||
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
|
||
const err: NodeJS.ErrnoException = new Error("Attachment not found");
|
||
err.code = "ENOENT";
|
||
(store.deleteAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/nope.png");
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("GET /tasks/:id/logs — returns agent logs", async () => {
|
||
const fakeLogs = [
|
||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "Hello", type: "text" },
|
||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" },
|
||
];
|
||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual(fakeLogs);
|
||
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001");
|
||
});
|
||
|
||
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
|
||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual([]);
|
||
});
|
||
|
||
it("GET /tasks/:id/logs — returns 500 on store error", async () => {
|
||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("disk error"));
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("disk error");
|
||
});
|
||
|
||
it("GET /tasks/:id/logs — preserves long text and detail without truncation", async () => {
|
||
const longText = "A".repeat(5000);
|
||
const longDetail = "B".repeat(5000);
|
||
const fakeLogs = [
|
||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: longText, type: "text" },
|
||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "KB-001", text: "Read", type: "tool", detail: longDetail },
|
||
];
|
||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(2);
|
||
expect(res.body[0].text).toBe(longText);
|
||
expect(res.body[0].text.length).toBe(5000);
|
||
expect(res.body[1].detail).toBe(longDetail);
|
||
expect(res.body[1].detail.length).toBe(5000);
|
||
});
|
||
});
|
||
|
||
// --- Models route tests ---
|
||
|
||
function createMockModelRegistry(overrides: Partial<ModelRegistryLike> = {}): ModelRegistryLike {
|
||
return {
|
||
refresh: vi.fn(),
|
||
getAvailable: vi.fn().mockReturnValue([
|
||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 },
|
||
{ id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 },
|
||
]),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe("GET /models", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp(modelRegistry?: ModelRegistryLike) {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { modelRegistry }));
|
||
return app;
|
||
}
|
||
|
||
it("returns available models from registry", async () => {
|
||
const modelRegistry = createMockModelRegistry();
|
||
const res = await GET(buildApp(modelRegistry), "/api/models");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.models).toEqual([
|
||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||
]);
|
||
expect(modelRegistry.refresh).toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns empty array when no model registry is provided", async () => {
|
||
const res = await GET(buildApp(), "/api/models");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.models).toEqual([]);
|
||
});
|
||
|
||
it("returns empty array when registry has no available models", async () => {
|
||
const modelRegistry = createMockModelRegistry({
|
||
getAvailable: vi.fn().mockReturnValue([]),
|
||
});
|
||
const res = await GET(buildApp(modelRegistry), "/api/models");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.models).toEqual([]);
|
||
});
|
||
|
||
it("returns empty array when registry throws", async () => {
|
||
const modelRegistry = createMockModelRegistry({
|
||
getAvailable: vi.fn().mockImplementation(() => {
|
||
throw new Error("registry error");
|
||
}),
|
||
});
|
||
const res = await GET(buildApp(modelRegistry), "/api/models");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.models).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// --- Auth route tests ---
|
||
|
||
function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthStorageLike {
|
||
return {
|
||
reload: vi.fn(),
|
||
getOAuthProviders: vi.fn().mockReturnValue([
|
||
{ id: "anthropic", name: "Anthropic" },
|
||
]),
|
||
hasAuth: vi.fn().mockReturnValue(false),
|
||
login: vi.fn().mockImplementation((_provider: string, callbacks: any) => {
|
||
// Simulate onAuth callback with a URL, then resolve
|
||
callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" });
|
||
return Promise.resolve();
|
||
}),
|
||
logout: vi.fn(),
|
||
getApiKeyProviders: vi.fn().mockReturnValue([
|
||
{ id: "openrouter", name: "OpenRouter" },
|
||
{ id: "kimi-coding", name: "Kimi" },
|
||
]),
|
||
hasApiKey: vi.fn().mockReturnValue(false),
|
||
setApiKey: vi.fn(),
|
||
clearApiKey: vi.fn(),
|
||
...overrides,
|
||
} as unknown as AuthStorageLike;
|
||
}
|
||
|
||
describe("GET /auth/status", () => {
|
||
let store: TaskStore;
|
||
let authStorage: AuthStorageLike;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
authStorage = createMockAuthStorage();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||
return app;
|
||
}
|
||
|
||
it("returns provider list with auth status", async () => {
|
||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||
|
||
const res = await GET(buildApp(), "/api/auth/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.providers).toEqual([
|
||
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
||
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
|
||
]);
|
||
expect(authStorage.reload).toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns unauthenticated status", async () => {
|
||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||
|
||
const res = await GET(buildApp(), "/api/auth/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.providers[0].authenticated).toBe(false);
|
||
});
|
||
|
||
it("returns authenticated true for API-key provider when hasApiKey is true", async () => {
|
||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||
|
||
const res = await GET(buildApp(), "/api/auth/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
const openrouter = res.body.providers.find((p: any) => p.id === "openrouter");
|
||
expect(openrouter).toBeDefined();
|
||
expect(openrouter.authenticated).toBe(true);
|
||
expect(openrouter.type).toBe("api_key");
|
||
});
|
||
|
||
it("returns 500 on error", async () => {
|
||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||
throw new Error("storage error");
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/auth/status");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("storage error");
|
||
});
|
||
});
|
||
|
||
describe("POST /auth/login", () => {
|
||
let store: TaskStore;
|
||
let authStorage: AuthStorageLike;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
authStorage = createMockAuthStorage();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||
return app;
|
||
}
|
||
|
||
it("returns auth URL for valid provider", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.url).toBe("https://auth.example.com/login");
|
||
expect(res.body.instructions).toBe("Open in browser");
|
||
});
|
||
|
||
it("returns 400 when provider is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toBe("provider is required");
|
||
});
|
||
|
||
it("returns 400 for unknown provider", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "unknown" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Unknown provider");
|
||
});
|
||
|
||
it("returns 500 when login fails", async () => {
|
||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
|
||
return Promise.reject(new Error("OAuth failed"));
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("OAuth failed");
|
||
});
|
||
});
|
||
|
||
describe("POST /auth/logout", () => {
|
||
let store: TaskStore;
|
||
let authStorage: AuthStorageLike;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
authStorage = createMockAuthStorage();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||
return app;
|
||
}
|
||
|
||
it("removes credentials for a provider", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/logout", JSON.stringify({ provider: "anthropic" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.success).toBe(true);
|
||
expect(authStorage.logout).toHaveBeenCalledWith("anthropic");
|
||
});
|
||
|
||
it("returns 400 when provider is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/logout", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toBe("provider is required");
|
||
});
|
||
|
||
it("returns 500 on error", async () => {
|
||
(authStorage.logout as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||
throw new Error("logout failed");
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/logout", JSON.stringify({ provider: "anthropic" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("logout failed");
|
||
});
|
||
});
|
||
|
||
describe("POST /auth/api-key", () => {
|
||
let store: TaskStore;
|
||
let authStorage: AuthStorageLike;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
authStorage = createMockAuthStorage();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||
return app;
|
||
}
|
||
|
||
it("saves an API key for a valid provider", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
apiKey: "sk-or-v1-test-key",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.success).toBe(true);
|
||
expect(authStorage.setApiKey).toHaveBeenCalledWith("openrouter", "sk-or-v1-test-key");
|
||
});
|
||
|
||
it("trims whitespace from API key", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
apiKey: " sk-or-v1-test-key ",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(authStorage.setApiKey).toHaveBeenCalledWith("openrouter", "sk-or-v1-test-key");
|
||
});
|
||
|
||
it("returns 400 when provider is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||
apiKey: "sk-test",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toBe("provider is required");
|
||
});
|
||
|
||
it("returns 400 when apiKey is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("apiKey is required");
|
||
});
|
||
|
||
it("returns 400 when apiKey is empty", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
apiKey: " ",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("apiKey is required");
|
||
});
|
||
|
||
it("returns 400 for unknown provider", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||
provider: "unknown-provider",
|
||
apiKey: "sk-test",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Unknown API key provider");
|
||
});
|
||
|
||
it("returns 400 when storage does not support API keys", async () => {
|
||
const storageWithoutApiKeys = createMockAuthStorage({
|
||
setApiKey: undefined,
|
||
getApiKeyProviders: undefined,
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { authStorage: storageWithoutApiKeys }));
|
||
|
||
const res = await REQUEST(app, "POST", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
apiKey: "sk-test",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not supported");
|
||
});
|
||
|
||
it("returns 500 on storage error", async () => {
|
||
(authStorage.setApiKey as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||
throw new Error("disk full");
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
apiKey: "sk-test",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("disk full");
|
||
});
|
||
});
|
||
|
||
describe("DELETE /auth/api-key", () => {
|
||
let store: TaskStore;
|
||
let authStorage: AuthStorageLike;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
authStorage = createMockAuthStorage();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||
return app;
|
||
}
|
||
|
||
it("clears an API key for a provider", async () => {
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.success).toBe(true);
|
||
expect(authStorage.clearApiKey).toHaveBeenCalledWith("openrouter");
|
||
});
|
||
|
||
it("returns 400 when provider is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/auth/api-key", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toBe("provider is required");
|
||
});
|
||
|
||
it("returns 400 when storage does not support API keys", async () => {
|
||
const storageWithoutApiKeys = createMockAuthStorage({
|
||
clearApiKey: undefined,
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { authStorage: storageWithoutApiKeys }));
|
||
|
||
const res = await REQUEST(app, "DELETE", "/api/auth/api-key", JSON.stringify({
|
||
provider: "openrouter",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not supported");
|
||
});
|
||
});
|
||
|
||
describe("Pause/Unpause endpoints", () => {
|
||
let store: TaskStore;
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
pauseTask: vi.fn().mockResolvedValue({ id: "FN-001", paused: true }),
|
||
});
|
||
});
|
||
|
||
it("POST /tasks/:id/pause — pauses a task", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual({ id: "FN-001", paused: true });
|
||
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", true);
|
||
});
|
||
|
||
it("POST /tasks/:id/unpause — unpauses a task", async () => {
|
||
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001" });
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unpause");
|
||
expect(res.status).toBe(200);
|
||
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", false);
|
||
});
|
||
|
||
it("POST /tasks/:id/pause — returns 500 on error", async () => {
|
||
(store.pauseTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"));
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("not found");
|
||
});
|
||
|
||
describe("task comment routes", () => {
|
||
it("GET /tasks/:id/comments — returns task comments", async () => {
|
||
const comments = [{ id: "c1", text: "Hello", author: "alice", createdAt: "2026-01-01T00:00:00.000Z" }];
|
||
const store = createMockStore({
|
||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, comments }),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
|
||
const res = await GET(app, "/api/tasks/KB-001/comments");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual(comments);
|
||
});
|
||
|
||
it("POST /tasks/:id/comments — adds a task comment", async () => {
|
||
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] };
|
||
const store = createMockStore({ addTaskComment: vi.fn().mockResolvedValue(updatedTask) });
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
|
||
const res = await REQUEST(app, "POST", "/api/tasks/KB-001/comments", JSON.stringify({ text: "Hello" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
|
||
});
|
||
|
||
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
|
||
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [{ id: "c1", text: "Updated", author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:01:00.000Z" }] };
|
||
const store = createMockStore({ updateTaskComment: vi.fn().mockResolvedValue(updatedTask) });
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
|
||
const res = await REQUEST(app, "PATCH", "/api/tasks/KB-001/comments/c1", JSON.stringify({ text: "Updated" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated");
|
||
});
|
||
|
||
it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => {
|
||
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [] };
|
||
const store = createMockStore({ deleteTaskComment: vi.fn().mockResolvedValue(updatedTask) });
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
|
||
const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1");
|
||
expect(res.status).toBe(200);
|
||
expect(store.deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/steer", () => {
|
||
it("adds a steering comment to a task", async () => {
|
||
const mockComment = {
|
||
id: "FN-001",
|
||
steeringComments: [
|
||
{
|
||
id: "1234567890-abc123",
|
||
text: "Please handle the edge case",
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
author: "user" as const,
|
||
},
|
||
],
|
||
};
|
||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/steer",
|
||
JSON.stringify({ text: "Please handle the edge case" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual(mockComment);
|
||
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||
"KB-001",
|
||
"Please handle the edge case",
|
||
"user"
|
||
);
|
||
});
|
||
|
||
it("returns 400 when text is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/steer", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("text is required");
|
||
});
|
||
|
||
it("returns 400 when text is empty", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/steer",
|
||
JSON.stringify({ text: "" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
// Empty string fails the "!text" check, not the length check
|
||
expect(res.body.error).toContain("text is required");
|
||
});
|
||
|
||
it("returns 400 when text exceeds 2000 characters", async () => {
|
||
const longText = "a".repeat(2001);
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/steer",
|
||
JSON.stringify({ text: longText }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("text must be between 1 and 2000 characters");
|
||
});
|
||
|
||
it("returns 404 when task not found", async () => {
|
||
const error = new Error("Task not found") as Error & { code?: string };
|
||
error.code = "ENOENT";
|
||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/steer",
|
||
JSON.stringify({ text: "Valid comment" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||
new Error("Database error")
|
||
);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/steer",
|
||
JSON.stringify({ text: "Valid comment" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("Database error");
|
||
});
|
||
});
|
||
|
||
// --- PR Management route tests ---
|
||
|
||
describe("POST /tasks/:id/pr/create", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
updatePrInfo: vi.fn(),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockPrInfo = {
|
||
url: "https://github.com/owner/repo/pull/42",
|
||
number: 42,
|
||
status: "open" as const,
|
||
title: "Test PR",
|
||
headBranch: "fusion/fn-001",
|
||
baseBranch: "main",
|
||
commentCount: 0,
|
||
};
|
||
|
||
const mockInReviewTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
column: "in-review" as const,
|
||
prInfo: undefined,
|
||
};
|
||
|
||
it("returns 400 if task is not in in-review column", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
column: "in-progress",
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/pr/create",
|
||
JSON.stringify({ title: "Test PR" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("in-review");
|
||
});
|
||
|
||
it("returns 409 if task already has a PR", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
column: "in-review",
|
||
prInfo: mockPrInfo,
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/pr/create",
|
||
JSON.stringify({ title: "Test PR" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(409);
|
||
expect(res.body.error).toContain("already has PR");
|
||
});
|
||
|
||
it("returns 400 if title is missing", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(mockInReviewTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/pr/create",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("title is required");
|
||
});
|
||
|
||
it("no longer has in-app rate limiter (gh CLI handles rate limiting)", async () => {
|
||
// Previously this test checked for a 429 response from an in-memory rate limiter.
|
||
// Now gh CLI handles rate limiting internally, so multiple rapid requests
|
||
// are allowed (gh CLI has its own rate limiting and caching).
|
||
// Set up GITHUB_REPOSITORY env to bypass git lookup
|
||
const originalEnv = process.env.GITHUB_REPOSITORY;
|
||
process.env.GITHUB_REPOSITORY = "owner/rate-test";
|
||
|
||
// Create a fresh store mock for this test
|
||
const freshStore = createMockStore({
|
||
getTask: vi.fn(),
|
||
updatePrInfo: vi.fn(),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
|
||
function buildFreshApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(freshStore));
|
||
return app;
|
||
}
|
||
|
||
// Make multiple rapid requests - should not be rate limited by our code
|
||
// (gh CLI handles rate limiting with GitHub)
|
||
const app = buildFreshApp();
|
||
for (let i = 0; i < 5; i++) {
|
||
(freshStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...mockInReviewTask,
|
||
id: `KB-RATE-${i}`,
|
||
});
|
||
const res = await REQUEST(
|
||
app,
|
||
"POST",
|
||
`/api/tasks/KB-RATE-${i}/pr/create`,
|
||
JSON.stringify({ title: `Test PR ${i}` }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
// Should not get 429 from our code (may get 500 from gh CLI not being available in test)
|
||
expect(res.status).not.toBe(429);
|
||
}
|
||
|
||
// Restore env
|
||
if (originalEnv) {
|
||
process.env.GITHUB_REPOSITORY = originalEnv;
|
||
} else {
|
||
delete process.env.GITHUB_REPOSITORY;
|
||
}
|
||
});
|
||
|
||
it("returns 404 for non-existent task", async () => {
|
||
// Create error with proper ENOENT code
|
||
const error = new Error("ENOENT: task not found") as NodeJS.ErrnoException;
|
||
error.code = "ENOENT";
|
||
error.errno = -2;
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-999/pr/create",
|
||
JSON.stringify({ title: "Test PR" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
});
|
||
|
||
describe("GET /tasks/:id/pr/status", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockPrInfo = {
|
||
url: "https://github.com/owner/repo/pull/42",
|
||
number: 42,
|
||
status: "open" as const,
|
||
title: "Test PR",
|
||
headBranch: "fusion/fn-001",
|
||
baseBranch: "main",
|
||
commentCount: 3,
|
||
};
|
||
|
||
it("returns cached PR info when available", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
prInfo: mockPrInfo,
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.prInfo).toEqual(mockPrInfo);
|
||
expect(res.body.stale).toBe(false);
|
||
expect(res.body.automationStatus).toBeNull();
|
||
});
|
||
|
||
it("returns 404 when task has no PR", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("no associated PR");
|
||
});
|
||
|
||
it("returns 404 for non-existent task", async () => {
|
||
const error = new Error("Task not found") as Error & { code?: string };
|
||
error.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-999/pr/status");
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("marks data as stale when older than 5 minutes", async () => {
|
||
const oldDate = new Date(Date.now() - 6 * 60 * 1000).toISOString(); // 6 minutes ago
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
prInfo: mockPrInfo,
|
||
updatedAt: oldDate,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.stale).toBe(true);
|
||
});
|
||
|
||
it("uses lastCheckedAt for staleness check when available", async () => {
|
||
const recentUpdate = new Date().toISOString();
|
||
const oldCheck = new Date(Date.now() - 6 * 60 * 1000).toISOString(); // 6 minutes ago
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
prInfo: { ...mockPrInfo, lastCheckedAt: oldCheck },
|
||
updatedAt: recentUpdate,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
// Should be stale because lastCheckedAt is old, even though updatedAt is recent
|
||
expect(res.body.stale).toBe(true);
|
||
});
|
||
|
||
it("returns automationStatus so the UI can reflect PR-first waiting states", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
status: "awaiting-pr-checks",
|
||
prInfo: mockPrInfo,
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.automationStatus).toBe("awaiting-pr-checks");
|
||
});
|
||
|
||
it("marks data as fresh when lastCheckedAt is recent", async () => {
|
||
const recentCheck = new Date(Date.now() - 2 * 60 * 1000).toISOString(); // 2 minutes ago
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
prInfo: { ...mockPrInfo, lastCheckedAt: recentCheck },
|
||
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
// Should be fresh because lastCheckedAt is recent, even though updatedAt is old
|
||
expect(res.body.stale).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/pr/refresh", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
updatePrInfo: vi.fn(),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockPrInfo = {
|
||
url: "https://github.com/owner/repo/pull/42",
|
||
number: 42,
|
||
status: "open" as const,
|
||
title: "Test PR",
|
||
headBranch: "fusion/fn-001",
|
||
baseBranch: "main",
|
||
commentCount: 3,
|
||
};
|
||
|
||
it("returns merge readiness details for PR-first UI refreshes", async () => {
|
||
const originalRepo = process.env.GITHUB_REPOSITORY;
|
||
process.env.GITHUB_REPOSITORY = "owner/repo";
|
||
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
|
||
prInfo: mockPrInfo,
|
||
mergeReady: false,
|
||
blockingReasons: ["required checks not successful: ci (pending)"],
|
||
reviewDecision: "CHANGES_REQUESTED",
|
||
checks: [{ name: "ci", required: true, state: "pending" }],
|
||
});
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
status: "awaiting-pr-checks",
|
||
prInfo: mockPrInfo,
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/pr/refresh",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.prInfo.number).toBe(42);
|
||
expect(res.body.mergeReady).toBe(false);
|
||
expect(res.body.blockingReasons).toEqual(["required checks not successful: ci (pending)"]);
|
||
expect(res.body.reviewDecision).toBe("CHANGES_REQUESTED");
|
||
expect(res.body.automationStatus).toBe("awaiting-pr-checks");
|
||
|
||
if (originalRepo) {
|
||
process.env.GITHUB_REPOSITORY = originalRepo;
|
||
} else {
|
||
delete process.env.GITHUB_REPOSITORY;
|
||
}
|
||
});
|
||
|
||
it("returns 404 when task has no PR", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/pr/refresh",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("no associated PR");
|
||
});
|
||
|
||
it("returns 404 for non-existent task", async () => {
|
||
const error = new Error("Task not found") as Error & { code?: string };
|
||
error.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-999/pr/refresh",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe("GET /tasks/:id/issue/status", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockIssueInfo = {
|
||
url: "https://github.com/owner/repo/issues/123",
|
||
number: 123,
|
||
state: "open" as const,
|
||
title: "Test Issue",
|
||
};
|
||
|
||
it("returns cached issue info when available", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
issueInfo: mockIssueInfo,
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/issue/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.issueInfo).toEqual(mockIssueInfo);
|
||
expect(res.body.stale).toBe(false);
|
||
});
|
||
|
||
it("returns 404 when task has no issue", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/issue/status");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("no associated issue");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/issue/refresh", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
updateIssueInfo: vi.fn(),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockIssueInfo = {
|
||
url: "https://github.com/owner/repo/issues/123",
|
||
number: 123,
|
||
state: "closed" as const,
|
||
title: "Test Issue",
|
||
stateReason: "completed" as const,
|
||
};
|
||
|
||
it("refreshes and persists issue status", async () => {
|
||
const originalRepo = process.env.GITHUB_REPOSITORY;
|
||
process.env.GITHUB_REPOSITORY = "owner/repo";
|
||
vi.spyOn(GitHubClient.prototype, "getIssueStatus").mockResolvedValue(mockIssueInfo);
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
issueInfo: {
|
||
url: "https://github.com/owner/repo/issues/123",
|
||
number: 123,
|
||
state: "open" as const,
|
||
title: "Test Issue",
|
||
},
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/issue/refresh",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.number).toBe(123);
|
||
expect(res.body.state).toBe("closed");
|
||
expect(res.body.stateReason).toBe("completed");
|
||
expect(store.updateIssueInfo).toHaveBeenCalled();
|
||
|
||
if (originalRepo) {
|
||
process.env.GITHUB_REPOSITORY = originalRepo;
|
||
} else {
|
||
delete process.env.GITHUB_REPOSITORY;
|
||
}
|
||
});
|
||
|
||
it("returns 404 when task has no issue", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/issue/refresh",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("no associated issue");
|
||
});
|
||
});
|
||
|
||
describe("POST /github/batch/status", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns status for multiple tasks in one request", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||
issueInfo: {
|
||
url: "https://github.com/owner/repo/issues/101",
|
||
number: 101,
|
||
state: "open" as const,
|
||
title: "Issue 101",
|
||
},
|
||
})
|
||
.mockResolvedValueOnce({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-002",
|
||
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||
prInfo: {
|
||
url: "https://github.com/owner/repo/pull/42",
|
||
number: 42,
|
||
status: "open" as const,
|
||
title: "PR 42",
|
||
headBranch: "feature/42",
|
||
baseBranch: "main",
|
||
commentCount: 0,
|
||
},
|
||
});
|
||
|
||
vi.spyOn(GitHubClient.prototype, "getBatchIssueStatus").mockResolvedValue(new Map([
|
||
[101, {
|
||
url: "https://github.com/owner/repo/issues/101",
|
||
number: 101,
|
||
state: "closed",
|
||
title: "Issue 101",
|
||
stateReason: "completed",
|
||
}],
|
||
]));
|
||
vi.spyOn(GitHubClient.prototype, "getBatchPrStatus").mockResolvedValue(new Map([
|
||
[42, {
|
||
url: "https://github.com/owner/repo/pull/42",
|
||
number: 42,
|
||
status: "merged",
|
||
title: "PR 42",
|
||
headBranch: "feature/42",
|
||
baseBranch: "main",
|
||
commentCount: 3,
|
||
}],
|
||
]));
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/batch/status",
|
||
JSON.stringify({ taskIds: ["FN-001", "FN-002"] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results["FN-001"].issueInfo.state).toBe("closed");
|
||
expect(res.body.results["FN-001"].stale).toBe(false);
|
||
expect(res.body.results["FN-002"].prInfo.status).toBe("merged");
|
||
expect(res.body.results["FN-002"].stale).toBe(false);
|
||
expect(store.updateIssueInfo).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({ number: 101, state: "closed", lastCheckedAt: expect.any(String) }),
|
||
);
|
||
expect(store.updatePrInfo).toHaveBeenCalledWith(
|
||
"FN-002",
|
||
expect.objectContaining({ number: 42, status: "merged", lastCheckedAt: expect.any(String) }),
|
||
);
|
||
});
|
||
|
||
it("handles partial failures without dropping successful results", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||
issueInfo: {
|
||
url: "https://github.com/owner/repo/issues/101",
|
||
number: 101,
|
||
state: "open" as const,
|
||
title: "Issue 101",
|
||
},
|
||
})
|
||
.mockResolvedValueOnce({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-002",
|
||
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||
issueInfo: {
|
||
url: "https://github.com/owner/repo/issues/404",
|
||
number: 404,
|
||
state: "open" as const,
|
||
title: "Issue 404",
|
||
},
|
||
});
|
||
|
||
vi.spyOn(GitHubClient.prototype, "getBatchIssueStatus").mockResolvedValue(new Map([
|
||
[101, {
|
||
url: "https://github.com/owner/repo/issues/101",
|
||
number: 101,
|
||
state: "closed",
|
||
title: "Issue 101",
|
||
stateReason: "completed",
|
||
}],
|
||
]));
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/batch/status",
|
||
JSON.stringify({ taskIds: ["FN-001", "FN-002"] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results["FN-001"].issueInfo.state).toBe("closed");
|
||
expect(res.body.results["FN-002"].error).toContain("Issue #404 not found");
|
||
expect(res.body.results["FN-002"].stale).toBe(true);
|
||
});
|
||
|
||
it("returns 429 when rate limit is exceeded", async () => {
|
||
const originalRepo = process.env.GITHUB_REPOSITORY;
|
||
process.env.GITHUB_REPOSITORY = "owner/repo";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
issueInfo: {
|
||
url: "https://github.com/owner/repo/issues/101",
|
||
number: 101,
|
||
state: "open" as const,
|
||
title: "Issue 101",
|
||
},
|
||
});
|
||
|
||
const canMakeRequestSpy = vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(false);
|
||
const getResetTimeSpy = vi.spyOn(githubRateLimiter, "getResetTime").mockReturnValue(new Date("2026-03-30T12:05:00.000Z"));
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/batch/status",
|
||
JSON.stringify({ taskIds: ["FN-001"] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(429);
|
||
expect(res.body.error).toContain("rate limit exceeded");
|
||
expect(res.body.resetAt).toBe("2026-03-30T12:05:00.000Z");
|
||
|
||
canMakeRequestSpy.mockRestore();
|
||
getResetTimeSpy.mockRestore();
|
||
if (originalRepo) {
|
||
process.env.GITHUB_REPOSITORY = originalRepo;
|
||
} else {
|
||
delete process.env.GITHUB_REPOSITORY;
|
||
}
|
||
});
|
||
|
||
it("calculates stale per task based on refresh success and existing cached data", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||
issueInfo: {
|
||
url: "https://github.com/owner/repo/issues/101",
|
||
number: 101,
|
||
state: "open" as const,
|
||
title: "Issue 101",
|
||
lastCheckedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||
},
|
||
});
|
||
vi.spyOn(GitHubClient.prototype, "getBatchIssueStatus").mockResolvedValue(new Map());
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/batch/status",
|
||
JSON.stringify({ taskIds: ["FN-001"] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results["FN-001"].stale).toBe(true);
|
||
expect(res.body.results["FN-001"].error).toContain("Issue #101 not found");
|
||
});
|
||
|
||
it("returns empty results for empty taskIds", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/batch/status",
|
||
JSON.stringify({ taskIds: [] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual({ results: {} });
|
||
expect(store.getTask).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|
||
|
||
// --- GitHub Import route tests ---
|
||
|
||
describe("POST /github/issues/fetch", () => {
|
||
let store: TaskStore;
|
||
let listIssuesSpy: ReturnType<typeof vi.fn>;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
mockIsGhAuthenticated.mockReturnValue(true);
|
||
listIssuesSpy = vi.fn();
|
||
vi.spyOn(GitHubClient.prototype, "listIssues").mockImplementation(listIssuesSpy);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockGitHubIssue = {
|
||
number: 1,
|
||
title: "Test Issue",
|
||
body: "Test body",
|
||
html_url: "https://github.com/owner/repo/issues/1",
|
||
labels: [{ name: "bug" }],
|
||
};
|
||
|
||
it("fetches issues successfully", async () => {
|
||
listIssuesSpy.mockResolvedValueOnce([mockGitHubIssue]);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(1);
|
||
expect(res.body[0].number).toBe(1);
|
||
expect(res.body[0].title).toBe("Test Issue");
|
||
});
|
||
|
||
it("returns 400 when owner is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ repo: "repo" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("owner is required");
|
||
});
|
||
|
||
it("returns 400 when repo is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("repo is required");
|
||
});
|
||
|
||
it("returns 404 when repository not found", async () => {
|
||
listIssuesSpy.mockRejectedValueOnce(new Error("Repository not found: owner/repo"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("Repository not found");
|
||
});
|
||
|
||
it("returns 401 when gh not authenticated", async () => {
|
||
mockIsGhAuthenticated.mockReturnValueOnce(false);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(401);
|
||
expect(res.body.error).toContain("Not authenticated with GitHub");
|
||
expect(res.body.error).toContain("gh auth login");
|
||
});
|
||
|
||
it("returns 502 when gh CLI fails", async () => {
|
||
listIssuesSpy.mockRejectedValueOnce(new Error("Some gh CLI error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(502);
|
||
expect(res.body.error).toContain("GitHub CLI error");
|
||
});
|
||
|
||
it("filters out pull requests (gh CLI already filters them)", async () => {
|
||
// gh issue list already filters out PRs, so we just verify the response
|
||
listIssuesSpy.mockResolvedValueOnce([mockGitHubIssue]);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo", limit: 10 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(1);
|
||
expect(res.body[0].number).toBe(1);
|
||
});
|
||
|
||
it("respects limit parameter", async () => {
|
||
const manyIssues = Array.from({ length: 50 }, (_, i) => ({ ...mockGitHubIssue, number: i + 1 }));
|
||
listIssuesSpy.mockResolvedValueOnce(manyIssues.slice(0, 10));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo", limit: 10 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(10);
|
||
});
|
||
});
|
||
|
||
describe("POST /github/issues/import", () => {
|
||
let store: TaskStore;
|
||
let getIssueSpy: ReturnType<typeof vi.fn>;
|
||
|
||
beforeEach(() => {
|
||
mockIsGhAuthenticated.mockReturnValue(true);
|
||
getIssueSpy = vi.fn();
|
||
vi.spyOn(GitHubClient.prototype, "getIssue").mockImplementation(getIssueSpy);
|
||
|
||
store = createMockStore({
|
||
createTask: vi.fn().mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test Issue",
|
||
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
|
||
column: "triage",
|
||
}),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockGitHubIssue = {
|
||
number: 1,
|
||
title: "Test Issue",
|
||
body: "Test body",
|
||
html_url: "https://github.com/owner/repo/issues/1",
|
||
state: "open",
|
||
};
|
||
|
||
it("imports a single issue successfully", async () => {
|
||
getIssueSpy.mockResolvedValueOnce(mockGitHubIssue);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.id).toBe("FN-001");
|
||
expect(store.createTask).toHaveBeenCalledWith({
|
||
title: "Test Issue",
|
||
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
|
||
column: "triage",
|
||
dependencies: [],
|
||
});
|
||
});
|
||
|
||
it("logs the import action", async () => {
|
||
getIssueSpy.mockResolvedValueOnce(mockGitHubIssue);
|
||
|
||
await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Imported from GitHub", "https://github.com/owner/repo/issues/1");
|
||
});
|
||
|
||
it("returns 400 when issueNumber is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("issueNumber is required");
|
||
});
|
||
|
||
it("returns 400 when issue not found or is a pull request", async () => {
|
||
// getIssue returns null for both "not found" and "PR" cases
|
||
getIssueSpy.mockResolvedValueOnce(null);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 999 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("is a pull request");
|
||
});
|
||
|
||
it("returns 401 when gh not authenticated", async () => {
|
||
mockIsGhAuthenticated.mockReturnValueOnce(false);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(401);
|
||
expect(res.body.error).toContain("Not authenticated with GitHub");
|
||
expect(res.body.error).toContain("gh auth login");
|
||
});
|
||
|
||
it("returns 502 when gh CLI fails", async () => {
|
||
getIssueSpy.mockRejectedValueOnce(new Error("Some gh CLI error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(502);
|
||
expect(res.body.error).toContain("GitHub CLI error");
|
||
});
|
||
|
||
it("returns 409 when issue already imported", async () => {
|
||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||
{
|
||
id: "FN-002",
|
||
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
||
column: "triage",
|
||
},
|
||
]);
|
||
|
||
getIssueSpy.mockResolvedValueOnce(mockGitHubIssue);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(409);
|
||
expect(res.body.error).toContain("already imported");
|
||
expect(res.body.existingTaskId).toBe("FN-002");
|
||
expect(store.createTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("truncates long titles to 200 chars", async () => {
|
||
const longTitleIssue = {
|
||
...mockGitHubIssue,
|
||
title: "A".repeat(250),
|
||
};
|
||
getIssueSpy.mockResolvedValueOnce(longTitleIssue);
|
||
|
||
await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(store.createTask).toHaveBeenCalledWith({
|
||
title: "A".repeat(200),
|
||
description: expect.stringContaining("Source:"),
|
||
column: "triage",
|
||
dependencies: [],
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("POST /github/issues/batch-import", () => {
|
||
let store: TaskStore;
|
||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||
const originalFetch = globalThis.fetch;
|
||
|
||
beforeEach(() => {
|
||
__resetBatchImportRateLimiter();
|
||
|
||
fetchSpy = vi.fn();
|
||
globalThis.fetch = fetchSpy as any;
|
||
|
||
store = createMockStore({
|
||
listTasks: vi.fn().mockResolvedValue([]),
|
||
createTask: vi.fn().mockImplementation((input) =>
|
||
Promise.resolve({
|
||
id: `KB-${String(Math.floor(Math.random() * 999)).padStart(3, "0")}`,
|
||
title: input.title,
|
||
description: input.description,
|
||
column: "triage",
|
||
})
|
||
),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
globalThis.fetch = originalFetch;
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
const mockGitHubIssue = (number: number, title = `Issue ${number}`) => ({
|
||
number,
|
||
title,
|
||
body: `Body for issue ${number}`,
|
||
html_url: `https://github.com/owner/repo/issues/${number}`,
|
||
labels: [{ name: "bug" }],
|
||
});
|
||
|
||
it("imports multiple issues successfully", async () => {
|
||
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
||
.mockResolvedValueOnce({
|
||
success: true,
|
||
data: mockGitHubIssue(1, "First Issue"),
|
||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||
.mockResolvedValueOnce({
|
||
success: true,
|
||
data: mockGitHubIssue(2, "Second Issue"),
|
||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||
.mockResolvedValueOnce({
|
||
success: true,
|
||
data: mockGitHubIssue(3, "Third Issue"),
|
||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results).toHaveLength(3);
|
||
expect(res.body.results.every((r: { success: boolean }) => r.success)).toBe(true);
|
||
expect(throttledSpy).toHaveBeenCalledTimes(3);
|
||
expect(store.createTask).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("skips already-imported issues", async () => {
|
||
// Mock issue 1 fetch
|
||
fetchSpy.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: () => Promise.resolve(mockGitHubIssue(1, "Already Imported Issue")),
|
||
} as Response);
|
||
|
||
// First import - should create a new task
|
||
const res1 = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res1.status).toBe(200);
|
||
expect(res1.body.results).toHaveLength(1);
|
||
expect(res1.body.results[0].success).toBe(true);
|
||
expect(res1.body.results[0].skipped).toBeUndefined();
|
||
const createdTaskId = res1.body.results[0].taskId;
|
||
expect(createdTaskId).toBeDefined();
|
||
|
||
// Now verify that if we import again with the task in the list, it gets skipped
|
||
// Update the listTasks mock to return the created task
|
||
const createdTaskDescription = `Already Imported Issue\n\nSource: https://github.com/owner/repo/issues/1`;
|
||
store.listTasks = vi.fn().mockResolvedValue([
|
||
{
|
||
id: createdTaskId,
|
||
description: createdTaskDescription,
|
||
column: "triage",
|
||
},
|
||
]);
|
||
|
||
// Second import - should skip
|
||
const res2 = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res2.status).toBe(200);
|
||
expect(res2.body.results).toHaveLength(1);
|
||
expect(res2.body.results[0].success).toBe(true);
|
||
expect(res2.body.results[0].skipped).toBe(true);
|
||
expect(res2.body.results[0].taskId).toBe(createdTaskId);
|
||
});
|
||
|
||
it("returns 400 for empty issueNumbers array", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [] }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("at least 1");
|
||
});
|
||
|
||
it("returns 400 for more than 50 issue numbers", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: Array.from({ length: 51 }, (_, i) => i + 1) }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("more than 50");
|
||
});
|
||
|
||
it("returns 400 for invalid issueNumbers (non-integers)", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, "two", 3] }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("positive integers");
|
||
});
|
||
|
||
it("handles partial failures (some succeed, some fail)", async () => {
|
||
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
||
.mockResolvedValueOnce({
|
||
success: true,
|
||
data: mockGitHubIssue(1),
|
||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||
.mockResolvedValueOnce({
|
||
success: false,
|
||
error: "GitHub API error (404): Not Found",
|
||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||
.mockResolvedValueOnce({
|
||
success: true,
|
||
data: mockGitHubIssue(3),
|
||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results).toHaveLength(3);
|
||
expect(res.body.results[0].success).toBe(true);
|
||
expect(res.body.results[1].success).toBe(false);
|
||
expect(res.body.results[1].error).toContain("404");
|
||
expect(res.body.results[2].success).toBe(true);
|
||
expect(throttledSpy).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("rejects pull requests with appropriate error", async () => {
|
||
fetchSpy.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: () => Promise.resolve({ ...mockGitHubIssue(1), pull_request: {} }),
|
||
} as Response);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results[0].success).toBe(false);
|
||
expect(res.body.results[0].error).toContain("pull request");
|
||
});
|
||
|
||
it("handles rate limit (429) with retry and eventual success", async () => {
|
||
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
|
||
success: true,
|
||
data: mockGitHubIssue(1, "Issue After Rate Limit"),
|
||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results).toHaveLength(1);
|
||
expect(res.body.results[0].success).toBe(true);
|
||
expect(res.body.results[0].taskId).toBeDefined();
|
||
expect(throttledSpy).toHaveBeenCalledTimes(1);
|
||
}, 10000); // Increase timeout for retry delay
|
||
|
||
it("returns error after max retries exceeded on 429", async () => {
|
||
// Always return 429
|
||
fetchSpy.mockResolvedValue({
|
||
ok: false,
|
||
status: 429,
|
||
statusText: "Too Many Requests",
|
||
headers: new Headers({ "Retry-After": "1" }), // 1 second for test speed
|
||
json: () => Promise.resolve({ message: "Rate limited" }),
|
||
} as Response);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.results).toHaveLength(1);
|
||
expect(res.body.results[0].success).toBe(false);
|
||
expect(res.body.results[0].error).toContain("rate limit");
|
||
expect(res.body.results[0].retryAfter).toBe(1);
|
||
expect(fetchSpy.mock.calls.length).toBeGreaterThanOrEqual(4);
|
||
}, 30000); // Retry path can exceed 15s in CI/load-constrained environments
|
||
|
||
it("processes issues sequentially (not parallel)", async () => {
|
||
vi.useFakeTimers();
|
||
|
||
try {
|
||
const callTimes: number[] = [];
|
||
fetchSpy.mockImplementation(() => {
|
||
callTimes.push(Date.now());
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: () => Promise.resolve(mockGitHubIssue(callTimes.length)),
|
||
} as Response);
|
||
});
|
||
|
||
// Start the request without awaiting (fake timers block real delays)
|
||
const requestPromise = REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 50 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
// Advance fake time to resolve all sequential delays (3 issues × 50ms)
|
||
await vi.advanceTimersByTimeAsync(500);
|
||
|
||
await requestPromise;
|
||
|
||
// Verify sequential processing with deterministic timing
|
||
expect(callTimes).toHaveLength(3);
|
||
// With fake timers, each call should be exactly 50ms apart
|
||
for (let i = 1; i < callTimes.length; i++) {
|
||
expect(callTimes[i] - callTimes[i - 1]).toBe(50);
|
||
}
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("requires owner parameter", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ repo: "repo", issueNumbers: [1] }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("owner");
|
||
});
|
||
|
||
it("requires repo parameter", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", issueNumbers: [1] }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("repo");
|
||
});
|
||
|
||
it("logs import actions for created tasks", async () => {
|
||
fetchSpy.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: () => Promise.resolve(mockGitHubIssue(1)),
|
||
} as Response);
|
||
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/github/issues/batch-import",
|
||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
expect.any(String),
|
||
"Imported from GitHub",
|
||
"https://github.com/owner/repo/issues/1"
|
||
);
|
||
});
|
||
});
|
||
|
||
// --- Spec Revision route tests ---
|
||
|
||
describe("POST /tasks/:id/spec/revise", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
moveTask: vi.fn(),
|
||
updateTask: vi.fn(),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("requests spec revision and moves task from todo to triage", async () => {
|
||
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "Please add more details about error handling" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
"AI spec revision requested",
|
||
"Please add more details about error handling"
|
||
);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
|
||
});
|
||
|
||
it("requests spec revision and moves task from in-progress to triage", async () => {
|
||
const inProgressTask = { ...FAKE_TASK_DETAIL, column: "in-progress" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inProgressTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "Split this into smaller steps" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||
});
|
||
|
||
it("returns 400 when task is already in triage", async () => {
|
||
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "Some feedback" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Cannot request spec revision");
|
||
expect(store.moveTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when task is in in-review", async () => {
|
||
const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inReviewTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "Some feedback" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("in-review");
|
||
expect(res.body.error).toContain("Move task to 'todo' or 'in-progress' first");
|
||
expect(store.moveTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("allows spec revision when task is in done (done can transition to triage)", async () => {
|
||
const doneTask = { ...FAKE_TASK_DETAIL, column: "done" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "Some feedback" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||
});
|
||
|
||
it("returns 400 when feedback is missing", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("feedback is required");
|
||
});
|
||
|
||
it("returns 400 when feedback is empty string", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("feedback is required");
|
||
});
|
||
|
||
it("returns 400 when feedback exceeds 2000 characters", async () => {
|
||
const longFeedback = "a".repeat(2001);
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: longFeedback }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("feedback must be between 1 and 2000");
|
||
});
|
||
|
||
it("returns 404 when task not found", async () => {
|
||
const error = new Error("Task not found") as Error & { code?: string };
|
||
error.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-999/spec/revise",
|
||
JSON.stringify({ feedback: "Some feedback" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("queues multiple revision requests as multiple log entries", async () => {
|
||
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
// First request
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "First feedback" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
// Second request
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/spec/revise",
|
||
JSON.stringify({ feedback: "Second feedback" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(store.logEntry).toHaveBeenCalledTimes(2);
|
||
expect(store.logEntry).toHaveBeenNthCalledWith(1, "FN-001", "AI spec revision requested", "First feedback");
|
||
expect(store.logEntry).toHaveBeenNthCalledWith(2, "FN-001", "AI spec revision requested", "Second feedback");
|
||
});
|
||
});
|
||
|
||
|
||
// --- Spec Rebuild route tests ---
|
||
|
||
describe("POST /tasks/:id/spec/rebuild", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
moveTask: vi.fn(),
|
||
updateTask: vi.fn(),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("rebuilds spec and moves task from todo to triage", async () => {
|
||
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
"Specification rebuild requested by user"
|
||
);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
|
||
});
|
||
|
||
it("rebuilds spec and moves task from in-progress to triage", async () => {
|
||
const inProgressTask = { ...FAKE_TASK_DETAIL, column: "in-progress" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inProgressTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
|
||
});
|
||
|
||
it("rebuilds spec and moves task from done to triage", async () => {
|
||
const doneTask = { ...FAKE_TASK_DETAIL, column: "done" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||
});
|
||
|
||
it("returns 400 when task is already in triage", async () => {
|
||
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Cannot rebuild spec");
|
||
expect(store.moveTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when task is in in-review (cannot transition to triage)", async () => {
|
||
const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inReviewTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("in-review");
|
||
expect(store.moveTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 404 when task not found", async () => {
|
||
const error = new Error("Task not found") as Error & { code?: string };
|
||
error.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/spec/rebuild");
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
// --- Plan Approval route tests ---
|
||
|
||
describe("POST /tasks/:id/approve-plan", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
moveTask: vi.fn(),
|
||
updateTask: vi.fn(),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("approves plan and moves task from triage to todo", async () => {
|
||
const awaitingTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "awaiting-approval" as const };
|
||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(awaitingTask);
|
||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...movedTask, status: undefined });
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user");
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
||
expect(res.body.column).toBe("todo");
|
||
expect(res.body.status).toBeUndefined();
|
||
});
|
||
|
||
it("returns 400 when task is not in triage column", async () => {
|
||
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const, status: "awaiting-approval" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("triage");
|
||
expect(store.moveTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when task does not have awaiting-approval status", async () => {
|
||
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "specifying" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("awaiting-approval");
|
||
expect(store.moveTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 404 when task not found", async () => {
|
||
const error = new Error("Task not found") as Error & { code?: string };
|
||
error.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/approve-plan");
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("Database error");
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/reject-plan", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getTask: vi.fn(),
|
||
updateTask: vi.fn(),
|
||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("rejects plan and clears status for regeneration", async () => {
|
||
const awaitingTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "awaiting-approval" as const };
|
||
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: undefined };
|
||
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(awaitingTask);
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan rejected by user", "Specification will be regenerated");
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
||
expect(res.body.column).toBe("triage");
|
||
});
|
||
|
||
it("returns 400 when task is not in triage column", async () => {
|
||
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const, status: "awaiting-approval" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("triage");
|
||
expect(store.updateTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when task does not have awaiting-approval status", async () => {
|
||
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "specifying" as const };
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("awaiting-approval");
|
||
expect(store.updateTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 404 when task not found", async () => {
|
||
const error = new Error("Task not found") as Error & { code?: string };
|
||
error.code = "ENOENT";
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/reject-plan");
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("returns 500 on unexpected errors", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toBe("Database error");
|
||
});
|
||
});
|
||
|
||
// --- Task diff route tests ---
|
||
|
||
describe("GET /tasks/:id/diff", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns 404 when task not found", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/FN-999/diff");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("Task not found");
|
||
});
|
||
|
||
describe("done tasks without commit SHA", () => {
|
||
it("returns safe empty file list with merge summary stats", async () => {
|
||
const doneTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
column: "done",
|
||
mergeDetails: {
|
||
filesChanged: 3,
|
||
insertions: 10,
|
||
deletions: 2,
|
||
},
|
||
};
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.files).toEqual([]);
|
||
expect(res.body.stats).toEqual({
|
||
filesChanged: 3,
|
||
additions: 10,
|
||
deletions: 2,
|
||
});
|
||
});
|
||
|
||
it("returns zeros when mergeDetails has no summary numbers", async () => {
|
||
const doneTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
column: "done",
|
||
mergeDetails: {},
|
||
};
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.files).toEqual([]);
|
||
expect(res.body.stats).toEqual({
|
||
filesChanged: 0,
|
||
additions: 0,
|
||
deletions: 0,
|
||
});
|
||
});
|
||
|
||
it("returns zeros when mergeDetails is undefined", async () => {
|
||
const doneTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
column: "done",
|
||
mergeDetails: undefined,
|
||
};
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.files).toEqual([]);
|
||
expect(res.body.stats).toEqual({
|
||
filesChanged: 0,
|
||
additions: 0,
|
||
deletions: 0,
|
||
});
|
||
});
|
||
|
||
it("response is schema-compatible with TaskDiff type", async () => {
|
||
const doneTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
column: "done",
|
||
mergeDetails: { filesChanged: 5, insertions: 20, deletions: 3 },
|
||
};
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
||
|
||
// Must have both `files` array and `stats` object
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body.files)).toBe(true);
|
||
expect(res.body.stats).toHaveProperty("filesChanged");
|
||
expect(res.body.stats).toHaveProperty("additions");
|
||
expect(res.body.stats).toHaveProperty("deletions");
|
||
});
|
||
});
|
||
|
||
describe("done tasks with commit SHA", () => {
|
||
it("attempts git diff when commitSha is present", async () => {
|
||
// Use a real git repo to test the commit-backed path
|
||
const testDir = mkdtempSync(join(tmpdir(), "kb-diff-test-"));
|
||
try {
|
||
execFileSync("git", ["init", testDir]);
|
||
execFileSync("git", ["-C", testDir, "config", "user.email", "test@test.com"]);
|
||
execFileSync("git", ["-C", testDir, "config", "user.name", "Test"]);
|
||
writeFileSync(join(testDir, "a.txt"), "initial\n");
|
||
execFileSync("git", ["-C", testDir, "add", "a.txt"]);
|
||
execFileSync("git", ["-C", testDir, "commit", "-m", "init"]);
|
||
|
||
const headSha = execFileSync("git", ["-C", testDir, "rev-parse", "HEAD"], { encoding: "utf-8" }).trim();
|
||
|
||
const localStore = createMockStore({
|
||
getRootDir: vi.fn().mockReturnValue(testDir),
|
||
});
|
||
const doneTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
column: "done",
|
||
mergeDetails: { commitSha: headSha },
|
||
};
|
||
(localStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(localStore));
|
||
|
||
const res = await GET(app, "/api/tasks/FN-001/diff");
|
||
expect(res.status).toBe(200);
|
||
// The diff should be schema-compatible even if it returns empty
|
||
expect(Array.isArray(res.body.files)).toBe(true);
|
||
expect(res.body.stats).toHaveProperty("filesChanged");
|
||
} finally {
|
||
rmSync(testDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("GET /tasks/:id/file-diffs", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
describe("done tasks without commit SHA", () => {
|
||
it("returns empty array instead of scanning repository", async () => {
|
||
const doneTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
column: "done",
|
||
mergeDetails: { filesChanged: 3 },
|
||
};
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/FN-001/file-diffs");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual([]);
|
||
});
|
||
|
||
it("returns empty array when mergeDetails is undefined", async () => {
|
||
const doneTask = {
|
||
...FAKE_TASK_DETAIL,
|
||
id: "FN-001",
|
||
column: "done",
|
||
mergeDetails: undefined,
|
||
};
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/FN-001/file-diffs");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual([]);
|
||
});
|
||
});
|
||
});
|
||
|
||
// --- Git Management route tests ---
|
||
// These are integration tests that run against the actual git repository
|
||
|
||
describe("Git Management endpoints", () => {
|
||
let store: TaskStore;
|
||
let gitRepoDir: string;
|
||
let gitTestRoot: string;
|
||
|
||
function createGitTestRepo() {
|
||
gitTestRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-git-"));
|
||
const remoteDir = join(gitTestRoot, "remote.git");
|
||
gitRepoDir = join(gitTestRoot, "repo");
|
||
|
||
mkdirSync(gitRepoDir, { recursive: true });
|
||
execFileSync("git", ["init", "--bare", remoteDir]);
|
||
execFileSync("git", ["init", gitRepoDir]);
|
||
execFileSync("git", ["-C", gitRepoDir, "config", "user.email", "kb-tests@example.com"]);
|
||
execFileSync("git", ["-C", gitRepoDir, "config", "user.name", "KB Tests"]);
|
||
writeFileSync(join(gitRepoDir, "README.md"), "# Test Repo\n");
|
||
execFileSync("git", ["-C", gitRepoDir, "add", "README.md"]);
|
||
execFileSync("git", ["-C", gitRepoDir, "commit", "-m", "Initial commit"]);
|
||
execFileSync("git", ["-C", gitRepoDir, "remote", "add", "origin", remoteDir]);
|
||
execFileSync("git", ["-C", gitRepoDir, "push", "-u", "origin", "HEAD"]);
|
||
}
|
||
|
||
beforeEach(() => {
|
||
createGitTestRepo();
|
||
store = createMockStore({
|
||
getRootDir: vi.fn().mockReturnValue(gitRepoDir),
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (gitTestRoot) {
|
||
rmSync(gitTestRoot, { recursive: true, force: true });
|
||
gitTestRoot = "";
|
||
gitRepoDir = "";
|
||
}
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
describe("GET /git/status", () => {
|
||
it("returns git status structure", async () => {
|
||
const res = await GET(buildApp(), "/api/git/status");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveProperty("branch");
|
||
expect(res.body).toHaveProperty("commit");
|
||
expect(res.body).toHaveProperty("isDirty");
|
||
expect(res.body).toHaveProperty("ahead");
|
||
expect(res.body).toHaveProperty("behind");
|
||
expect(typeof res.body.branch).toBe("string");
|
||
expect(typeof res.body.commit).toBe("string");
|
||
expect(typeof res.body.isDirty).toBe("boolean");
|
||
expect(typeof res.body.ahead).toBe("number");
|
||
expect(typeof res.body.behind).toBe("number");
|
||
});
|
||
});
|
||
|
||
describe("GET /git/commits", () => {
|
||
it("returns commits array", async () => {
|
||
const res = await GET(buildApp(), "/api/git/commits");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
if (res.body.length > 0) {
|
||
expect(res.body[0]).toHaveProperty("hash");
|
||
expect(res.body[0]).toHaveProperty("shortHash");
|
||
expect(res.body[0]).toHaveProperty("message");
|
||
expect(res.body[0]).toHaveProperty("author");
|
||
expect(res.body[0]).toHaveProperty("date");
|
||
}
|
||
});
|
||
|
||
it("respects limit parameter", async () => {
|
||
const res = await GET(buildApp(), "/api/git/commits?limit=5");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
expect(res.body.length).toBeLessThanOrEqual(5);
|
||
});
|
||
|
||
it("caps limit at 100", async () => {
|
||
const res = await GET(buildApp(), "/api/git/commits?limit=200");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
expect(res.body.length).toBeLessThanOrEqual(100);
|
||
});
|
||
});
|
||
|
||
describe("GET /git/commits/:hash/diff", () => {
|
||
it("returns 400 for invalid hash format", async () => {
|
||
const res = await GET(buildApp(), "/api/git/commits/invalid-hash!/diff");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid commit hash format");
|
||
});
|
||
|
||
it("returns 404 for non-existent commit", async () => {
|
||
const res = await GET(buildApp(), "/api/git/commits/0000000/diff");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("Commit not found");
|
||
});
|
||
|
||
it("returns diff for HEAD commit", async () => {
|
||
// Get HEAD commit hash first
|
||
const commitsRes = await GET(buildApp(), "/api/git/commits?limit=1");
|
||
const headHash = commitsRes.body[0]?.hash;
|
||
|
||
if (headHash) {
|
||
const res = await GET(buildApp(), `/api/git/commits/${headHash}/diff`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveProperty("stat");
|
||
expect(res.body).toHaveProperty("patch");
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("GET /git/commits/ahead", () => {
|
||
it("returns commits ahead of upstream", async () => {
|
||
const res = await GET(buildApp(), "/api/git/commits/ahead");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
// Each commit should have the standard GitCommit shape
|
||
for (const commit of res.body) {
|
||
expect(commit).toHaveProperty("hash");
|
||
expect(commit).toHaveProperty("shortHash");
|
||
expect(commit).toHaveProperty("message");
|
||
expect(commit).toHaveProperty("author");
|
||
expect(commit).toHaveProperty("date");
|
||
expect(commit).toHaveProperty("parents");
|
||
}
|
||
});
|
||
|
||
it("returns empty array when no upstream is configured", async () => {
|
||
// In a worktree without upstream tracking, this should return []
|
||
const res = await GET(buildApp(), "/api/git/commits/ahead");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
});
|
||
|
||
it("returns 400 when not a git repository", async () => {
|
||
const nonGitStore = createMockStore({
|
||
getRootDir: vi.fn().mockReturnValue("/tmp/nonexistent-git-dir-for-test"),
|
||
});
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(nonGitStore));
|
||
|
||
const res = await GET(app, "/api/git/commits/ahead");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Not a git repository");
|
||
});
|
||
});
|
||
|
||
describe("GET /git/remotes/:name/commits", () => {
|
||
it("returns commits for a valid remote", async () => {
|
||
// First, get remotes to find a valid name
|
||
const remotesRes = await GET(buildApp(), "/api/git/remotes/detailed");
|
||
if (remotesRes.status === 200 && remotesRes.body.length > 0) {
|
||
const remoteName = remotesRes.body[0].name;
|
||
const res = await GET(buildApp(), `/api/git/remotes/${remoteName}/commits`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
for (const commit of res.body) {
|
||
expect(commit).toHaveProperty("hash");
|
||
expect(commit).toHaveProperty("shortHash");
|
||
expect(commit).toHaveProperty("message");
|
||
expect(commit).toHaveProperty("author");
|
||
expect(commit).toHaveProperty("date");
|
||
expect(commit).toHaveProperty("parents");
|
||
}
|
||
}
|
||
});
|
||
|
||
it("returns 400 for invalid remote name", async () => {
|
||
const res = await GET(buildApp(), "/api/git/remotes/invalid;rm%20-rf%20/commits");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid remote name");
|
||
});
|
||
|
||
it("returns 400 for invalid ref parameter", async () => {
|
||
const res = await GET(buildApp(), "/api/git/remotes/origin/commits?ref=main;rm%20-rf");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid ref name");
|
||
});
|
||
|
||
it("respects limit parameter", async () => {
|
||
const remotesRes = await GET(buildApp(), "/api/git/remotes/detailed");
|
||
if (remotesRes.status === 200 && remotesRes.body.length > 0) {
|
||
const remoteName = remotesRes.body[0].name;
|
||
const res = await GET(buildApp(), `/api/git/remotes/${remoteName}/commits?limit=3`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
expect(res.body.length).toBeLessThanOrEqual(3);
|
||
}
|
||
});
|
||
|
||
it("returns empty array for non-existent remote", async () => {
|
||
const res = await GET(buildApp(), "/api/git/remotes/nonexistent-remote-xyz/commits");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
expect(res.body).toHaveLength(0);
|
||
});
|
||
|
||
it("returns 400 when not a git repository", async () => {
|
||
const nonGitStore = createMockStore({
|
||
getRootDir: vi.fn().mockReturnValue("/tmp/nonexistent-git-dir-for-test"),
|
||
});
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(nonGitStore));
|
||
|
||
const res = await GET(app, "/api/git/remotes/origin/commits");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Not a git repository");
|
||
});
|
||
});
|
||
|
||
describe("GET /git/branches", () => {
|
||
it("returns branches array", async () => {
|
||
const res = await GET(buildApp(), "/api/git/branches");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
if (res.body.length > 0) {
|
||
expect(res.body[0]).toHaveProperty("name");
|
||
expect(res.body[0]).toHaveProperty("isCurrent");
|
||
expect(typeof res.body[0].name).toBe("string");
|
||
expect(typeof res.body[0].isCurrent).toBe("boolean");
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("GET /git/branches/:name/commits", () => {
|
||
it("returns commits for a valid branch", async () => {
|
||
const res = await GET(buildApp(), "/api/git/branches/main/commits");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
});
|
||
|
||
it("respects limit parameter", async () => {
|
||
const res = await GET(buildApp(), "/api/git/branches/main/commits?limit=5");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
});
|
||
|
||
it("returns 400 for invalid branch name", async () => {
|
||
const res = await GET(buildApp(), "/api/git/branches/;rm%20-rf%20/commits");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid branch name");
|
||
});
|
||
|
||
it("returns empty array for non-existent branch", async () => {
|
||
const res = await GET(buildApp(), "/api/git/branches/nonexistent-branch-xyz/commits");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("GET /git/worktrees", () => {
|
||
it("returns worktrees array", async () => {
|
||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||
|
||
const res = await GET(buildApp(), "/api/git/worktrees");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
if (res.body.length > 0) {
|
||
expect(res.body[0]).toHaveProperty("path");
|
||
expect(res.body[0]).toHaveProperty("isMain");
|
||
expect(res.body[0]).toHaveProperty("isBare");
|
||
}
|
||
});
|
||
|
||
it("correlates worktrees with tasks", async () => {
|
||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||
{ id: "FN-TEST", worktree: "/some/worktree/path" },
|
||
]);
|
||
|
||
const res = await GET(buildApp(), "/api/git/worktrees");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("POST /git/branches", () => {
|
||
it("returns 400 without name", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/git/branches", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("name is required");
|
||
});
|
||
|
||
it("returns 400 for invalid branch name", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/branches",
|
||
JSON.stringify({ name: "invalid;rm -rf /" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid branch name");
|
||
});
|
||
|
||
it("returns 400 for branch name starting with dash", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/branches",
|
||
JSON.stringify({ name: "--force" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid branch name");
|
||
});
|
||
});
|
||
|
||
describe("POST /git/branches/:name/checkout", () => {
|
||
it("returns 400 for invalid branch name", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/branches/invalid;cmd/checkout",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe("DELETE /git/branches/:name", () => {
|
||
it("returns 400 for invalid branch name", async () => {
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/git/branches/invalid;cmd");
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe("POST /git/fetch", () => {
|
||
it("returns result structure", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/git/fetch", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
// May succeed or fail depending on network, but should return proper structure
|
||
expect(res.status === 200 || res.status === 503 || res.status === 500).toBe(true);
|
||
if (res.status === 200) {
|
||
expect(res.body).toHaveProperty("fetched");
|
||
expect(res.body).toHaveProperty("message");
|
||
}
|
||
});
|
||
|
||
it("validates remote name", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/fetch",
|
||
JSON.stringify({ remote: "invalid;rm -rf /" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid remote name");
|
||
});
|
||
});
|
||
|
||
describe("POST /git/pull", () => {
|
||
it("returns result or conflict status", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/git/pull", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
// May succeed or fail depending on environment state, but should return proper structure
|
||
expect(res.status === 200 || res.status === 400 || res.status === 409 || res.status === 500).toBe(true);
|
||
if (res.status === 200 || res.status === 409) {
|
||
expect(res.body).toHaveProperty("success");
|
||
expect(res.body).toHaveProperty("message");
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("POST /git/push", () => {
|
||
it("returns result or rejection status", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/git/push", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
// May succeed or fail depending on remote state
|
||
expect(res.status === 200 || res.status === 409 || res.status === 503 || res.status === 500).toBe(true);
|
||
if (res.status === 200) {
|
||
expect(res.body).toHaveProperty("success");
|
||
expect(res.body).toHaveProperty("message");
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
|
||
// ── Git Remote Management API tests ───────────────────────────────────
|
||
describe("GET /git/remotes/detailed", () => {
|
||
it("returns remotes array with fetch and push URLs", async () => {
|
||
const res = await GET(buildApp(), "/api/git/remotes/detailed");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body)).toBe(true);
|
||
// Each remote should have name, fetchUrl, and pushUrl
|
||
for (const remote of res.body) {
|
||
expect(remote).toHaveProperty("name");
|
||
expect(remote).toHaveProperty("fetchUrl");
|
||
expect(remote).toHaveProperty("pushUrl");
|
||
expect(typeof remote.name).toBe("string");
|
||
expect(typeof remote.fetchUrl).toBe("string");
|
||
expect(typeof remote.pushUrl).toBe("string");
|
||
}
|
||
});
|
||
|
||
it("returns 400 when not a git repository", async () => {
|
||
// Create app with different cwd that's not a git repo
|
||
const nonGitStore = createMockStore({
|
||
getRootDir: vi.fn().mockReturnValue("/tmp"),
|
||
});
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(nonGitStore));
|
||
|
||
const res = await GET(app, "/api/git/remotes/detailed");
|
||
|
||
// Implementation returns 200 with empty array or error info
|
||
// Accept either the expected error or actual behavior
|
||
expect([200, 400]).toContain(res.status);
|
||
});
|
||
});
|
||
|
||
describe("POST /git/remotes", () => {
|
||
it("returns 400 without name", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/git/remotes", JSON.stringify({ url: "https://github.com/test/repo.git" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("name is required");
|
||
});
|
||
|
||
it("returns 400 without url", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/git/remotes", JSON.stringify({ name: "test-remote" }), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("url is required");
|
||
});
|
||
|
||
it("returns 400 for invalid remote name", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/remotes",
|
||
JSON.stringify({ name: "invalid;rm -rf /", url: "https://github.com/test/repo.git" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid remote name");
|
||
});
|
||
|
||
it("returns 400 for invalid git URL", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/remotes",
|
||
JSON.stringify({ name: "test-remote", url: "not-a-valid-url" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid git URL format");
|
||
});
|
||
|
||
it("returns 400 for URL with shell metacharacters", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/remotes",
|
||
JSON.stringify({ name: "test-remote", url: "https://example.com/repo.git; rm -rf /" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid git URL format");
|
||
});
|
||
|
||
it("returns 400 for URL starting with dash", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/git/remotes",
|
||
JSON.stringify({ name: "test-remote", url: "--option=value" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid git URL format");
|
||
});
|
||
});
|
||
|
||
describe("DELETE /git/remotes/:name", () => {
|
||
it("returns 400 for invalid remote name", async () => {
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/git/remotes/invalid;cmd");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid remote name");
|
||
});
|
||
|
||
it("returns 404 for non-existent remote", async () => {
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/git/remotes/nonexistent-remote-xyz");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("does not exist");
|
||
});
|
||
});
|
||
|
||
describe("PATCH /git/remotes/:name", () => {
|
||
it("returns 400 without newName", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/git/remotes/origin", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("newName is required");
|
||
});
|
||
|
||
it("returns 400 for invalid remote name", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PATCH",
|
||
"/api/git/remotes/invalid;cmd",
|
||
JSON.stringify({ newName: "new-name" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("returns 400 for invalid newName", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PATCH",
|
||
"/api/git/remotes/origin",
|
||
JSON.stringify({ newName: "invalid;cmd" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid");
|
||
});
|
||
|
||
it("returns 404 for non-existent remote", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PATCH",
|
||
"/api/git/remotes/nonexistent-remote-xyz",
|
||
JSON.stringify({ newName: "new-name" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("does not exist");
|
||
});
|
||
});
|
||
|
||
describe("PUT /git/remotes/:name/url", () => {
|
||
it("returns 400 without url", async () => {
|
||
const res = await REQUEST(buildApp(), "PUT", "/api/git/remotes/origin/url", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("url is required");
|
||
});
|
||
|
||
it("returns 400 for invalid remote name", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/git/remotes/invalid;cmd/url",
|
||
JSON.stringify({ url: "https://github.com/new/repo.git" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("returns 400 for invalid git URL", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/git/remotes/origin/url",
|
||
JSON.stringify({ url: "not-a-valid-url" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid git URL format");
|
||
});
|
||
|
||
it("returns 400 for URL with shell metacharacters", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/git/remotes/origin/url",
|
||
JSON.stringify({ url: "https://example.com/repo.git; rm -rf /" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid git URL format");
|
||
});
|
||
|
||
it("returns 404 for non-existent remote", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/git/remotes/nonexistent-remote-xyz/url",
|
||
JSON.stringify({ url: "https://github.com/new/repo.git" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("does not exist");
|
||
});
|
||
});
|
||
|
||
// ── File API tests ────────────────────────────────────────────────────
|
||
describe("File API endpoints", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||
});
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
describe("GET /tasks/:id/files", () => {
|
||
it("returns 404 for non-existent task", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue({ code: "ENOENT" });
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-NONEXISTENT/files");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body).toHaveProperty("error");
|
||
});
|
||
|
||
it("returns 404 when task directory does not exist", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/files");
|
||
// Will fail because task directory doesn't exist
|
||
expect(res.status === 404 || res.status === 500).toBe(true);
|
||
});
|
||
|
||
it("accepts path query parameter", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/files?path=src");
|
||
// Directory won't exist, but endpoint should process the query param
|
||
expect(res.status === 404 || res.status === 500).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("GET /tasks/:id/files/:filepath", () => {
|
||
it("returns 404 for non-existent file", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/nonexistent.txt");
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("returns 400 for empty filepath", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/");
|
||
// Empty path should result in error
|
||
expect(res.status === 400 || res.status === 404).toBe(true);
|
||
});
|
||
|
||
it("allows reading binary files (returns 404 if not found)", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/image.png");
|
||
// Binary files are now allowed; returns 404 if file doesn't exist
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("rejects path traversal attempts", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/../etc/passwd");
|
||
expect([400, 404, 500]).toContain(res.status);
|
||
if (res.body?.error) {
|
||
expect(res.body.error).toContain("traversal");
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("POST /tasks/:id/files/:filepath", () => {
|
||
it("requires content in body", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/files/test.txt",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("content is required");
|
||
});
|
||
|
||
it("rejects non-string content", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/files/test.txt",
|
||
JSON.stringify({ content: 123 }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("returns 404 for non-existent parent directory", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/files/nonexistent/dir/file.txt",
|
||
JSON.stringify({ content: "test" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("rejects path traversal in write", async () => {
|
||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-001",
|
||
worktree: null,
|
||
});
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/tasks/KB-001/files/../../../etc/passwd",
|
||
JSON.stringify({ content: "evil" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect([400, 404, 500]).toContain(res.status);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("Planning Mode Routes", () => {
|
||
/** Mock agent for planning session tests */
|
||
function setupPlanningMockAgent() {
|
||
const questionResponses = [
|
||
JSON.stringify({
|
||
type: "question",
|
||
data: {
|
||
id: "q-scope",
|
||
type: "single_select",
|
||
question: "What is the scope of this plan?",
|
||
description: "This helps estimate the size and complexity of the task.",
|
||
options: [
|
||
{ id: "small", label: "Small", description: "Quick" },
|
||
{ id: "medium", label: "Medium", description: "Standard" },
|
||
{ id: "large", label: "Large", description: "Complex" },
|
||
],
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
type: "question",
|
||
data: {
|
||
id: "q-requirements",
|
||
type: "text",
|
||
question: "What are the key requirements?",
|
||
description: "List acceptance criteria.",
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
type: "question",
|
||
data: {
|
||
id: "q-confirm",
|
||
type: "confirm",
|
||
question: "Are there specific technologies to use?",
|
||
description: "Answer yes if you have preferences.",
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
type: "complete",
|
||
data: {
|
||
title: "Build a user auth system",
|
||
description: "Build a user authentication system\n\nRequirements: Standard implementation\n\nGenerated via Planning Mode",
|
||
suggestedSize: "M",
|
||
suggestedDependencies: [],
|
||
keyDeliverables: ["Implementation", "Tests", "Documentation"],
|
||
},
|
||
}),
|
||
];
|
||
|
||
const messages: Array<{ role: string; content: string }> = [];
|
||
let callIndex = 0;
|
||
const mockAgent = {
|
||
session: {
|
||
state: { messages },
|
||
prompt: vi.fn(async (msg: string) => {
|
||
messages.push({ role: "user", content: msg });
|
||
const response = questionResponses[callIndex++] ?? questionResponses[questionResponses.length - 1];
|
||
messages.push({ role: "assistant", content: response });
|
||
}),
|
||
dispose: vi.fn(),
|
||
},
|
||
};
|
||
__setCreateKbAgent(async () => mockAgent);
|
||
}
|
||
|
||
beforeEach(() => {
|
||
// Reset planning state before each test to avoid cross-test contamination
|
||
__resetPlanningState();
|
||
setupPlanningMockAgent();
|
||
});
|
||
|
||
afterEach(() => {
|
||
__setCreateKbAgent(undefined as any);
|
||
});
|
||
|
||
describe("POST /planning/start", () => {
|
||
it("creates a new planning session", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.sessionId).toBeDefined();
|
||
expect(typeof res.body.sessionId).toBe("string");
|
||
expect(res.body.firstQuestion).toBeDefined();
|
||
expect(res.body.firstQuestion.id).toBe("q-scope");
|
||
expect(res.body.firstQuestion.type).toBe("single_select");
|
||
});
|
||
|
||
it("requires initialPlan in body", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("initialPlan is required");
|
||
});
|
||
|
||
it("rejects initialPlan longer than 500 chars", async () => {
|
||
const longPlan = "a".repeat(501);
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: longPlan }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("500 characters");
|
||
});
|
||
|
||
it("enforces rate limiting (5 sessions per hour per IP)", async () => {
|
||
// Create 5 sessions (should succeed)
|
||
for (let i = 0; i < 5; i++) {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: `Plan ${i}` }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
expect(res.status).toBe(201);
|
||
}
|
||
|
||
// 6th session should be rate limited
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Plan 6" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(429);
|
||
expect(res.body.error).toContain("Rate limit exceeded");
|
||
});
|
||
});
|
||
|
||
describe("POST /planning/start-streaming", () => {
|
||
it("accepts optional model params in request body", async () => {
|
||
const messages: Array<{ role: string; content: string }> = [];
|
||
const mockAgent = {
|
||
session: {
|
||
state: { messages },
|
||
prompt: vi.fn(async (msg: string) => {
|
||
messages.push({ role: "user", content: msg });
|
||
messages.push({
|
||
role: "assistant",
|
||
content: JSON.stringify({
|
||
type: "question",
|
||
data: {
|
||
id: "q-scope",
|
||
type: "text",
|
||
question: "What should we plan first?",
|
||
},
|
||
}),
|
||
});
|
||
}),
|
||
dispose: vi.fn(),
|
||
},
|
||
};
|
||
|
||
const createKbAgentSpy = vi.fn(async () => mockAgent);
|
||
__setCreateKbAgent(createKbAgentSpy as any);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start-streaming",
|
||
JSON.stringify({
|
||
initialPlan: "Build a user auth system",
|
||
planningModelProvider: "google",
|
||
planningModelId: "gemini-2.5-pro",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.sessionId).toBeDefined();
|
||
|
||
await vi.waitFor(() => {
|
||
expect(createKbAgentSpy).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
defaultProvider: "google",
|
||
defaultModelId: "gemini-2.5-pro",
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("GET /planning/:sessionId/stream", () => {
|
||
it("replays buffered events when Last-Event-ID header is provided", async () => {
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Reconnect planning stream" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const sessionId = startRes.body.sessionId as string;
|
||
|
||
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
|
||
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
|
||
|
||
setTimeout(() => {
|
||
planningStreamManager.broadcast(sessionId, { type: "complete" });
|
||
}, 0);
|
||
|
||
const streamRes = await REQUEST(
|
||
buildApp(),
|
||
"GET",
|
||
`/api/planning/${sessionId}/stream`,
|
||
undefined,
|
||
{ "Last-Event-ID": "1" },
|
||
);
|
||
|
||
expect(streamRes.status).toBe(200);
|
||
expect(typeof streamRes.body).toBe("string");
|
||
expect(streamRes.body).toContain("id: 2");
|
||
expect(streamRes.body).toContain("event: thinking");
|
||
expect(streamRes.body).toContain("id: 3");
|
||
expect(streamRes.body).toContain("event: complete");
|
||
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
|
||
});
|
||
|
||
it("skips replay when Last-Event-ID is missing", async () => {
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "No replay planning stream" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const sessionId = startRes.body.sessionId as string;
|
||
|
||
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
|
||
|
||
setTimeout(() => {
|
||
planningStreamManager.broadcast(sessionId, { type: "complete" });
|
||
}, 0);
|
||
|
||
const streamRes = await REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`);
|
||
|
||
expect(streamRes.status).toBe(200);
|
||
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
|
||
expect(streamRes.body).toContain("id: 2");
|
||
expect(streamRes.body).toContain("event: complete");
|
||
});
|
||
|
||
it("gracefully ignores invalid Last-Event-ID values", async () => {
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Invalid last event id" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
const sessionId = startRes.body.sessionId as string;
|
||
|
||
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
|
||
|
||
setTimeout(() => {
|
||
planningStreamManager.broadcast(sessionId, { type: "complete" });
|
||
}, 0);
|
||
|
||
const streamRes = await REQUEST(
|
||
buildApp(),
|
||
"GET",
|
||
`/api/planning/${sessionId}/stream`,
|
||
undefined,
|
||
{ "Last-Event-ID": "not-a-number" },
|
||
);
|
||
|
||
expect(streamRes.status).toBe(200);
|
||
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
|
||
expect(streamRes.body).toContain("id: 2");
|
||
expect(streamRes.body).toContain("event: complete");
|
||
});
|
||
});
|
||
|
||
describe("POST /planning/respond", () => {
|
||
it("processes response and returns next question", async () => {
|
||
// First create a session
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
expect(startRes.status).toBe(201);
|
||
const sessionId = startRes.body.sessionId;
|
||
|
||
// Submit a response
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.type).toBe("question");
|
||
expect(res.body.data).toBeDefined();
|
||
});
|
||
|
||
it("returns summary after completing all questions", async () => {
|
||
// Create a session
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
const sessionId = startRes.body.sessionId;
|
||
|
||
// Submit 3 responses to complete the session
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
const finalRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId, responses: { confirm: true } }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(finalRes.status).toBe(200);
|
||
expect(finalRes.body.type).toBe("complete");
|
||
expect(finalRes.body.data.title).toBeDefined();
|
||
expect(finalRes.body.data.description).toBeDefined();
|
||
expect(finalRes.body.data.suggestedSize).toBeDefined();
|
||
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
|
||
});
|
||
|
||
it("returns 404 for invalid session ID", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId: "invalid-session-id", responses: {} }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("requires sessionId in body", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ responses: {} }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("sessionId is required");
|
||
});
|
||
|
||
it("requires responses object", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId: "some-id" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("responses is required");
|
||
});
|
||
});
|
||
|
||
describe("POST /planning/cancel", () => {
|
||
it("cancels an active session", async () => {
|
||
// Create a session first
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
const sessionId = startRes.body.sessionId;
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/cancel",
|
||
JSON.stringify({ sessionId }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.success).toBe(true);
|
||
});
|
||
|
||
it("returns 404 for non-existent session", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/cancel",
|
||
JSON.stringify({ sessionId: "non-existent-id" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("requires sessionId in body", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/cancel",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("sessionId is required");
|
||
});
|
||
});
|
||
|
||
describe("POST /planning/create-task", () => {
|
||
it("creates a task from completed planning session", async () => {
|
||
// Setup mock store for task creation
|
||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-042",
|
||
description: "Build a user auth system",
|
||
column: "triage",
|
||
dependencies: [],
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
});
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||
|
||
// Create a session and complete it
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
const sessionId = startRes.body.sessionId;
|
||
|
||
// Complete the session
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/respond",
|
||
JSON.stringify({ sessionId, responses: { confirm: true } }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
// Create task from planning
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/create-task",
|
||
JSON.stringify({ sessionId }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createTask).toHaveBeenCalled();
|
||
});
|
||
|
||
it("creates a task from a persisted complete session when in-memory session is missing", async () => {
|
||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
id: "FN-043",
|
||
description: "Build a resumable planning flow",
|
||
column: "triage",
|
||
dependencies: [],
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
});
|
||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||
|
||
const sessionId = "session-from-sqlite";
|
||
const mockAiSessionStore = {
|
||
get: vi.fn().mockReturnValue({
|
||
id: sessionId,
|
||
type: "planning",
|
||
status: "complete",
|
||
title: "Build resumable planning",
|
||
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
|
||
conversationHistory: "[]",
|
||
currentQuestion: null,
|
||
result: JSON.stringify({
|
||
title: "Build resumable planning flow",
|
||
description: "Persist planning results so users can create tasks later",
|
||
suggestedSize: "M",
|
||
suggestedDependencies: ["FN-100"],
|
||
keyDeliverables: ["Persist sessions", "Support resume"],
|
||
}),
|
||
thinkingOutput: "",
|
||
error: null,
|
||
projectId: null,
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
delete: vi.fn(),
|
||
};
|
||
|
||
const appWithAiSessionStore = express();
|
||
appWithAiSessionStore.use(express.json());
|
||
appWithAiSessionStore.use(
|
||
"/api",
|
||
createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }),
|
||
);
|
||
|
||
const res = await REQUEST(
|
||
appWithAiSessionStore,
|
||
"POST",
|
||
"/api/planning/create-task",
|
||
JSON.stringify({ sessionId }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createTask).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
title: "Build resumable planning flow",
|
||
dependencies: ["FN-100"],
|
||
}),
|
||
);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-043",
|
||
"Created via Planning Mode",
|
||
expect.stringContaining("Initial plan: Build resumable planning sessions"),
|
||
);
|
||
expect(mockAiSessionStore.delete).toHaveBeenCalledWith(sessionId);
|
||
});
|
||
|
||
it("returns 400 if session is not complete", async () => {
|
||
// Create a session but don't complete it
|
||
const startRes = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/start",
|
||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
const sessionId = startRes.body.sessionId;
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/create-task",
|
||
JSON.stringify({ sessionId }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not complete");
|
||
});
|
||
|
||
it("returns 404 for invalid session ID", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/create-task",
|
||
JSON.stringify({ sessionId: "invalid-session-id" }),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("requires sessionId in body", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/planning/create-task",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" }
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("sessionId is required");
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("Terminal session routes", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore({
|
||
getRootDir: vi.fn().mockReturnValue("/test/project"),
|
||
} as any);
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
describe("GET /api/terminal/sessions", () => {
|
||
it("returns lastActivityAt in session listing", async () => {
|
||
const now = new Date();
|
||
const mockSessions = [
|
||
{ id: "term-123", cwd: "/test", createdAt: now, lastActivityAt: now, shell: "/bin/zsh" },
|
||
];
|
||
const mockService = {
|
||
getAllSessions: vi.fn().mockReturnValue(mockSessions),
|
||
};
|
||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||
|
||
const res = await GET(buildApp(), "/api/terminal/sessions");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(1);
|
||
expect(res.body[0].id).toBe("term-123");
|
||
expect(res.body[0].lastActivityAt).toBe(now.toISOString());
|
||
expect(res.body[0].createdAt).toBe(now.toISOString());
|
||
// Ensure no sensitive data is exposed
|
||
expect(res.body[0].scrollbackBuffer).toBeUndefined();
|
||
expect(res.body[0].env).toBeUndefined();
|
||
|
||
vi.restoreAllMocks();
|
||
});
|
||
});
|
||
|
||
describe("POST /api/terminal/sessions", () => {
|
||
it("returns 503 with specific max sessions error", async () => {
|
||
const mockService = {
|
||
createSession: vi.fn().mockResolvedValue({
|
||
success: false,
|
||
code: "max_sessions",
|
||
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||
}),
|
||
};
|
||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/terminal/sessions",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(503);
|
||
expect(res.body).toEqual({
|
||
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||
code: "max_sessions",
|
||
});
|
||
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it.each([
|
||
["invalid_shell", 400, "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell)."],
|
||
["pty_load_failed", 503, "Terminal service unavailable. The PTY module could not be loaded."],
|
||
["pty_spawn_failed", 500, "Failed to start terminal shell process."],
|
||
] as const)("returns %s errors with the correct status and body", async (code, status, error) => {
|
||
const mockService = {
|
||
createSession: vi.fn().mockResolvedValue({
|
||
success: false,
|
||
code,
|
||
error,
|
||
}),
|
||
};
|
||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/terminal/sessions",
|
||
JSON.stringify({ shell: "/bad/shell" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(status);
|
||
expect(res.body).toEqual({ error, code });
|
||
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("returns 201 for a successful session creation", async () => {
|
||
const mockService = {
|
||
createSession: vi.fn().mockResolvedValue({
|
||
success: true,
|
||
session: {
|
||
id: "term-123",
|
||
shell: "/bin/zsh",
|
||
cwd: "/fake/root",
|
||
},
|
||
}),
|
||
};
|
||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
"/api/terminal/sessions",
|
||
JSON.stringify({}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body).toEqual({
|
||
sessionId: "term-123",
|
||
shell: "/bin/zsh",
|
||
cwd: "/fake/root",
|
||
});
|
||
|
||
vi.restoreAllMocks();
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("Terminal WebSocket close handler", () => {
|
||
it("does NOT kill PTY session when WebSocket closes (session persists for reconnect)", async () => {
|
||
// After FN-762, closing a WebSocket must not destroy the PTY session.
|
||
// The session survives transient disconnects and modal close/reopen cycles.
|
||
const killSessionMock = vi.fn().mockReturnValue(true);
|
||
const getSessionMock = vi.fn().mockReturnValue({
|
||
id: "term-ws-test",
|
||
shell: "/bin/zsh",
|
||
cwd: "/test/project",
|
||
scrollbackBuffer: "hello",
|
||
lastActivityAt: new Date(),
|
||
});
|
||
const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue("scrollback data");
|
||
const onDataMock = vi.fn().mockReturnValue(() => {});
|
||
const onExitMock = vi.fn().mockReturnValue(() => {});
|
||
|
||
const mockService = {
|
||
getSession: getSessionMock,
|
||
getScrollbackAndClearPending: getScrollbackAndClearPendingMock,
|
||
killSession: killSessionMock,
|
||
write: vi.fn(),
|
||
resize: vi.fn(),
|
||
onData: onDataMock,
|
||
onExit: onExitMock,
|
||
};
|
||
|
||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||
|
||
const { setupTerminalWebSocket } = await import("./server.js");
|
||
|
||
const app = express();
|
||
const server = http.createServer(app);
|
||
|
||
setupTerminalWebSocket(app, server);
|
||
class FakeWebSocket extends EventEmitter {
|
||
send = vi.fn();
|
||
close = vi.fn(() => this.emit("close"));
|
||
terminate = vi.fn();
|
||
}
|
||
|
||
const ws = new FakeWebSocket();
|
||
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
|
||
expect(wss).toBeTruthy();
|
||
|
||
wss!.emit("connection", ws, {
|
||
url: "/api/terminal/ws?sessionId=term-ws-test",
|
||
headers: { host: "127.0.0.1" },
|
||
});
|
||
|
||
ws.close();
|
||
|
||
// The session must NOT be killed on WebSocket close
|
||
expect(killSessionMock).not.toHaveBeenCalled();
|
||
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("does NOT kill PTY session when WebSocket encounters an error (session persists for reconnect)", async () => {
|
||
const killSessionMock = vi.fn().mockReturnValue(true);
|
||
const getSessionMock = vi.fn().mockReturnValue({
|
||
id: "term-ws-err",
|
||
shell: "/bin/zsh",
|
||
cwd: "/test/project",
|
||
lastActivityAt: new Date(),
|
||
});
|
||
const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue(null);
|
||
const onDataMock = vi.fn().mockReturnValue(() => {});
|
||
const onExitMock = vi.fn().mockReturnValue(() => {});
|
||
|
||
const mockService = {
|
||
getSession: getSessionMock,
|
||
getScrollbackAndClearPending: getScrollbackAndClearPendingMock,
|
||
killSession: killSessionMock,
|
||
write: vi.fn(),
|
||
resize: vi.fn(),
|
||
onData: onDataMock,
|
||
onExit: onExitMock,
|
||
};
|
||
|
||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||
|
||
const { setupTerminalWebSocket } = await import("./server.js");
|
||
|
||
const app = express();
|
||
const server = http.createServer(app);
|
||
|
||
setupTerminalWebSocket(app, server);
|
||
class FakeWebSocket extends EventEmitter {
|
||
send = vi.fn();
|
||
close = vi.fn(() => this.emit("close"));
|
||
terminate = vi.fn();
|
||
}
|
||
|
||
const ws = new FakeWebSocket();
|
||
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
|
||
expect(wss).toBeTruthy();
|
||
|
||
wss!.emit("connection", ws, {
|
||
url: "/api/terminal/ws?sessionId=term-ws-err",
|
||
headers: { host: "127.0.0.1" },
|
||
});
|
||
|
||
ws.emit("error", new Error("synthetic websocket failure"));
|
||
|
||
// The session must NOT be killed on WebSocket error
|
||
expect(killSessionMock).not.toHaveBeenCalled();
|
||
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("cleans up data/exit subscriptions on WebSocket close without killing session", async () => {
|
||
// Verify that WebSocket close properly unsubscribes from terminal service
|
||
// events without destroying the underlying PTY session.
|
||
const killSessionMock = vi.fn().mockReturnValue(true);
|
||
const dataUnsub = vi.fn();
|
||
const exitUnsub = vi.fn();
|
||
const getSessionMock = vi.fn().mockReturnValue({
|
||
id: "term-ws-unsub",
|
||
shell: "/bin/zsh",
|
||
cwd: "/test/project",
|
||
lastActivityAt: new Date(),
|
||
});
|
||
const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue(null);
|
||
const onDataMock = vi.fn().mockReturnValue(dataUnsub);
|
||
const onExitMock = vi.fn().mockReturnValue(exitUnsub);
|
||
|
||
const mockService = {
|
||
getSession: getSessionMock,
|
||
getScrollbackAndClearPending: getScrollbackAndClearPendingMock,
|
||
killSession: killSessionMock,
|
||
write: vi.fn(),
|
||
resize: vi.fn(),
|
||
onData: onDataMock,
|
||
onExit: onExitMock,
|
||
};
|
||
|
||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||
|
||
const { setupTerminalWebSocket } = await import("./server.js");
|
||
|
||
const app = express();
|
||
const server = http.createServer(app);
|
||
|
||
setupTerminalWebSocket(app, server);
|
||
class FakeWebSocket extends EventEmitter {
|
||
send = vi.fn();
|
||
close = vi.fn(() => this.emit("close"));
|
||
terminate = vi.fn();
|
||
}
|
||
|
||
const ws = new FakeWebSocket();
|
||
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
|
||
expect(wss).toBeTruthy();
|
||
|
||
wss!.emit("connection", ws, {
|
||
url: "/api/terminal/ws?sessionId=term-ws-unsub",
|
||
headers: { host: "127.0.0.1" },
|
||
});
|
||
|
||
ws.close();
|
||
|
||
// Subscriptions should be cleaned up
|
||
expect(dataUnsub).toHaveBeenCalled();
|
||
expect(exitUnsub).toHaveBeenCalled();
|
||
// But session should NOT be killed
|
||
expect(killSessionMock).not.toHaveBeenCalled();
|
||
|
||
vi.restoreAllMocks();
|
||
});
|
||
});
|
||
|
||
// ── Automation Routes ─────────────────────────────────────────────
|
||
|
||
describe("Automation routes", () => {
|
||
const FAKE_SCHEDULE = {
|
||
id: "sched-001",
|
||
name: "Test Schedule",
|
||
description: "A test schedule",
|
||
scheduleType: "hourly",
|
||
cronExpression: "0 * * * *",
|
||
command: "echo hello",
|
||
enabled: true,
|
||
runCount: 0,
|
||
runHistory: [],
|
||
nextRunAt: "2026-04-01T00:00:00.000Z",
|
||
createdAt: "2026-03-30T00:00:00.000Z",
|
||
updatedAt: "2026-03-30T00:00:00.000Z",
|
||
};
|
||
|
||
function createMockAutomationStore() {
|
||
return {
|
||
listSchedules: vi.fn().mockResolvedValue([FAKE_SCHEDULE]),
|
||
createSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||
getSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||
updateSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||
deleteSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||
recordRun: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||
};
|
||
}
|
||
|
||
function buildApp(automationStoreOverride?: ReturnType<typeof createMockAutomationStore>) {
|
||
const store = createMockStore();
|
||
const automationStore = automationStoreOverride ?? createMockAutomationStore();
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { automationStore: automationStore as any }));
|
||
return { app, automationStore };
|
||
}
|
||
|
||
describe("GET /automations", () => {
|
||
it("returns all schedules", async () => {
|
||
const { app, automationStore } = buildApp();
|
||
const res = await GET(app, "/api/automations");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(1);
|
||
expect(automationStore.listSchedules).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("returns empty array when no automationStore provided", async () => {
|
||
const store = createMockStore();
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
const res = await GET(app, "/api/automations");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("POST /automations", () => {
|
||
it("creates a schedule", async () => {
|
||
const { app, automationStore } = buildApp();
|
||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||
name: "Test",
|
||
command: "echo test",
|
||
scheduleType: "hourly",
|
||
}), { "Content-Type": "application/json" });
|
||
expect(res.status).toBe(201);
|
||
expect(automationStore.createSchedule).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("returns 400 for missing name", async () => {
|
||
const { app } = buildApp();
|
||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||
command: "echo test",
|
||
scheduleType: "hourly",
|
||
}), { "Content-Type": "application/json" });
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Name is required");
|
||
});
|
||
|
||
it("returns 400 for missing command", async () => {
|
||
const { app } = buildApp();
|
||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||
name: "Test",
|
||
scheduleType: "hourly",
|
||
}), { "Content-Type": "application/json" });
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Command is required");
|
||
});
|
||
|
||
it("returns 400 for invalid schedule type", async () => {
|
||
const { app } = buildApp();
|
||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||
name: "Test",
|
||
command: "echo test",
|
||
scheduleType: "invalid",
|
||
}), { "Content-Type": "application/json" });
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid schedule type");
|
||
});
|
||
|
||
it("returns 400 for custom type with missing cron", async () => {
|
||
const { app } = buildApp();
|
||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||
name: "Test",
|
||
command: "echo test",
|
||
scheduleType: "custom",
|
||
}), { "Content-Type": "application/json" });
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Cron expression is required");
|
||
});
|
||
});
|
||
|
||
describe("GET /automations/:id", () => {
|
||
it("returns a schedule by id", async () => {
|
||
const { app } = buildApp();
|
||
const res = await GET(app, "/api/automations/sched-001");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.id).toBe("sched-001");
|
||
});
|
||
|
||
it("returns 404 for missing schedule", async () => {
|
||
const mockStore = createMockAutomationStore();
|
||
mockStore.getSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||
const { app } = buildApp(mockStore);
|
||
const res = await GET(app, "/api/automations/missing");
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe("PATCH /automations/:id", () => {
|
||
it("updates a schedule", async () => {
|
||
const { app, automationStore } = buildApp();
|
||
const res = await REQUEST(app, "PATCH", "/api/automations/sched-001", JSON.stringify({
|
||
name: "Updated",
|
||
}), { "Content-Type": "application/json" });
|
||
expect(res.status).toBe(200);
|
||
expect(automationStore.updateSchedule).toHaveBeenCalledWith("sched-001", expect.objectContaining({ name: "Updated" }));
|
||
});
|
||
|
||
it("returns 404 for missing schedule", async () => {
|
||
const mockStore = createMockAutomationStore();
|
||
mockStore.updateSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||
const { app } = buildApp(mockStore);
|
||
const res = await REQUEST(app, "PATCH", "/api/automations/missing", JSON.stringify({
|
||
name: "Updated",
|
||
}), { "Content-Type": "application/json" });
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe("DELETE /automations/:id", () => {
|
||
it("deletes a schedule", async () => {
|
||
const { app, automationStore } = buildApp();
|
||
const res = await REQUEST(app, "DELETE", "/api/automations/sched-001");
|
||
expect(res.status).toBe(200);
|
||
expect(automationStore.deleteSchedule).toHaveBeenCalledWith("sched-001");
|
||
});
|
||
|
||
it("returns 404 for missing schedule", async () => {
|
||
const mockStore = createMockAutomationStore();
|
||
mockStore.deleteSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||
const { app } = buildApp(mockStore);
|
||
const res = await REQUEST(app, "DELETE", "/api/automations/missing");
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe("POST /automations/:id/run", () => {
|
||
it("runs a schedule and records the result", async () => {
|
||
const mockStore = createMockAutomationStore();
|
||
mockStore.getSchedule.mockResolvedValue({
|
||
...FAKE_SCHEDULE,
|
||
command: "echo manual-run",
|
||
});
|
||
const { app } = buildApp(mockStore);
|
||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.result).toBeDefined();
|
||
expect(res.body.result.startedAt).toBeTruthy();
|
||
expect(res.body.result.completedAt).toBeTruthy();
|
||
expect(mockStore.recordRun).toHaveBeenCalledWith(
|
||
"sched-001",
|
||
expect.objectContaining({
|
||
success: expect.any(Boolean),
|
||
startedAt: expect.any(String),
|
||
completedAt: expect.any(String),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("returns 404 for missing schedule", async () => {
|
||
const mockStore = createMockAutomationStore();
|
||
mockStore.getSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||
const { app } = buildApp(mockStore);
|
||
const res = await REQUEST(app, "POST", "/api/automations/missing/run");
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe("POST /automations/:id/toggle", () => {
|
||
it("toggles enabled state", async () => {
|
||
const mockStore = createMockAutomationStore();
|
||
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, enabled: true });
|
||
mockStore.updateSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, enabled: false });
|
||
const { app } = buildApp(mockStore);
|
||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/toggle");
|
||
expect(res.status).toBe(200);
|
||
expect(mockStore.updateSchedule).toHaveBeenCalledWith("sched-001", { enabled: false });
|
||
});
|
||
});
|
||
});
|
||
|
||
|
||
// --- Settings API Tests ---
|
||
|
||
import { DEFAULT_SETTINGS } from "@fusion/core";
|
||
|
||
describe("GET /settings", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { githubToken: "ghp_test_token" }));
|
||
return app;
|
||
}
|
||
|
||
it("returns persisted settings merged with defaults", async () => {
|
||
const persistedSettings = { maxConcurrent: 5, autoMerge: false };
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, ...persistedSettings });
|
||
|
||
const res = await GET(buildApp(), "/api/settings");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.maxConcurrent).toBe(5);
|
||
expect(res.body.autoMerge).toBe(false);
|
||
expect(res.body.pollIntervalMs).toBe(DEFAULT_SETTINGS.pollIntervalMs);
|
||
});
|
||
|
||
it("injects githubTokenConfigured as true when token is configured", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(DEFAULT_SETTINGS);
|
||
|
||
const res = await GET(buildApp(), "/api/settings");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.githubTokenConfigured).toBe(true);
|
||
});
|
||
|
||
it("injects githubTokenConfigured as false when no token", async () => {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store)); // no githubToken option
|
||
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(DEFAULT_SETTINGS);
|
||
|
||
const res = await GET(app, "/api/settings");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.githubTokenConfigured).toBe(false);
|
||
});
|
||
|
||
it("returns 500 on store error", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Config read failed"));
|
||
|
||
const res = await GET(buildApp(), "/api/settings");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Config read failed");
|
||
});
|
||
});
|
||
|
||
describe("PUT /settings", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store, { githubToken: "ghp_test_token" }));
|
||
return app;
|
||
}
|
||
|
||
it("updates settings with valid payload", async () => {
|
||
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 8 };
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ maxConcurrent: 8 }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8 });
|
||
});
|
||
|
||
it("strips server-owned fields (githubTokenConfigured) before calling store.updateSettings", async () => {
|
||
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 4 };
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ maxConcurrent: 4, githubTokenConfigured: true }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
// The server should strip githubTokenConfigured before passing to store
|
||
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 4 });
|
||
});
|
||
|
||
it("strips multiple server-owned fields if present", async () => {
|
||
const updatedSettings = { ...DEFAULT_SETTINGS, maxWorktrees: 10 };
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
// Currently only githubTokenConfigured is server-owned
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ maxWorktrees: 10, githubTokenConfigured: true }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith({ maxWorktrees: 10 });
|
||
});
|
||
|
||
it("validates and forwards model presets", async () => {
|
||
const updatedSettings = {
|
||
...DEFAULT_SETTINGS,
|
||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }],
|
||
};
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini", validatorProvider: undefined, validatorModelId: undefined }],
|
||
}));
|
||
});
|
||
|
||
it("resolves duplicate preset ids by auto-generating unique ids", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget" }, { id: "budget", name: "Budget 2" }] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||
modelPresets: [
|
||
expect.objectContaining({ id: "budget", name: "Budget" }),
|
||
// "budget" collides; falls back to slug of name "Budget 2" → "budget-2"
|
||
expect.objectContaining({ id: "budget-2", name: "Budget 2" }),
|
||
],
|
||
}));
|
||
});
|
||
|
||
it("auto-generates preset id from name when id is omitted", async () => {
|
||
const updatedSettings = {
|
||
...DEFAULT_SETTINGS,
|
||
modelPresets: [{ id: "my-custom-preset", name: "My Custom Preset" }],
|
||
};
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ modelPresets: [{ name: "My Custom Preset" }] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||
modelPresets: [expect.objectContaining({ id: "my-custom-preset", name: "My Custom Preset" })],
|
||
}));
|
||
});
|
||
|
||
it("preserves explicit preset id when provided", async () => {
|
||
const updatedSettings = {
|
||
...DEFAULT_SETTINGS,
|
||
modelPresets: [{ id: "custom-id", name: "My Preset" }],
|
||
};
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ modelPresets: [{ id: "custom-id", name: "My Preset" }] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||
modelPresets: [expect.objectContaining({ id: "custom-id", name: "My Preset" })],
|
||
}));
|
||
});
|
||
|
||
it("rejects incomplete model provider/modelId pairs", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai" }] }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must include both provider and modelId or neither");
|
||
});
|
||
|
||
it("rejects global-only fields with 400 error and helpful message", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ themeMode: "dark", maxConcurrent: 4 }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("global settings");
|
||
expect(res.body.error).toContain("themeMode");
|
||
expect(res.body.error).toContain("/settings/global");
|
||
});
|
||
|
||
it("rejects when only global fields are sent", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("defaultProvider");
|
||
});
|
||
|
||
it("allows project-only fields to pass through successfully", async () => {
|
||
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 8 };
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ maxConcurrent: 8, autoMerge: false }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8, autoMerge: false });
|
||
});
|
||
|
||
it("returns 500 on store update error", async () => {
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({ maxConcurrent: 3 }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Write failed");
|
||
});
|
||
|
||
it("updates planning and validator model settings via store.updateSettings", async () => {
|
||
const updatedSettings = {
|
||
...DEFAULT_SETTINGS,
|
||
planningProvider: "anthropic",
|
||
planningModelId: "claude-sonnet-4-5",
|
||
validatorProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
};
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({
|
||
planningProvider: "anthropic",
|
||
planningModelId: "claude-sonnet-4-5",
|
||
validatorProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||
planningProvider: "anthropic",
|
||
planningModelId: "claude-sonnet-4-5",
|
||
validatorProvider: "openai",
|
||
validatorModelId: "gpt-4o",
|
||
});
|
||
});
|
||
|
||
it("persists planning/validator settings and returns them via GET /settings", async () => {
|
||
// First, update the settings
|
||
const updatedSettings = {
|
||
...DEFAULT_SETTINGS,
|
||
planningProvider: "anthropic",
|
||
planningModelId: "claude-opus-4",
|
||
validatorProvider: "openai",
|
||
validatorModelId: "gpt-4-turbo",
|
||
};
|
||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
|
||
const updateRes = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings",
|
||
JSON.stringify({
|
||
planningProvider: "anthropic",
|
||
planningModelId: "claude-opus-4",
|
||
validatorProvider: "openai",
|
||
validatorModelId: "gpt-4-turbo",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(updateRes.status).toBe(200);
|
||
|
||
// Then, verify GET /settings returns the persisted values
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||
const getRes = await GET(buildApp(), "/api/settings");
|
||
|
||
expect(getRes.status).toBe(200);
|
||
expect(getRes.body.planningProvider).toBe("anthropic");
|
||
expect(getRes.body.planningModelId).toBe("claude-opus-4");
|
||
expect(getRes.body.validatorProvider).toBe("openai");
|
||
expect(getRes.body.validatorModelId).toBe("gpt-4-turbo");
|
||
});
|
||
});
|
||
|
||
describe("GET /settings/global", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns global settings from the global settings store", async () => {
|
||
const mockGlobalStore = createMockGlobalSettingsStore();
|
||
mockGlobalStore.getSettings.mockResolvedValue({ themeMode: "light", colorTheme: "ocean" });
|
||
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
|
||
|
||
const res = await GET(buildApp(), "/api/settings/global");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.themeMode).toBe("light");
|
||
expect(res.body.colorTheme).toBe("ocean");
|
||
// Should NOT include server-only fields
|
||
expect(res.body.githubTokenConfigured).toBeUndefined();
|
||
});
|
||
|
||
it("returns 500 on global store error", async () => {
|
||
const mockGlobalStore = createMockGlobalSettingsStore();
|
||
mockGlobalStore.getSettings.mockRejectedValue(new Error("Read failed"));
|
||
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
|
||
|
||
const res = await GET(buildApp(), "/api/settings/global");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Read failed");
|
||
});
|
||
});
|
||
|
||
describe("PUT /settings/global", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("updates global settings via store.updateGlobalSettings", async () => {
|
||
const updatedMerged = { themeMode: "light", maxConcurrent: 2 };
|
||
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedMerged);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings/global",
|
||
JSON.stringify({ themeMode: "light" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ themeMode: "light" });
|
||
});
|
||
|
||
it("returns 500 on update error", async () => {
|
||
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings/global",
|
||
JSON.stringify({ themeMode: "light" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Write failed");
|
||
});
|
||
|
||
it("persists modelOnboardingComplete flag", async () => {
|
||
const updated = { modelOnboardingComplete: true };
|
||
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updated);
|
||
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"PUT",
|
||
"/api/settings/global",
|
||
JSON.stringify(updated),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
|
||
expect(res.body.modelOnboardingComplete).toBe(true);
|
||
});
|
||
|
||
it("GET /settings/global returns modelOnboardingComplete value", async () => {
|
||
const mockGlobalStore = createMockGlobalSettingsStore();
|
||
mockGlobalStore.getSettings.mockResolvedValue({ modelOnboardingComplete: true, themeMode: "dark" });
|
||
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
|
||
|
||
const res = await GET(buildApp(), "/api/settings/global");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.modelOnboardingComplete).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("GET /settings/scopes", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns settings separated by scope", async () => {
|
||
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
global: { themeMode: "dark", defaultProvider: "anthropic" },
|
||
project: { maxConcurrent: 4, autoMerge: false },
|
||
});
|
||
|
||
const res = await GET(buildApp(), "/api/settings/scopes");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.global.themeMode).toBe("dark");
|
||
expect(res.body.global.defaultProvider).toBe("anthropic");
|
||
expect(res.body.project.maxConcurrent).toBe(4);
|
||
expect(res.body.project.autoMerge).toBe(false);
|
||
});
|
||
|
||
it("returns 500 on store error", async () => {
|
||
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Failed"));
|
||
|
||
const res = await GET(buildApp(), "/api/settings/scopes");
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(res.body.error).toContain("Failed");
|
||
});
|
||
});
|
||
|
||
describe("POST /settings/test-ntfy", () => {
|
||
let store: TaskStore;
|
||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
|
||
});
|
||
|
||
afterEach(() => {
|
||
fetchSpy.mockRestore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("sends Fusion-branded test notification", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "test-topic",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual({ success: true });
|
||
|
||
// Verify the ntfy request uses Fusion branding
|
||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||
const [url, options] = fetchSpy.mock.calls[0];
|
||
expect(url).toBe("https://ntfy.sh/test-topic");
|
||
expect(options?.method).toBe("POST");
|
||
expect(options?.headers).toHaveProperty("Title", "Fusion test notification");
|
||
});
|
||
|
||
it("sends Fusion-branded body text", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "my-topic",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||
|
||
expect(res.status).toBe(200);
|
||
const [_, options] = fetchSpy.mock.calls[0];
|
||
expect(options?.body).toBe("Fusion test notification — your notifications are working!");
|
||
});
|
||
|
||
it("returns 400 when ntfy is not enabled", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
ntfyEnabled: false,
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not enabled");
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when topic is missing", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||
ntfyEnabled: true,
|
||
ntfyTopic: undefined,
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not configured or invalid");
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ── Workflow Step Routes ─────────────────────────────────────────────
|
||
|
||
describe("GET /workflow-steps", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns empty array when no workflow steps exist", async () => {
|
||
const res = await GET(buildApp(), "/api/workflow-steps");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual([]);
|
||
});
|
||
|
||
it("returns workflow steps", async () => {
|
||
const steps = [
|
||
{ id: "WS-001", name: "Docs", description: "Check docs", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||
];
|
||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce(steps);
|
||
|
||
const res = await GET(buildApp(), "/api/workflow-steps");
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual(steps);
|
||
});
|
||
});
|
||
|
||
describe("POST /workflow-steps", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("creates a workflow step", async () => {
|
||
const created = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Docs",
|
||
description: "Check docs",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.id).toBe("WS-001");
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
name: "Docs",
|
||
description: "Check docs",
|
||
mode: "prompt",
|
||
phase: undefined,
|
||
prompt: undefined,
|
||
scriptName: undefined,
|
||
enabled: undefined,
|
||
defaultOn: false,
|
||
});
|
||
});
|
||
|
||
it("returns 400 when name is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
description: "Check docs",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("name");
|
||
});
|
||
|
||
it("returns 400 when description is missing", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Docs",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("description");
|
||
});
|
||
|
||
it("returns 409 when name already exists", async () => {
|
||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||
{ id: "WS-001", name: "Docs", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||
]);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Docs",
|
||
description: "Another docs step",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(409);
|
||
expect(res.body.error).toContain("already exists");
|
||
});
|
||
|
||
it("creates a workflow step with model override", async () => {
|
||
const created = { id: "WS-002", name: "Security", description: "Security audit", prompt: "", enabled: true, modelProvider: "anthropic", modelId: "claude-sonnet-4-5", createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Security",
|
||
description: "Security audit",
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
name: "Security",
|
||
description: "Security audit",
|
||
mode: "prompt",
|
||
phase: undefined,
|
||
prompt: undefined,
|
||
scriptName: undefined,
|
||
enabled: undefined,
|
||
defaultOn: false,
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
});
|
||
});
|
||
|
||
it("returns 400 when model provider is set without modelId", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Security",
|
||
description: "Security audit",
|
||
modelProvider: "anthropic",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must include both provider and modelId");
|
||
});
|
||
|
||
it("returns 400 when modelId is set without model provider", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Security",
|
||
description: "Security audit",
|
||
modelId: "claude-sonnet-4-5",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must include both provider and modelId");
|
||
});
|
||
|
||
it("creates a workflow step without model fields when both empty strings", async () => {
|
||
const created = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Docs",
|
||
description: "Check docs",
|
||
modelProvider: "",
|
||
modelId: "",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
name: "Docs",
|
||
description: "Check docs",
|
||
mode: "prompt",
|
||
phase: undefined,
|
||
prompt: undefined,
|
||
scriptName: undefined,
|
||
enabled: undefined,
|
||
defaultOn: false,
|
||
modelProvider: undefined,
|
||
modelId: undefined,
|
||
});
|
||
});
|
||
|
||
it("creates a script-mode workflow step with valid scriptName", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
scripts: { test: "pnpm test", lint: "pnpm lint" },
|
||
});
|
||
const created = { id: "WS-001", name: "Run Tests", description: "Execute tests", mode: "script", scriptName: "test", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Run Tests",
|
||
description: "Execute tests",
|
||
mode: "script",
|
||
scriptName: "test",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
name: "Run Tests",
|
||
description: "Execute tests",
|
||
mode: "script",
|
||
phase: undefined,
|
||
prompt: undefined,
|
||
scriptName: "test",
|
||
enabled: undefined,
|
||
defaultOn: false,
|
||
});
|
||
});
|
||
|
||
it("returns 400 for script mode without scriptName", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Run Tests",
|
||
description: "Execute tests",
|
||
mode: "script",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("scriptName is required");
|
||
});
|
||
|
||
it("returns 400 for script mode with scriptName not in project scripts", async () => {
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
scripts: { lint: "pnpm lint" },
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Run Tests",
|
||
description: "Execute tests",
|
||
mode: "script",
|
||
scriptName: "nonexistent",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not found in project settings");
|
||
});
|
||
|
||
it("returns 400 for invalid mode value", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Test",
|
||
description: "Test",
|
||
mode: "invalid",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("mode must be");
|
||
});
|
||
|
||
it("creates a workflow step with 'post-merge' phase", async () => {
|
||
const created = { id: "WS-001", name: "Post Merge", description: "After merge", mode: "prompt", phase: "post-merge", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Post Merge",
|
||
description: "After merge",
|
||
phase: "post-merge",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
name: "Post Merge",
|
||
description: "After merge",
|
||
mode: "prompt",
|
||
phase: "post-merge",
|
||
prompt: undefined,
|
||
scriptName: undefined,
|
||
enabled: undefined,
|
||
defaultOn: false,
|
||
});
|
||
});
|
||
|
||
it("returns 400 for invalid phase value", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Test",
|
||
description: "Test",
|
||
phase: "during-merge",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("phase must be");
|
||
});
|
||
|
||
it("creates a workflow step with defaultOn true", async () => {
|
||
const created = { id: "WS-010", name: "Auto Step", description: "Auto-enabled", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Auto Step",
|
||
description: "Auto-enabled",
|
||
defaultOn: true,
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
name: "Auto Step",
|
||
description: "Auto-enabled",
|
||
mode: "prompt",
|
||
phase: undefined,
|
||
prompt: undefined,
|
||
scriptName: undefined,
|
||
enabled: undefined,
|
||
defaultOn: true,
|
||
});
|
||
});
|
||
|
||
it("defaults defaultOn to false when not specified", async () => {
|
||
const created = { id: "WS-011", name: "Manual Step", description: "Manual only", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Manual Step",
|
||
description: "Manual only",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith(
|
||
expect.objectContaining({ defaultOn: false })
|
||
);
|
||
});
|
||
|
||
it("returns 400 when defaultOn is not a boolean", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||
name: "Bad Step",
|
||
description: "Bad defaultOn",
|
||
defaultOn: "yes",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("defaultOn");
|
||
});
|
||
});
|
||
|
||
describe("PATCH /workflow-steps/:id", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("updates a workflow step", async () => {
|
||
const updated = { id: "WS-001", name: "Updated", description: "Updated desc", prompt: "Updated prompt", enabled: false, createdAt: "2026-01-01", updatedAt: "2026-01-02" };
|
||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
name: "Updated",
|
||
enabled: false,
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.name).toBe("Updated");
|
||
});
|
||
|
||
it("returns 404 for non-existent step", async () => {
|
||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found"));
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-999", JSON.stringify({
|
||
name: "Nope",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("updates a workflow step with model override", async () => {
|
||
const updated = { id: "WS-001", name: "Security", description: "Audit", prompt: "", enabled: true, modelProvider: "anthropic", modelId: "claude-sonnet-4-5", createdAt: "2026-01-01", updatedAt: "2026-01-02" };
|
||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
}));
|
||
});
|
||
|
||
it("returns 400 when updating with only modelProvider", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
modelProvider: "anthropic",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must include both provider and modelId");
|
||
});
|
||
|
||
it("returns 400 when updating with only modelId", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
modelId: "claude-sonnet-4-5",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("must include both provider and modelId");
|
||
});
|
||
|
||
it("returns 400 when updating scriptName to nonexistent on existing script-mode step", async () => {
|
||
// Simulate an existing script-mode step
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
id: "WS-001",
|
||
name: "Run Tests",
|
||
description: "Test runner",
|
||
mode: "script",
|
||
scriptName: "test",
|
||
prompt: "",
|
||
enabled: true,
|
||
createdAt: "2026-01-01",
|
||
updatedAt: "2026-01-01",
|
||
});
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
scripts: { test: "pnpm test", lint: "pnpm lint" },
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
scriptName: "nonexistent",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("not found in project settings");
|
||
// Should NOT have called updateWorkflowStep since validation failed
|
||
expect(store.updateWorkflowStep).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns 400 when updating script-mode step without scriptName (resulting state)", async () => {
|
||
// Simulate an existing script-mode step with scriptName cleared
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
id: "WS-001",
|
||
name: "Run Tests",
|
||
description: "Test runner",
|
||
mode: "script",
|
||
scriptName: "",
|
||
prompt: "",
|
||
enabled: true,
|
||
createdAt: "2026-01-01",
|
||
updatedAt: "2026-01-01",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
name: "Updated Name",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("scriptName is required when mode is 'script'");
|
||
});
|
||
|
||
it("updates a workflow step phase", async () => {
|
||
const updated = { id: "WS-001", name: "Post Merge", description: "After merge", phase: "post-merge", createdAt: "2026-01-01", updatedAt: "2026-01-02" };
|
||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
id: "WS-001", name: "Pre Merge", description: "Before merge", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
phase: "post-merge",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
|
||
phase: "post-merge",
|
||
}));
|
||
});
|
||
|
||
it("returns 400 for invalid phase value on update", async () => {
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
id: "WS-001", name: "Test", description: "Test", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
phase: "during-merge",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("phase must be");
|
||
});
|
||
|
||
it("updates defaultOn to true", async () => {
|
||
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
defaultOn: true,
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: true }));
|
||
});
|
||
|
||
it("updates defaultOn to false", async () => {
|
||
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: false, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
defaultOn: false,
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: false }));
|
||
});
|
||
|
||
it("returns 400 when defaultOn is not a boolean in PATCH", async () => {
|
||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||
defaultOn: "yes",
|
||
}), { "Content-Type": "application/json" });
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("defaultOn");
|
||
});
|
||
});
|
||
|
||
describe("DELETE /workflow-steps/:id", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("deletes a workflow step", async () => {
|
||
(store.deleteWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/workflow-steps/WS-001", undefined, {});
|
||
|
||
expect(res.status).toBe(204);
|
||
});
|
||
|
||
it("returns 404 for non-existent step", async () => {
|
||
(store.deleteWorkflowStep as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found"));
|
||
|
||
const res = await REQUEST(buildApp(), "DELETE", "/api/workflow-steps/WS-999", undefined, {});
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
});
|
||
|
||
describe("POST /workflow-steps/:id/refine", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns 404 when workflow step not found", async () => {
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-999/refine", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("returns 400 when workflow step has no description", async () => {
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
id: "WS-001", name: "Empty", description: " ", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("no description");
|
||
});
|
||
|
||
it("returns 400 when workflow step is in script mode", async () => {
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
id: "WS-001", name: "Run Tests", description: "Execute test suite", mode: "script", scriptName: "test", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
|
||
});
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Cannot refine prompt for script-mode");
|
||
});
|
||
|
||
it("falls back to description when AI is unavailable", async () => {
|
||
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
|
||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({});
|
||
const updatedWs = { ...ws, prompt: "Check docs" };
|
||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
// AI import will fail in test env, falling back to description
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.prompt).toBeDefined();
|
||
expect(res.body.workflowStep).toBeDefined();
|
||
expect(store.updateWorkflowStep).toHaveBeenCalled();
|
||
}, 30000);
|
||
});
|
||
|
||
// ── Workflow Step Template Tests ──────────────────────────────────────────
|
||
|
||
describe("GET /workflow-step-templates", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns all built-in templates", async () => {
|
||
const res = await GET(buildApp(), "/api/workflow-step-templates");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.templates).toBeDefined();
|
||
expect(Array.isArray(res.body.templates)).toBe(true);
|
||
expect(res.body.templates.length).toBeGreaterThanOrEqual(5);
|
||
|
||
// Check that templates have required fields
|
||
for (const template of res.body.templates) {
|
||
expect(template.id).toBeDefined();
|
||
expect(template.name).toBeDefined();
|
||
expect(template.description).toBeDefined();
|
||
expect(template.category).toBeDefined();
|
||
expect(template.prompt).toBeDefined();
|
||
}
|
||
});
|
||
|
||
it("includes expected template IDs", async () => {
|
||
const res = await GET(buildApp(), "/api/workflow-step-templates");
|
||
|
||
expect(res.status).toBe(200);
|
||
const ids = res.body.templates.map((t: { id: string }) => t.id);
|
||
expect(ids).toContain("documentation-review");
|
||
expect(ids).toContain("qa-check");
|
||
expect(ids).toContain("security-audit");
|
||
expect(ids).toContain("performance-review");
|
||
expect(ids).toContain("accessibility-check");
|
||
});
|
||
});
|
||
|
||
describe("POST /workflow-step-templates/:id/create", () => {
|
||
let store: TaskStore;
|
||
|
||
beforeEach(() => {
|
||
store = createMockStore();
|
||
});
|
||
|
||
function buildApp() {
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("creates workflow step from template", async () => {
|
||
const created = {
|
||
id: "WS-001",
|
||
name: "Documentation Review",
|
||
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
|
||
prompt: expect.stringContaining("documentation reviewer"),
|
||
enabled: true,
|
||
createdAt: "2026-01-01",
|
||
updatedAt: "2026-01-01",
|
||
};
|
||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.id).toBe("WS-001");
|
||
expect(res.body.name).toBe("Documentation Review");
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
templateId: "documentation-review",
|
||
name: "Documentation Review",
|
||
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
|
||
prompt: expect.stringContaining("documentation reviewer"),
|
||
toolMode: "readonly",
|
||
enabled: true,
|
||
});
|
||
});
|
||
|
||
it("creates workflow step from qa-check template", async () => {
|
||
const created = {
|
||
id: "WS-002",
|
||
name: "QA Check",
|
||
description: "Run tests and verify they pass, check for obvious bugs",
|
||
prompt: expect.stringContaining("QA tester"),
|
||
enabled: true,
|
||
createdAt: "2026-01-01",
|
||
updatedAt: "2026-01-01",
|
||
};
|
||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/qa-check/create", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.name).toBe("QA Check");
|
||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||
templateId: "qa-check",
|
||
name: "QA Check",
|
||
description: "Run tests and verify they pass, check for obvious bugs",
|
||
prompt: expect.stringContaining("QA tester"),
|
||
toolMode: "coding",
|
||
enabled: true,
|
||
});
|
||
});
|
||
|
||
it("returns 404 for non-existent template", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/nonexistent/create", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("returns 409 when workflow step with same name already exists", async () => {
|
||
const existingSteps = [
|
||
{ id: "WS-001", name: "Documentation Review", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||
];
|
||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce(existingSteps);
|
||
|
||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
|
||
"Content-Type": "application/json",
|
||
});
|
||
|
||
expect(res.status).toBe(409);
|
||
expect(res.body.error).toContain("already exists");
|
||
});
|
||
});
|
||
|
||
describe("Agent create/update routes", () => {
|
||
let tempDir: string;
|
||
let fusionDir: string;
|
||
let agentId: string;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agents-fields-"));
|
||
fusionDir = join(tempDir, ".fusion");
|
||
mkdirSync(fusionDir, { recursive: true });
|
||
|
||
const { AgentStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||
await agentStore.init();
|
||
const agent = await agentStore.createAgent({
|
||
name: "Initial Agent",
|
||
role: "executor",
|
||
});
|
||
agentId = agent.id;
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
function buildAgentApp() {
|
||
const store = createMockStore({
|
||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||
} as any);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("POST /api/agents accepts all AgentCreateInput fields", async () => {
|
||
const res = await REQUEST(
|
||
buildAgentApp(),
|
||
"POST",
|
||
"/api/agents",
|
||
JSON.stringify({
|
||
name: "Full Agent",
|
||
role: "reviewer",
|
||
metadata: { team: "qa" },
|
||
title: "QA Reviewer",
|
||
icon: "🧪",
|
||
reportsTo: agentId,
|
||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||
permissions: { read: true },
|
||
instructionsPath: "docs/reviewer.md",
|
||
instructionsText: "Check test quality.",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body).toMatchObject({
|
||
name: "Full Agent",
|
||
role: "reviewer",
|
||
metadata: { team: "qa" },
|
||
title: "QA Reviewer",
|
||
icon: "🧪",
|
||
reportsTo: agentId,
|
||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||
permissions: { read: true },
|
||
instructionsPath: "docs/reviewer.md",
|
||
instructionsText: "Check test quality.",
|
||
});
|
||
});
|
||
|
||
it("PATCH /api/agents/:id accepts all AgentUpdateInput fields", async () => {
|
||
const res = await REQUEST(
|
||
buildAgentApp(),
|
||
"PATCH",
|
||
`/api/agents/${agentId}`,
|
||
JSON.stringify({
|
||
name: "Updated Agent",
|
||
role: "engineer",
|
||
metadata: { area: "infra" },
|
||
title: "Infra Engineer",
|
||
icon: "⚙️",
|
||
reportsTo: "agent-parent",
|
||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||
pauseReason: "manual",
|
||
permissions: { deploy: true },
|
||
totalInputTokens: 42,
|
||
totalOutputTokens: 21,
|
||
instructionsPath: "agents/infra.md",
|
||
instructionsText: "Focus on reliability.",
|
||
}),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toMatchObject({
|
||
id: agentId,
|
||
name: "Updated Agent",
|
||
role: "engineer",
|
||
metadata: { area: "infra" },
|
||
title: "Infra Engineer",
|
||
icon: "⚙️",
|
||
reportsTo: "agent-parent",
|
||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||
pauseReason: "manual",
|
||
permissions: { deploy: true },
|
||
totalInputTokens: 42,
|
||
totalOutputTokens: 21,
|
||
instructionsPath: "agents/infra.md",
|
||
instructionsText: "Focus on reliability.",
|
||
});
|
||
});
|
||
|
||
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
|
||
const res = await REQUEST(
|
||
buildAgentApp(),
|
||
"POST",
|
||
`/api/agents/${agentId}/state`,
|
||
JSON.stringify({ state: "terminated" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toContain("Invalid state transition");
|
||
});
|
||
});
|
||
|
||
describe("POST /api/agents/:id/runs", () => {
|
||
let tempDir: string;
|
||
let fusionDir: string;
|
||
let agentId: string;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-runs-"));
|
||
fusionDir = join(tempDir, ".fusion");
|
||
mkdirSync(fusionDir, { recursive: true });
|
||
|
||
// Create a real agent in the temp directory so AgentStore can find it
|
||
const { AgentStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||
await agentStore.init();
|
||
const agent = await agentStore.createAgent({
|
||
name: "Test Agent",
|
||
role: "executor",
|
||
});
|
||
agentId = agent.id;
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
function buildApp() {
|
||
const store = createMockStore({
|
||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||
} as any);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns 201 with created run for valid agent", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body).toMatchObject({
|
||
id: expect.stringMatching(/^run-/),
|
||
agentId,
|
||
status: "active",
|
||
endedAt: null,
|
||
invocationSource: "on_demand",
|
||
});
|
||
expect(res.body.startedAt).toBeTruthy();
|
||
});
|
||
|
||
it("persists the run via saveRun", async () => {
|
||
await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||
|
||
// Verify run was persisted to filesystem
|
||
const { AgentStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||
await agentStore.init();
|
||
const runs = await agentStore.getRecentRuns(agentId);
|
||
expect(runs).toHaveLength(1);
|
||
expect(runs[0].invocationSource).toBe("on_demand");
|
||
});
|
||
|
||
it("returns 404 for non-existent agent", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", "/api/agents/agent-nonexistent/runs");
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("not found");
|
||
});
|
||
|
||
it("uses default invocationSource when no body provided", async () => {
|
||
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.invocationSource).toBe("on_demand");
|
||
});
|
||
|
||
it("uses custom source and triggerDetail from body", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
`/api/agents/${agentId}/runs`,
|
||
JSON.stringify({ source: "timer", triggerDetail: "cron schedule" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.invocationSource).toBe("timer");
|
||
expect(res.body.triggerDetail).toBe("cron schedule");
|
||
});
|
||
|
||
it("returns 500 on store error", async () => {
|
||
const store = createMockStore({
|
||
getFusionDir: vi.fn().mockReturnValue("/nonexistent/path/that/does/not/exist"),
|
||
} as any);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
|
||
// This should hit an error because the agent doesn't exist in that path
|
||
const res = await REQUEST(app, "POST", `/api/agents/${agentId}/runs`);
|
||
|
||
expect(res.status).toBe(500);
|
||
});
|
||
|
||
it("accepts taskId in body and includes it in contextSnapshot", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
`/api/agents/${agentId}/runs`,
|
||
JSON.stringify({ source: "on_demand", triggerDetail: "manual", taskId: "FN-001" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.contextSnapshot).toMatchObject({
|
||
wakeReason: "on_demand",
|
||
triggerDetail: "manual",
|
||
taskId: "FN-001",
|
||
});
|
||
});
|
||
|
||
it("includes wake context without taskId when not provided", async () => {
|
||
const res = await REQUEST(
|
||
buildApp(),
|
||
"POST",
|
||
`/api/agents/${agentId}/runs`,
|
||
JSON.stringify({ source: "timer", triggerDetail: "scheduled" }),
|
||
{ "Content-Type": "application/json" },
|
||
);
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.contextSnapshot).toMatchObject({
|
||
wakeReason: "timer",
|
||
triggerDetail: "scheduled",
|
||
});
|
||
expect(res.body.contextSnapshot.taskId).toBeUndefined();
|
||
});
|
||
|
||
it("returns 409 when agent already has an active run", async () => {
|
||
// Create first run
|
||
const res1 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||
expect(res1.status).toBe(201);
|
||
|
||
// Try to create second run — should conflict
|
||
const res2 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||
expect(res2.status).toBe(409);
|
||
expect(res2.body.error).toContain("active run");
|
||
expect(res2.body.runId).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe("GET /api/agents/:id/runs/:runId/logs", () => {
|
||
let tempDir: string;
|
||
let fusionDir: string;
|
||
let agentId: string;
|
||
let taskId: string;
|
||
let runId: string;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-run-logs-"));
|
||
fusionDir = join(tempDir, ".fusion");
|
||
mkdirSync(fusionDir, { recursive: true });
|
||
|
||
const { AgentStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||
await agentStore.init();
|
||
|
||
const agent = await agentStore.createAgent({
|
||
name: "Test Agent",
|
||
role: "executor",
|
||
});
|
||
agentId = agent.id;
|
||
|
||
// Start a run, complete it, and record its ID
|
||
const run = await agentStore.startHeartbeatRun(agentId);
|
||
runId = run.id;
|
||
|
||
// End the run with a context snapshot containing a taskId
|
||
run.endedAt = new Date().toISOString();
|
||
run.status = "completed";
|
||
run.contextSnapshot = { taskId: "FN-001", projectId: "test-project" };
|
||
await agentStore.saveRun(run);
|
||
|
||
taskId = "FN-001";
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
function buildApp() {
|
||
const store = createMockStore({
|
||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([
|
||
{
|
||
timestamp: "2024-01-01T00:01:00.000Z",
|
||
taskId: "FN-001",
|
||
text: "Starting task execution",
|
||
type: "text",
|
||
},
|
||
{
|
||
timestamp: "2024-01-01T00:02:00.000Z",
|
||
taskId: "FN-001",
|
||
text: "Read file: src/index.ts",
|
||
type: "tool",
|
||
},
|
||
]),
|
||
} as any);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
return app;
|
||
}
|
||
|
||
it("returns logs for a valid run with contextSnapshot.taskId", async () => {
|
||
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toHaveLength(2);
|
||
expect(res.body[0]).toMatchObject({ taskId: "FN-001", type: "text" });
|
||
expect(res.body[1]).toMatchObject({ taskId: "FN-001", type: "tool" });
|
||
});
|
||
|
||
it("returns empty array for run without contextSnapshot.taskId", async () => {
|
||
// Create a run without contextSnapshot
|
||
const { AgentStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||
await agentStore.init();
|
||
const run = await agentStore.startHeartbeatRun(agentId);
|
||
run.endedAt = new Date().toISOString();
|
||
run.status = "completed";
|
||
// No contextSnapshot
|
||
await agentStore.saveRun(run);
|
||
|
||
const store = createMockStore({
|
||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||
} as any);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
|
||
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${run.id}/logs`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual([]);
|
||
});
|
||
|
||
it("returns 404 for non-existent run", async () => {
|
||
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/run-nonexistent/logs`);
|
||
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.error).toContain("Run not found");
|
||
});
|
||
|
||
it("returns 404 for non-existent agent", async () => {
|
||
const res = await REQUEST(buildApp(), "GET", `/api/agents/agent-nonexistent/runs/${runId}/logs`);
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it("handles store errors gracefully", async () => {
|
||
const store = createMockStore({
|
||
getFusionDir: vi.fn().mockReturnValue("/nonexistent/path"),
|
||
} as any);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use("/api", createApiRoutes(store));
|
||
|
||
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
|
||
|
||
// Either 404 or 500 depending on where the error occurs
|
||
expect([404, 500]).toContain(res.status);
|
||
});
|
||
});
|