feat(KB-618): add multi-project dashboard support
- Add project API methods and types for multi-project support - Add server-side project management routes for multi-project support - Update test coverage for dashboard API and routes
This commit is contained in:
@@ -52,7 +52,7 @@ const FAKE_DETAIL: TaskDetail = {
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# FN-001",
|
||||
prompt: "# KB-001",
|
||||
};
|
||||
|
||||
function mockFetchResponse(
|
||||
@@ -141,7 +141,7 @@ describe("updateTask", () => {
|
||||
const result = await updateTask("FN-001", { dependencies: ["FN-002"] });
|
||||
|
||||
expect(result.dependencies).toEqual(["FN-002"]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ dependencies: ["FN-002"] }),
|
||||
@@ -182,7 +182,7 @@ describe("task comments api", () => {
|
||||
const result = await fetchTaskComments("FN-001");
|
||||
|
||||
expect(result).toEqual(comments);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
@@ -193,7 +193,7 @@ describe("task comments api", () => {
|
||||
const result = await addTaskComment("FN-001", "Hello", "user");
|
||||
|
||||
expect(result).toEqual(FAKE_TASK);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Hello", author: "user" }),
|
||||
@@ -205,7 +205,7 @@ describe("task comments api", () => {
|
||||
|
||||
await updateTaskComment("FN-001", "c1", "Updated");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ text: "Updated" }),
|
||||
@@ -217,7 +217,7 @@ describe("task comments api", () => {
|
||||
|
||||
await deleteTaskComment("FN-001", "c1");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
@@ -529,7 +529,7 @@ describe("addSteeringComment", () => {
|
||||
expect(result.id).toBe("FN-001");
|
||||
expect(result.steeringComments).toHaveLength(1);
|
||||
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Please handle the edge case" }),
|
||||
@@ -794,7 +794,7 @@ describe("approvePlan", () => {
|
||||
|
||||
expect(result.column).toBe("todo");
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/approve-plan", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/approve-plan", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -828,7 +828,7 @@ describe("rejectPlan", () => {
|
||||
|
||||
expect(result.column).toBe("triage");
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/reject-plan", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/reject-plan", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -854,7 +854,7 @@ describe("refineTask", () => {
|
||||
|
||||
const FAKE_REFINED_TASK: Task = {
|
||||
id: "FN-002",
|
||||
description: "Refinement of FN-001",
|
||||
description: "Refinement of KB-001",
|
||||
column: "triage",
|
||||
dependencies: ["FN-001"],
|
||||
steps: [],
|
||||
@@ -872,7 +872,7 @@ describe("refineTask", () => {
|
||||
expect(result.id).toBe("FN-002");
|
||||
expect(result.column).toBe("triage");
|
||||
expect(result.dependencies).toContain("FN-001");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/refine", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/refine", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ feedback: "Need to add more tests and improve error handling" }),
|
||||
@@ -1171,7 +1171,7 @@ describe("Git Management API", () => {
|
||||
const response = await archiveTask("FN-001");
|
||||
|
||||
expect(response.column).toBe("archived");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/archive", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/archive", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -1192,7 +1192,7 @@ describe("Git Management API", () => {
|
||||
const response = await unarchiveTask("FN-001");
|
||||
|
||||
expect(response.column).toBe("done");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/unarchive", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/unarchive", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -1228,7 +1228,7 @@ describe("Git Management API", () => {
|
||||
const response = await fetchWorkspaceFileList("FN-001", "src");
|
||||
|
||||
expect(response).toEqual(payload);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files?workspace=FN-001&path=src", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files?workspace=KB-001&path=src", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
@@ -1252,7 +1252,7 @@ describe("Git Management API", () => {
|
||||
const response = await saveWorkspaceFileContent("FN-001", "src/index.ts", "hello");
|
||||
|
||||
expect(response).toEqual(payload);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=FN-001", {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=KB-001", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "hello" }),
|
||||
@@ -2155,3 +2155,4 @@ describe("fetchProjectConfig", () => {
|
||||
expect(result.rootDir).toBe("/path/to/project");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ import type {
|
||||
FeatureCreateInput,
|
||||
MissionStatus,
|
||||
MilestoneStatus,
|
||||
SliceStatus,
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -119,11 +117,9 @@ function validateOrderedIds(body: unknown): string[] {
|
||||
|
||||
// ── Async Handler Wrapper ───────────────────────────────────────────────────
|
||||
|
||||
type TypedRequest = Request<Record<string, string>>;
|
||||
|
||||
function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
|
||||
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req as TypedRequest, res, next)).catch(next);
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, 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";
|
||||
@@ -17,7 +10,6 @@ import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { __resetPlanningState } from "./planning.js";
|
||||
import { __resetSubtaskBreakdownState } 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
|
||||
vi.mock("@fusion/core", async () => {
|
||||
@@ -41,18 +33,6 @@ function createMockGlobalSettingsStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore() {
|
||||
return {
|
||||
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
|
||||
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
|
||||
updateSession: vi.fn().mockResolvedValue(undefined),
|
||||
addAnswer: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
@@ -83,7 +63,6 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -101,11 +80,28 @@ const FAKE_TASK_DETAIL: TaskDetail = {
|
||||
prompt: "# KB-001\n\nTest task",
|
||||
};
|
||||
|
||||
/** Helper: send GET and return { status, body } */
|
||||
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 };
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
http.get(`http://127.0.0.1:${addr.port}${path}`, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
server.close();
|
||||
try {
|
||||
resolve({ status: res.statusCode!, body: JSON.parse(data) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, body: data });
|
||||
}
|
||||
});
|
||||
}).on("error", (err) => { server.close(); reject(err); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: send a request with method/body and return { status, body } */
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
method: string,
|
||||
@@ -113,8 +109,30 @@ async function REQUEST(
|
||||
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 };
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
const url = new URL(`http://127.0.0.1:${addr.port}${path}`);
|
||||
const req = http.request(
|
||||
{ hostname: url.hostname, port: url.port, path: url.pathname, method, headers },
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
server.close();
|
||||
try {
|
||||
resolve({ status: res.statusCode!, body: JSON.parse(data) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, body: data });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", (err) => { server.close(); reject(err); });
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a minimal multipart/form-data body */
|
||||
@@ -238,19 +256,13 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
expect(store.createTask).toHaveBeenCalledWith({
|
||||
title: undefined,
|
||||
description: "Big initiative",
|
||||
column: undefined,
|
||||
dependencies: undefined,
|
||||
breakIntoSubtasks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards model overrides when both provider and id are supplied", async () => {
|
||||
@@ -279,23 +291,17 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
expect(store.createTask).toHaveBeenCalledWith({
|
||||
title: undefined,
|
||||
description: "Use explicit models",
|
||||
column: undefined,
|
||||
dependencies: undefined,
|
||||
breakIntoSubtasks: undefined,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes partial model overrides back to defaults", async () => {
|
||||
@@ -318,23 +324,17 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
expect(store.createTask).toHaveBeenCalledWith({
|
||||
title: undefined,
|
||||
description: "Ignore partial model selection",
|
||||
column: undefined,
|
||||
dependencies: undefined,
|
||||
breakIntoSubtasks: undefined,
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when model fields are not strings", async () => {
|
||||
@@ -595,8 +595,8 @@ describe("POST /tasks/:id/retry", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in failed state", async () => {
|
||||
@@ -623,8 +623,8 @@ describe("POST /tasks/:id/retry", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -655,7 +655,7 @@ describe("POST /tasks/:id/duplicate", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBe("FN-002");
|
||||
expect(res.body.column).toBe("triage");
|
||||
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001");
|
||||
expect(store.duplicateTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
it("returns 404 when source task not found", async () => {
|
||||
@@ -712,8 +712,8 @@ describe("POST /tasks/:id/refine", () => {
|
||||
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");
|
||||
expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Need improvements");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Refinement requested", "Need improvements");
|
||||
});
|
||||
|
||||
it("creates refinement task from in-review task and returns 201", async () => {
|
||||
@@ -727,7 +727,7 @@ describe("POST /tasks/:id/refine", () => {
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.column).toBe("triage");
|
||||
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Fix edge cases");
|
||||
expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Fix edge cases");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done or in-review column", async () => {
|
||||
@@ -834,7 +834,7 @@ describe("POST /tasks/:id/archive", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("archived");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done column", async () => {
|
||||
@@ -886,7 +886,7 @@ describe("POST /tasks/:id/unarchive", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("done");
|
||||
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001");
|
||||
expect(store.unarchiveTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in archived column", async () => {
|
||||
@@ -1260,7 +1260,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1281,7 +1281,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
title: "New",
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1312,7 +1312,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1361,7 +1361,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1411,7 +1411,7 @@ describe("Attachment routes", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.filename).toBe("1234-screenshot.png");
|
||||
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"FN-001",
|
||||
"screenshot.png",
|
||||
expect.any(Buffer),
|
||||
"image/png",
|
||||
@@ -1454,7 +1454,7 @@ describe("Attachment routes", () => {
|
||||
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");
|
||||
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("FN-001", "1234-screenshot.png");
|
||||
});
|
||||
|
||||
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
|
||||
@@ -1478,7 +1478,7 @@ describe("Attachment routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(fakeLogs);
|
||||
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001");
|
||||
expect(store.getAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
|
||||
@@ -1763,14 +1763,14 @@ describe("Pause/Unpause endpoints", () => {
|
||||
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);
|
||||
expect(store.pauseTask).toHaveBeenCalledWith("FN-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);
|
||||
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", false);
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/pause — returns 500 on error", async () => {
|
||||
@@ -1807,7 +1807,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("FN-001", "Hello", "user");
|
||||
});
|
||||
|
||||
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
|
||||
@@ -1821,7 +1821,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated");
|
||||
expect(store.updateTaskComment).toHaveBeenCalledWith("FN-001", "c1", "Updated");
|
||||
});
|
||||
|
||||
it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => {
|
||||
@@ -1833,7 +1833,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
|
||||
const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1");
|
||||
expect(store.deleteTaskComment).toHaveBeenCalledWith("FN-001", "c1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1863,7 +1863,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(mockComment);
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"FN-001",
|
||||
"Please handle the edge case",
|
||||
"user"
|
||||
);
|
||||
@@ -3160,10 +3160,19 @@ describe("POST /github/issues/batch-import", () => {
|
||||
});
|
||||
|
||||
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"]>>);
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
headers: new Headers({ "Retry-After": "1" }),
|
||||
json: () => Promise.resolve({ message: "Rate limited" }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGitHubIssue(1, "Issue After Rate Limit")),
|
||||
} as Response);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -3177,7 +3186,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
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);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2); // Initial 429 + 1 retry
|
||||
}, 10000); // Increase timeout for retry delay
|
||||
|
||||
it("returns error after max retries exceeded on 429", async () => {
|
||||
@@ -3203,7 +3212,8 @@ describe("POST /github/issues/batch-import", () => {
|
||||
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);
|
||||
// Initial attempt + 3 retries = 4 calls
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
}, 15000); // Increase timeout for multiple retries
|
||||
|
||||
it("processes issues sequentially (not parallel)", async () => {
|
||||
@@ -3653,31 +3663,9 @@ describe("POST /tasks/:id/reject-plan", () => {
|
||||
|
||||
describe("Git Management endpoints", () => {
|
||||
let store: TaskStore;
|
||||
let gitRepoDir: string;
|
||||
let gitTestRoot: string;
|
||||
|
||||
beforeAll(() => {
|
||||
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(() => {
|
||||
// Use the actual project root so git commands work
|
||||
store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue(process.cwd()),
|
||||
});
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
@@ -4783,25 +4771,39 @@ describe("Terminal WebSocket close handler", () => {
|
||||
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();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
const { WebSocket: WsClient } = require("ws");
|
||||
const ws = new WsClient(`ws://127.0.0.1:${addr.port}/api/terminal/ws?sessionId=term-ws-test`);
|
||||
|
||||
wss!.emit("connection", ws, {
|
||||
url: "/api/terminal/ws?sessionId=term-ws-test",
|
||||
headers: { host: "127.0.0.1" },
|
||||
ws.on("open", () => {
|
||||
// Close the WebSocket - this should trigger killSession
|
||||
ws.close();
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
// Give the close handler time to execute
|
||||
setTimeout(() => {
|
||||
try {
|
||||
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
|
||||
server.close();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
server.close();
|
||||
reject(err);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
server.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ws.close();
|
||||
|
||||
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -4834,25 +4836,32 @@ describe("Terminal WebSocket close handler", () => {
|
||||
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();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
const { WebSocket: WsClient } = require("ws");
|
||||
const ws = new WsClient(`ws://127.0.0.1:${addr.port}/api/terminal/ws?sessionId=term-ws-err`);
|
||||
|
||||
wss!.emit("connection", ws, {
|
||||
url: "/api/terminal/ws?sessionId=term-ws-err",
|
||||
headers: { host: "127.0.0.1" },
|
||||
ws.on("open", () => {
|
||||
// Force-terminate the connection to trigger error/close
|
||||
ws.terminate();
|
||||
});
|
||||
|
||||
// After termination, give the handler time to run
|
||||
setTimeout(() => {
|
||||
try {
|
||||
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
|
||||
server.close();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
server.close();
|
||||
reject(err);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
ws.emit("error", new Error("synthetic websocket failure"));
|
||||
|
||||
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user