Merge branch 'main' into timothyjlaurent/Anthropic-error

This commit is contained in:
gsxdsm
2026-05-09 10:42:33 -07:00
committed by GitHub
690 changed files with 59296 additions and 22734 deletions

View File

@@ -74,7 +74,7 @@ describe("agent-onboarding", () => {
}
});
it("parses complete summary responses", () => {
it("parses complete summary responses with rich optional draft fields", () => {
const parsed = parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
@@ -84,6 +84,13 @@ describe("agent-onboarding", () => {
instructionsText: "Review docs for clarity and accuracy.",
thinkingLevel: "medium",
maxTurns: 20,
soul: "Calm and thorough",
memory: "Remember docs conventions",
heartbeatProcedurePath: " .fusion/agents/docs-reviewer/HEARTBEAT.md ",
heartbeatIntervalMs: 30000,
heartbeatEnabled: true,
modelHint: "anthropic/claude-sonnet-4-5",
runtimeHint: "openclaw",
},
}),
);
@@ -92,6 +99,34 @@ describe("agent-onboarding", () => {
if (parsed.type === "complete") {
expect(parsed.data.name).toBe("Docs Reviewer");
expect(parsed.data.maxTurns).toBe(20);
expect(parsed.data.heartbeatProcedurePath).toBe(".fusion/agents/docs-reviewer/HEARTBEAT.md");
expect(parsed.data.heartbeatIntervalMs).toBe(30000);
expect(parsed.data.heartbeatEnabled).toBe(true);
expect(parsed.data.modelHint).toBe("anthropic/claude-sonnet-4-5");
expect(parsed.data.runtimeHint).toBe("openclaw");
}
});
it("parses legacy complete summaries without rich draft fields", () => {
const parsed = parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Legacy Reviewer",
role: "reviewer",
instructionsText: "Review old style drafts",
thinkingLevel: "low",
maxTurns: 10,
},
}),
);
expect(parsed.type).toBe("complete");
if (parsed.type === "complete") {
expect(parsed.data.name).toBe("Legacy Reviewer");
expect(parsed.data.heartbeatProcedurePath).toBeUndefined();
expect(parsed.data.modelHint).toBeUndefined();
expect(parsed.data.runtimeHint).toBeUndefined();
}
});
@@ -112,6 +147,58 @@ describe("agent-onboarding", () => {
).toThrow(/Invalid summary/);
});
it("rejects malformed rich draft fields", () => {
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Malformed",
role: "reviewer",
instructionsText: "Valid instructions",
thinkingLevel: "medium",
maxTurns: 20,
heartbeatProcedurePath: "",
},
}),
),
).toThrow("Invalid summary.heartbeatProcedurePath");
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Malformed",
role: "reviewer",
instructionsText: "Valid instructions",
thinkingLevel: "medium",
maxTurns: 20,
heartbeatIntervalMs: 0,
},
}),
),
).toThrow("Invalid summary.heartbeatIntervalMs");
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Malformed",
role: "reviewer",
instructionsText: "Valid instructions",
thinkingLevel: "medium",
maxTurns: 20,
heartbeatEnabled: "yes",
modelHint: 10,
runtimeHint: { runtime: "openclaw" },
},
}),
),
).toThrow(/Invalid summary\.(heartbeatEnabled|modelHint|runtimeHint)/);
});
it("builds compact onboarding context prompt for create mode", () => {
const prompt = createAgentOnboardingSessionPrompt({
mode: "create",

View File

@@ -24,6 +24,14 @@ const { mockSummarizeTitle } = vi.hoisted(() => ({
vi.mock("@fusion/core", () => ({
summarizeTitle: mockSummarizeTitle,
DASHBOARD_USER_ID: "dashboard",
normalizeMessageParticipant: (id: string, type: "user" | "agent" | "system") => {
const normalized = id.trim();
if (type === "user" && ["dashboard", "user:dashboard", "User: user:dashboard"].includes(normalized)) {
return { id: "dashboard", type: "user" as const };
}
return { id: normalized, type };
},
}));
// SessionManager is constructed per-chat for CLI session continuity. We don't
@@ -63,8 +71,8 @@ const mockAgentStore = {
listAgents: vi.fn(),
};
function createChatManager(pluginRunner?: Record<string, unknown>): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any);
function createChatManager(pluginRunner?: Record<string, unknown>, messageStore?: Record<string, unknown>): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any, undefined, messageStore as any);
}
function createChatManagerWithSettings(settings: {
@@ -652,7 +660,13 @@ describe("ChatManager.sendMessage", () => {
getRuntimeById: vi.fn(),
createRuntimeContext: vi.fn(),
};
const chatManager = createChatManager(pluginRunner);
const messageStore = {
sendMessage: vi.fn(),
getInbox: vi.fn(),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
};
const chatManager = createChatManager(pluginRunner, messageStore);
await chatManager.sendMessage("chat-001", "Hello");
@@ -661,6 +675,99 @@ describe("ChatManager.sendMessage", () => {
runtimeHint: "openclaw",
pluginRunner,
}));
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
customTools: expect.arrayContaining([
expect.objectContaining({ name: "fn_send_message" }),
expect.objectContaining({ name: "fn_read_messages" }),
]),
}));
});
it("routes Hermes mailbox sends from agent to canonical dashboard user", async () => {
const createResolvedSession = vi.fn(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Runtime response" }],
},
},
}));
__setCreateResolvedAgentSession(createResolvedSession as any);
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be calm and precise.",
memory: "Remember to keep test coverage high.",
instructionsText: "Keep replies focused.",
runtimeConfig: {
runtimeHint: "hermes-runtime",
},
});
const messageStore = {
sendMessage: vi.fn().mockReturnValue({ id: "msg-123" }),
getInbox: vi.fn().mockReturnValue([]),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
};
const chatManager = createChatManager(undefined, messageStore);
await chatManager.sendMessage("chat-001", "Hello");
const customTools = createResolvedSession.mock.calls[0]?.[0]?.customTools ?? [];
const sendTool = customTools.find((tool: { name: string }) => tool.name === "fn_send_message");
expect(sendTool).toBeDefined();
const sendResult = await sendTool.execute("call-1", {
to_id: "User: user:dashboard",
content: "status",
type: "agent-to-user",
}, undefined, undefined, undefined);
expect(sendResult.content[0]?.type === "text" ? sendResult.content[0].text : "").toContain("Message sent to dashboard");
expect(messageStore.sendMessage).toHaveBeenCalledWith(expect.objectContaining({
fromId: "agent-001",
fromType: "agent",
toId: "dashboard",
toType: "user",
type: "agent-to-user",
}));
});
it("does not inject mailbox tools for non-agent chat sessions", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: null,
status: "active",
});
const createResolvedSession = vi.fn(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Runtime response" }],
},
},
}));
__setCreateResolvedAgentSession(createResolvedSession as any);
const messageStore = {
sendMessage: vi.fn(),
getInbox: vi.fn(),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
};
const chatManager = createChatManager(undefined, messageStore);
await chatManager.sendMessage("chat-001", "Hello");
expect(createResolvedSession).toHaveBeenCalledWith(expect.not.objectContaining({
customTools: expect.anything(),
}));
});
it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => {
@@ -981,8 +1088,30 @@ describe("ChatManager.sendMessage", () => {
expect(mockAgentStore.init).toHaveBeenCalledTimes(1);
expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001");
expect(createOptions.systemPrompt).toContain("Be calm and precise.");
expect(createOptions.systemPrompt).toContain("type: \"agent-to-user\"");
expect(createOptions.systemPrompt).toContain("to_id: \"dashboard\"");
expect(createOptions.systemPrompt).toContain("Your chat reply is the primary response to the user.");
expect(createOptions.systemPrompt).toContain("Only use `fn_send_message` when the user explicitly asks");
});
it("includes guidance to avoid double-sending mailbox copies by default", async () => {
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.systemPrompt).toContain("Do not also call `fn_send_message` with the same content");
expect(createOptions.systemPrompt).toContain("Only use `fn_send_message` when the user explicitly asks for mailbox/inbox/notification delivery");
});
it("passes enriched system prompt with agent memory when agent context is available", async () => {

View File

@@ -0,0 +1,236 @@
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ChatStore, Database } from "@fusion/core";
import { request } from "../test-request.js";
class MockStore {
constructor(private readonly rootDir: string, private readonly db: Database) {}
getRootDir(): string { return this.rootDir; }
getFusionDir(): string { return join(this.rootDir, ".fusion"); }
getKbDir(): string { return join(this.rootDir, ".fusion"); }
getDatabase(): Database { return this.db; }
}
describe("Chat Room API Routes", () => {
let tempRoot: string;
let db: Database;
let store: MockStore;
let chatStore: ChatStore;
let app: ReturnType<typeof import("../server.js").createServer>;
beforeEach(async () => {
tempRoot = mkdtempSync(join(tmpdir(), "fusion-chat-room-routes-"));
const fusionDir = join(tempRoot, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new MockStore(tempRoot, db);
chatStore = new ChatStore(fusionDir, db);
const { createServer } = await import("../server.js");
app = createServer(store as any, { chatStore });
});
afterEach(async () => {
db.close();
await rm(tempRoot, { recursive: true, force: true });
});
it("create + fetch + update + delete room", async () => {
const createRes = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "Engineering" }), {
"content-type": "application/json",
});
expect(createRes.status).toBe(201);
const roomId = (createRes.body as any).room.id as string;
const getRes = await request(app, "GET", `/api/chat/rooms/${roomId}`);
expect(getRes.status).toBe(200);
const patchRes = await request(
app,
"PATCH",
`/api/chat/rooms/${roomId}`,
JSON.stringify({ description: "Core team" }),
{ "content-type": "application/json" },
);
expect(patchRes.status).toBe(200);
expect((patchRes.body as any).room.description).toBe("Core team");
const delRes = await request(app, "DELETE", `/api/chat/rooms/${roomId}`);
expect(delRes.status).toBe(200);
expect((delRes.body as any).success).toBe(true);
});
it("validates create and slug collision", async () => {
const missingName = await request(app, "POST", "/api/chat/rooms", JSON.stringify({}), {
"content-type": "application/json",
});
expect(missingName.status).toBe(400);
const first = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "Platform Team", projectId: "p1" }), {
"content-type": "application/json",
});
expect(first.status).toBe(201);
const duplicate = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "platform-team", projectId: "p1" }), {
"content-type": "application/json",
});
expect(duplicate.status).toBe(409);
});
it("returns 404 for unknown room", async () => {
const res = await request(app, "GET", "/api/chat/rooms/room-missing");
expect(res.status).toBe(404);
});
it("handles room members add/delete", async () => {
const createRes = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "Ops" }), {
"content-type": "application/json",
});
const roomId = (createRes.body as any).room.id as string;
const addRes = await request(
app,
"POST",
`/api/chat/rooms/${roomId}/members`,
JSON.stringify({ agentId: "agent-1", role: "member" }),
{ "content-type": "application/json" },
);
expect(addRes.status).toBe(201);
const addRes2 = await request(
app,
"POST",
`/api/chat/rooms/${roomId}/members`,
JSON.stringify({ agentId: "agent-1", role: "member" }),
{ "content-type": "application/json" },
);
expect(addRes2.status).toBe(201);
const deleteRes = await request(app, "DELETE", `/api/chat/rooms/${roomId}/members/agent-1`);
expect(deleteRes.status).toBe(200);
const deleteMissing = await request(app, "DELETE", `/api/chat/rooms/${roomId}/members/agent-1`);
expect(deleteMissing.status).toBe(404);
});
it("persists room message and validates sender/content", async () => {
const createRoomRes = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "Product" }), {
"content-type": "application/json",
});
const roomId = (createRoomRes.body as any).room.id as string;
const beforeCount = chatStore.getRoomMessages(roomId).length;
const postRes = await request(
app,
"POST",
`/api/chat/rooms/${roomId}/messages`,
JSON.stringify({ content: " hello world " }),
{ "content-type": "application/json" },
);
expect(postRes.status).toBe(201);
const messageId = (postRes.body as any).message.id as string;
const persisted = chatStore.getRoomMessage(messageId);
expect(persisted?.content).toBe("hello world");
const afterCount = chatStore.getRoomMessages(roomId).length;
expect(afterCount).toBe(beforeCount + 1);
const invalidSender = await request(
app,
"POST",
`/api/chat/rooms/${roomId}/messages`,
JSON.stringify({ content: "x", senderAgentId: "agent-1" }),
{ "content-type": "application/json" },
);
expect(invalidSender.status).toBe(400);
const emptyContent = await request(
app,
"POST",
`/api/chat/rooms/${roomId}/messages`,
JSON.stringify({ content: " " }),
{ "content-type": "application/json" },
);
expect(emptyContent.status).toBe(400);
});
it("deletes messages and supports pagination", async () => {
const room = chatStore.createRoom({ name: "QA" });
const m1 = chatStore.addRoomMessage(room.id, { role: "user", content: "one" });
const m2 = chatStore.addRoomMessage(room.id, { role: "user", content: "two" });
const m3 = chatStore.addRoomMessage(room.id, { role: "user", content: "three" });
const page = await request(app, "GET", `/api/chat/rooms/${room.id}/messages?limit=2&offset=1`);
expect(page.status).toBe(200);
expect((page.body as any).messages.map((m: any) => m.id)).toEqual([m2.id, m3.id]);
const del1 = await request(app, "DELETE", `/api/chat/rooms/${room.id}/messages/${m1.id}`);
expect(del1.status).toBe(200);
const del2 = await request(app, "DELETE", `/api/chat/rooms/${room.id}/messages/${m1.id}`);
expect(del2.status).toBe(404);
});
it("handles message attachments route", async () => {
const room = chatStore.createRoom({ name: "Files" });
const message = chatStore.addRoomMessage(room.id, { role: "user", content: "hello" });
const badPayload = await request(
app,
"POST",
`/api/chat/rooms/${room.id}/messages/${message.id}/attachments`,
JSON.stringify("invalid"),
{ "content-type": "application/json" },
);
expect(badPayload.status).toBe(400);
const addAttachment = await request(
app,
"POST",
`/api/chat/rooms/${room.id}/messages/${message.id}/attachments`,
JSON.stringify({
id: "att-1",
filename: "a.txt",
originalName: "a.txt",
mimeType: "text/plain",
size: 1,
createdAt: new Date().toISOString(),
}),
{ "content-type": "application/json" },
);
expect(addAttachment.status).toBe(200);
expect((addAttachment.body as any).message.attachments).toHaveLength(1);
const missingMessage = await request(
app,
"POST",
`/api/chat/rooms/${room.id}/messages/missing/attachments`,
JSON.stringify({
id: "att-2",
filename: "b.txt",
originalName: "b.txt",
mimeType: "text/plain",
size: 1,
createdAt: new Date().toISOString(),
}),
{ "content-type": "application/json" },
);
expect(missingMessage.status).toBe(404);
});
it("rate-limits GET /chat/rooms", async () => {
let status = 200;
for (let i = 0; i < 1020; i++) {
const res = await request(app, "GET", "/api/chat/rooms");
status = res.status;
if (status === 429) break;
}
expect(status).toBe(429);
});
});

View File

@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TaskStore } from "@fusion/core";
import { TaskStore as TaskStoreClass } from "@fusion/core";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const resolverMocks = vi.hoisted(() => ({
getOrCreateProjectStore: vi.fn(),
}));
vi.mock("../project-store-resolver.js", async () => {
const actual = await vi.importActual<typeof import("../project-store-resolver.js")>("../project-store-resolver.js");
return {
...actual,
getOrCreateProjectStore: resolverMocks.getOrCreateProjectStore,
};
});
describe("Evals routes", () => {
let rootA: string;
let rootB: string;
let storeA: TaskStore;
let storeB: TaskStore;
let app: ReturnType<typeof createServer>;
beforeEach(async () => {
vi.clearAllMocks();
rootA = mkdtempSync(join(tmpdir(), "kb-evals-routes-a-"));
rootB = mkdtempSync(join(tmpdir(), "kb-evals-routes-b-"));
storeA = new TaskStoreClass(rootA, join(rootA, ".fusion-global-settings"), { inMemoryDb: true });
storeB = new TaskStoreClass(rootB, join(rootB, ".fusion-global-settings"), { inMemoryDb: true });
await storeA.init();
await storeB.init();
resolverMocks.getOrCreateProjectStore.mockImplementation(async (projectId: string) => (
projectId === "project-b" ? storeB : storeA
));
app = createServer(storeA);
});
afterEach(async () => {
try { await storeA.close(); } catch { /* cleanup */ }
try { await storeB.close(); } catch { /* cleanup */ }
await rm(rootA, { recursive: true, force: true });
await rm(rootB, { recursive: true, force: true });
});
function seedEvalResult(store: TaskStore, options?: { runId?: string; title?: string; score?: number; rationale?: string }) {
const evalStore = store.getEvalStore();
const run = options?.runId
? evalStore.getRun(options.runId)!
: evalStore.createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
return evalStore.createTaskResult(run.id, {
taskId: "FN-1",
taskSnapshot: { taskId: "FN-1", title: options?.title ?? "Fix routing", column: "done" },
status: "scored",
overallScore: options?.score ?? 82,
maxScore: 100,
categoryScores: [{ category: "quality", score: 80, maxScore: 100 }],
rationale: options?.rationale ?? "Looks good",
evidence: [{ source: "task", id: "FN-1", label: "Task" }],
followUps: [],
});
}
it("GET /api/evals/runs is not shadowed by /:id", async () => {
const run = storeA.getEvalStore().createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
const listRes = await request(app, "GET", "/api/evals/runs");
const getRes = await request(app, "GET", `/api/evals/${run.id}`);
expect(listRes.status).toBe(200);
expect((listRes.body as { runs: Array<{ id: string }> }).runs.length).toBeGreaterThan(0);
expect(getRes.status).toBe(404);
});
it("GET /api/evals supports q/runId/score filters and pagination", async () => {
const evalStore = storeA.getEvalStore();
const runA = evalStore.createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
const runB = evalStore.createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
seedEvalResult(storeA, { runId: runA.id, title: "Fix auth", score: 92, rationale: "Strong" });
evalStore.createTaskResult(runB.id, {
taskId: "FN-2",
taskSnapshot: { taskId: "FN-2", title: "Tune docs", column: "done" },
status: "scored",
overallScore: 45,
maxScore: 100,
categoryScores: [],
rationale: "Weak",
evidence: [],
followUps: [],
});
const filtered = await request(app, "GET", `/api/evals?runId=${runA.id}&q=auth&scoreMin=90&scoreMax=95&limit=1&offset=0`);
expect(filtered.status).toBe(200);
expect((filtered.body as { count: number }).count).toBe(1);
expect((filtered.body as { results: Array<{ taskSnapshot: { title: string } }> }).results[0].taskSnapshot.title).toBe("Fix auth");
});
it("GET /api/evals uses default limit of 100 and offset of 0", async () => {
const evalStore = storeA.getEvalStore();
const run = evalStore.createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
for (let index = 0; index < 101; index += 1) {
evalStore.createTaskResult(run.id, {
taskId: `FN-${index + 1}`,
taskSnapshot: { taskId: `FN-${index + 1}`, title: `Task ${index + 1}`, column: "done" },
status: "scored",
overallScore: 80,
maxScore: 100,
categoryScores: [],
evidence: [],
followUps: [],
});
}
const response = await request(app, "GET", "/api/evals");
expect(response.status).toBe(200);
expect((response.body as { results: unknown[] }).results).toHaveLength(100);
expect((response.body as { count: number }).count).toBe(101);
});
it("GET /api/evals list endpoint honors project scoping", async () => {
const runA = storeA.getEvalStore().createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
const runB = storeB.getEvalStore().createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
storeA.getEvalStore().createTaskResult(runA.id, {
taskId: "FN-1",
taskSnapshot: { taskId: "FN-1", title: "Project A task", column: "done" },
status: "scored",
overallScore: 80,
maxScore: 100,
categoryScores: [],
evidence: [],
followUps: [],
});
storeB.getEvalStore().createTaskResult(runB.id, {
taskId: "FN-2",
taskSnapshot: { taskId: "FN-2", title: "Project B task", column: "done" },
status: "scored",
overallScore: 70,
maxScore: 100,
categoryScores: [],
evidence: [],
followUps: [],
});
const scoped = await request(app, "GET", "/api/evals?projectId=project-b");
expect(scoped.status).toBe(200);
expect((scoped.body as { count: number }).count).toBe(1);
expect((scoped.body as { results: Array<{ taskSnapshot: { title: string } }> }).results.map((result) => result.taskSnapshot.title)).toEqual(["Project B task"]);
});
it("GET /api/evals/:id returns detail and unknown ids return 404", async () => {
const result = seedEvalResult(storeA);
const ok = await request(app, "GET", `/api/evals/${result.id}`);
expect(ok.status).toBe(200);
expect((ok.body as { result: { id: string } }).result.id).toBe(result.id);
const missing = await request(app, "GET", "/api/evals/ER-missing");
expect(missing.status).toBe(404);
});
it("GET /api/evals/runs includes selector metadata and supports project scoping", async () => {
const runA = storeA.getEvalStore().createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
const runB = storeB.getEvalStore().createRun({ projectId: "", scope: "scheduled", trigger: "manual" });
storeA.getEvalStore().createTaskResult(runA.id, {
taskId: "FN-1",
taskSnapshot: { taskId: "FN-1", title: "A", column: "done" },
status: "scored",
overallScore: 80,
maxScore: 100,
categoryScores: [],
evidence: [],
followUps: [],
});
storeB.getEvalStore().createTaskResult(runB.id, {
taskId: "FN-2",
taskSnapshot: { taskId: "FN-2", title: "B", column: "done" },
status: "scored",
overallScore: 70,
maxScore: 100,
categoryScores: [],
evidence: [],
followUps: [],
});
const defaultRuns = await request(app, "GET", "/api/evals/runs");
expect(defaultRuns.status).toBe(200);
expect((defaultRuns.body as { runs: Array<{ id: string; status: string; createdAt: string; evaluatedTaskCount: number }> }).runs[0]).toMatchObject({ id: runA.id, status: expect.any(String), createdAt: expect.any(String) });
const scopedRuns = await request(app, "GET", "/api/evals/runs?projectId=project-b");
expect(scopedRuns.status).toBe(200);
expect((scopedRuns.body as { runs: Array<{ id: string }> }).runs.map((run) => run.id)).toEqual([runB.id]);
});
it("rejects invalid score and pagination queries", async () => {
const badScore = await request(app, "GET", "/api/evals?scoreMin=foo");
expect(badScore.status).toBe(400);
const reversed = await request(app, "GET", "/api/evals?scoreMin=90&scoreMax=10");
expect(reversed.status).toBe(400);
const badLimit = await request(app, "GET", "/api/evals?limit=0");
expect(badLimit.status).toBe(400);
});
});

View File

@@ -990,6 +990,82 @@ describe("GitHubClient", () => {
});
});
describe("getPrReviewSnapshot", () => {
it("normalizes reviews/comments into review-state items and summary", async () => {
mockRunGhJsonAsync
.mockResolvedValueOnce({
reviewDecision: "CHANGES_REQUESTED",
reviews: [{ id: "r1", state: "CHANGES_REQUESTED", body: "please fix", submittedAt: "2024-01-01T00:00:00Z", author: { login: "octocat" }, url: "https://github.com/owner/repo/pull/1#review-r1" }],
comments: [{ id: "c1", body: "nit", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", author: { login: "reviewer" }, url: "https://github.com/owner/repo/pull/1#issuecomment-c1" }],
})
.mockResolvedValueOnce({
number: 1,
url: "https://github.com/owner/repo/pull/1",
title: "PR",
state: "OPEN",
reviewDecision: "CHANGES_REQUESTED",
baseRefName: "main",
headRefName: "fn/fn-1",
})
.mockResolvedValueOnce([]);
const snapshot = await client.getPrReviewSnapshot("owner", "repo", 1);
expect(snapshot.items).toHaveLength(2);
expect(snapshot.summary?.reviewDecision).toBe("CHANGES_REQUESTED");
expect(snapshot.prInfo.number).toBe(1);
expect(snapshot.commentCount).toBe(1);
expect(snapshot.summary?.reviewers[0]).toEqual(expect.objectContaining({ login: "octocat", state: "CHANGES_REQUESTED" }));
});
it("falls back to API review details when gh fails and token is available", async () => {
mockRunGhJsonAsync.mockImplementation(() => {
throw new Error("gh down");
});
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
repository: {
pullRequest: {
reviewDecision: "APPROVED",
comments: { nodes: [{ id: "C_1", body: "lgtm", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", url: "https://example.com/c1", author: { login: "bot" } }] },
reviews: { nodes: [{ id: "R_1", state: "APPROVED", body: "good", submittedAt: "2024-01-01T00:00:00Z", url: "https://example.com/r1", author: { login: "reviewer" } }] },
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
repository: {
pullRequest: {
number: 1,
url: "https://github.com/owner/repo/pull/1",
title: "PR",
state: "OPEN",
reviewDecision: "APPROVED",
baseRefName: "main",
headRefName: "fn/fn-1",
comments: { totalCount: 1 },
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: [] } } } }] },
},
},
},
}),
});
global.fetch = mockFetch as any;
const snapshot = await clientWithToken.getPrReviewSnapshot("owner", "repo", 1);
expect(snapshot.summary?.reviewDecision).toBe("APPROVED");
expect(snapshot.items).toHaveLength(2);
vi.restoreAllMocks();
});
});
describe("mergePr", () => {
it("merges a PR with gh CLI and refetches merged status", async () => {
mockRunGh.mockReturnValue("Merged pull request");

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import { request } from "../test-request.js";
@@ -19,6 +19,7 @@ const mockGetMeshState = vi.fn();
const mockGetNodeVersionInfo = vi.fn();
const mockSyncPlugins = vi.fn();
const mockCheckVersionCompatibility = vi.fn();
const mockListProjectNodePathMappingsForNode = vi.fn();
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
@@ -40,6 +41,7 @@ vi.mock("@fusion/core", async () => {
getNodeVersionInfo: mockGetNodeVersionInfo,
syncPlugins: mockSyncPlugins,
checkVersionCompatibility: mockCheckVersionCompatibility,
listProjectNodePathMappingsForNode: mockListProjectNodePathMappingsForNode,
})),
};
});
@@ -98,6 +100,10 @@ function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
describe("Node routes", () => {
const app = createServer(new MockStore() as any);
afterEach(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
mockListNodes.mockResolvedValue([]);
@@ -158,6 +164,156 @@ describe("Node routes", () => {
status: "compatible",
message: "Versions match",
});
mockListProjectNodePathMappingsForNode.mockResolvedValue([]);
});
describe("POST /api/nodes/discover-projects", () => {
it("returns normalized remote project discovery payload on success", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ([
{
id: "proj_1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
},
]),
});
vi.stubGlobal("fetch", fetchMock);
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com", apiKey: "secret" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
projects: [
{
id: "proj_1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
},
],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://node.example.com/api/projects",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer secret" },
signal: expect.any(AbortSignal),
}),
);
});
it("returns upstream HTTP failure status and message", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 503,
statusText: "Service Unavailable",
json: async () => ({ error: "upstream down" }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "upstream down" });
});
it("returns 401 when upstream rejects auth", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
json: async () => ({ error: "Invalid API key" }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com", apiKey: "wrong" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Invalid API key" });
});
it("rejects malformed upstream payload", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ projects: [] }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(502);
expect(res.body).toEqual({ error: "Remote node returned malformed project discovery payload" });
});
it("returns 504 on timeout/unreachable abort", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(Object.assign(new Error("aborted"), { name: "AbortError" })));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(504);
expect(res.body).toEqual({ error: "Remote node discovery request timed out" });
});
});
it("GET /api/nodes/:id/path-mappings returns node mappings", async () => {
mockListProjectNodePathMappingsForNode.mockResolvedValue([
{
projectId: "proj_1",
nodeId: "node_local",
path: "/tmp/project",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const res = await request(app, "GET", "/api/nodes/node_local/path-mappings");
expect(res.status).toBe(200);
expect(mockListProjectNodePathMappingsForNode).toHaveBeenCalledWith("node_local");
});
it("GET /api/nodes/:id/path-mappings returns 404 when node missing", async () => {
mockListProjectNodePathMappingsForNode.mockRejectedValue(new Error("Node not found: node_missing"));
const res = await request(app, "GET", "/api/nodes/node_missing/path-mappings");
expect(res.status).toBe(404);
});
it("GET /api/nodes returns an empty array when no nodes are registered", async () => {

View File

@@ -361,6 +361,18 @@ describe("planning module", () => {
expect(session?.agent).toBeDefined();
});
it("passes builtin web tool allowlist when creating non-streaming planning agent", async () => {
const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES));
__setCreateFnAgent(createFnAgentSpy as any);
await createSession(getUniqueIp(), initialPlan, undefined, TEST_ROOT_DIR);
expect(createFnAgentSpy).toHaveBeenCalledWith(expect.objectContaining({
tools: "readonly",
builtinToolsAllowlist: ["WebSearch", "WebFetch"],
}));
});
it("cleans up session on agent failure", async () => {
__setCreateFnAgent(async () => {
throw new Error("Agent creation failed");
@@ -447,6 +459,7 @@ describe("planning module", () => {
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.defaultProvider).toBeUndefined();
expect(callArg?.defaultModelId).toBeUndefined();
expect(callArg?.builtinToolsAllowlist).toEqual(["WebSearch", "WebFetch"]);
});
it("uses custom prompt from promptOverrides when provided", async () => {
@@ -2055,6 +2068,7 @@ describe("planning module", () => {
title: "Implementation",
description: expect.any(String),
suggestedSize: "S",
priority: "normal",
dependsOn: [],
});
@@ -2064,6 +2078,7 @@ describe("planning module", () => {
title: "Tests",
description: expect.any(String),
suggestedSize: "M",
priority: "normal",
dependsOn: ["subtask-1"],
});
@@ -2073,10 +2088,26 @@ describe("planning module", () => {
title: "Documentation",
description: expect.any(String),
suggestedSize: "S",
priority: "normal",
dependsOn: ["subtask-2"],
});
});
it("inherits summary priority for generated subtasks", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Build auth with urgent priority");
const session = getSession(sessionId);
if (!session?.summary) {
throw new Error("Expected summary to exist for completed session");
}
session.summary.priority = "urgent";
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBeGreaterThan(0);
expect(result.every((subtask) => subtask.priority === "urgent")).toBe(true);
});
it("generates deliverable subtasks with distinct lead guidance plus separate plan context", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Build auth system with context");
@@ -2143,6 +2174,7 @@ describe("planning module", () => {
title: "Define implementation approach",
description: expect.any(String),
suggestedSize: "S",
priority: "normal",
dependsOn: [],
});
expect(result[1]).toEqual({
@@ -2150,6 +2182,7 @@ describe("planning module", () => {
title: "Implement core changes",
description: expect.any(String),
suggestedSize: "M",
priority: "normal",
dependsOn: ["subtask-1"],
});
expect(result[2]).toEqual({
@@ -2157,6 +2190,7 @@ describe("planning module", () => {
title: "Verify and polish",
description: expect.any(String),
suggestedSize: "S",
priority: "normal",
dependsOn: ["subtask-2"],
});
expect(result[0]?.description).toContain("Define the implementation approach for the plan");

View File

@@ -0,0 +1,119 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import { createPluginRouter } from "../plugin-routes.js";
import { get as performGet, request as performRequest } from "../test-request.js";
describe("createPluginRouter wiring under /api/plugins", () => {
function buildApp() {
const enablePlugin = vi.fn(async (id: string) => ({ id, enabled: true }));
const pluginStore = {
listPlugins: vi.fn(async () => [{ id: "test-plugin", name: "Test Plugin", enabled: false }]),
getPlugin: vi.fn(async (id: string) => ({ id, settings: {}, enabled: false, manifest: { id, name: id, version: "1.0.0", description: "" } })),
enablePlugin,
disablePlugin: vi.fn(),
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
updatePluginSettings: vi.fn(),
updatePluginState: vi.fn(),
} as any;
const taskStore = {
listTasks: vi.fn(async () => []),
} as any;
const helloHandler = vi.fn(async () => ({ ok: true }));
const collidingEnableHandler = vi.fn(async () => ({ pluginEnable: true }));
const taskStoreHandler = vi.fn(async (_req: unknown, ctx: { taskStore: { listTasks: () => Promise<unknown[]> } }) => {
await ctx.taskStore.listTasks();
return { usedTaskStore: true };
});
const pluginLoader = {
getPlugin: vi.fn((id: string) => {
if (id === "test-plugin" || id === "collision-plugin") {
return { manifest: { id } };
}
return undefined;
}),
createRouteContext: vi.fn(async (_id: string, overrides: { taskStore: unknown; settings: Record<string, unknown> }) => ({
pluginId: "test-plugin",
taskStore: overrides.taskStore,
settings: overrides.settings,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
})),
loadPlugin: vi.fn(),
stopPlugin: vi.fn(),
} as any;
const pluginRunner = {
getPluginRoutes: vi.fn(() => [
{ pluginId: "test-plugin", route: { method: "GET", path: "/hello", handler: helloHandler } },
{ pluginId: "test-plugin", route: { method: "GET", path: "/use-task-store", handler: taskStoreHandler } },
{ pluginId: "collision-plugin", route: { method: "POST", path: "/enable", handler: collidingEnableHandler } },
]),
} as any;
const app = express();
app.use(express.json());
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner, taskStore));
app.use((_req, res) => res.status(404).json({ error: "Not found" }));
return {
app,
pluginStore,
taskStore,
handlers: { helloHandler, collidingEnableHandler, taskStoreHandler },
};
}
it("resolves plugin-defined dynamic GET route", async () => {
const { app } = buildApp();
const res = await performGet(app, "/api/plugins/test-plugin/hello");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
it("keeps management routes working alongside dynamic routes", async () => {
const { app, pluginStore } = buildApp();
const list = await performGet(app, "/api/plugins/");
expect(list.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalled();
const enable = await performRequest(app, "POST", "/api/plugins/test-plugin/enable");
expect(enable.status).toBe(200);
expect(pluginStore.enablePlugin).toHaveBeenCalledWith("test-plugin");
});
it("prioritizes management /:id/enable over plugin-defined /enable route collisions", async () => {
const { app, pluginStore, handlers } = buildApp();
const res = await performRequest(app, "POST", "/api/plugins/collision-plugin/enable");
expect(res.status).toBe(200);
expect(pluginStore.enablePlugin).toHaveBeenCalledWith("collision-plugin");
expect(handlers.collidingEnableHandler).not.toHaveBeenCalled();
});
it.each([
["GET", "/api/plugins/does-not-exist/anything", 404],
["GET", "/api/plugins/missing/hello", 404],
])("returns %i for unknown plugin IDs (%s %s)", async (method, path, expectedStatus) => {
const { app } = buildApp();
const res = await performRequest(app, method as "GET", path);
expect(res.status).toBe(expectedStatus);
});
it("plumbs default taskStore to plugin route context", async () => {
const { app, taskStore, handlers } = buildApp();
const res = await performGet(app, "/api/plugins/test-plugin/use-task-store");
expect(res.status).toBe(200);
expect(res.body).toEqual({ usedTaskStore: true });
expect(taskStore.listTasks).toHaveBeenCalled();
expect(handlers.taskStoreHandler).toHaveBeenCalled();
});
});

View File

@@ -7,6 +7,7 @@ import type { PluginInstallation } from "@fusion/core";
import type { PluginStore } from "@fusion/core";
import type { PluginLoader } from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { createPluginRouter } from "../plugin-routes.js";
import { get as performGet, request as performRequest } from "../test-request.js";
import * as projectStoreResolver from "../project-store-resolver.js";
@@ -56,6 +57,15 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
createRouteContext: vi.fn().mockImplementation(async (pluginId: string, ctx: Record<string, unknown>) => ({
pluginId,
taskStore: ctx.taskStore,
settings: ctx.settings ?? {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: (ctx as { createAiSession?: unknown }).createAiSession,
resolveProjectTaskStore: ctx.resolveProjectTaskStore,
})),
...overrides,
} as unknown as PluginLoader;
}
@@ -841,6 +851,27 @@ describe("plugin setup routes", () => {
expect(res.body).toEqual({ hasSetup: false });
});
it("GET /plugins/:id/setup-status returns deferred status when plugin is not started", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "installed" });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "GET", "/api/plugins/test-plugin/setup-status");
expect(res.status).toBe(200);
expect(res.body).toEqual({
hasSetup: true,
setupCheckDeferred: true,
deferredReason: "plugin-not-started",
pluginState: "installed",
});
expect(pluginRunner.checkPluginSetup).not.toHaveBeenCalled();
});
it("GET /plugins/:id/setup-status returns 404 for nonexistent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Plugin "missing" not found'));
@@ -1158,6 +1189,73 @@ describe("DELETE /plugins/:id", () => {
});
});
describe("plugin-defined route dispatch", () => {
it("registers PATCH routes from plugins", () => {
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{ pluginId: "roadmap-planner", route: { method: "PATCH", path: "/roadmaps/x", handler: vi.fn() } },
]),
};
const pluginStore = createMockPluginStore();
const router = createPluginRouter(pluginStore, createMockPluginLoader({
createRouteContext: vi.fn().mockResolvedValue({
pluginId: "roadmap-planner",
taskStore: createMockTaskStore(),
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
}),
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "roadmap-planner" } }),
} as any), pluginRunner as any, createMockTaskStore());
const stack = (router as any).stack as Array<{ route?: { path: string; methods: Record<string, boolean> } }>;
const patchRoute = stack.find((layer) => layer.route?.path === "/roadmap-planner/roadmaps/x");
expect(patchRoute?.route?.methods.patch).toBe(true);
});
it("passes scoped taskStore and createAiSession through pluginLoader.createRouteContext", async () => {
const routeHandler = vi.fn().mockResolvedValue({ ok: true });
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{ pluginId: "roadmap-planner", route: { method: "POST", path: "/ctx-check", handler: routeHandler } },
]),
};
const scopedPluginStore = createMockPluginStore();
const scopedTaskStore = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(scopedPluginStore) });
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
const createRouteContext = vi.fn().mockImplementation(async (_pluginId: string, overrides: any) => ({
pluginId: "roadmap-planner",
taskStore: overrides.taskStore,
settings: overrides.settings,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: vi.fn(),
resolveProjectTaskStore: overrides.resolveProjectTaskStore,
}));
const pluginLoader = createMockPluginLoader({
createRouteContext,
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "roadmap-planner" } }),
} as any);
const pluginStore = createMockPluginStore();
const app = express();
app.use(express.json());
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner as any, createMockTaskStore()));
const res = await REQUEST(app, "POST", "/api/plugins/roadmap-planner/ctx-check", { projectId: "proj_123" });
expect(res.status).toBe(200);
expect(createRouteContext).toHaveBeenCalledWith("roadmap-planner", expect.objectContaining({
taskStore: scopedTaskStore,
resolveProjectTaskStore: projectStoreResolver.getOrCreateProjectStore,
}));
expect(routeHandler).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ taskStore: scopedTaskStore, createAiSession: expect.any(Function) }),
);
});
});
describe("Project scoping", () => {
let defaultPluginStore: PluginStore;
let scopedPluginStore: PluginStore;

View File

@@ -13,9 +13,14 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import type { TaskStore, PluginStore, PluginLoader, PluginInstallation } from "@fusion/core";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database, CentralDatabase, type TaskStore, type PluginStore, type PluginLoader, type PluginInstallation } from "@fusion/core";
import * as fusionCore from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { createPluginRouter } from "../plugin-routes.js";
import { get as performGet, request as performRequest } from "../test-request.js";
@@ -96,10 +101,33 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
getPluginUiContributions: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]),
getPluginDashboardViews: vi.fn().mockReturnValue([]),
createRouteContext: vi.fn(async (pluginId: string, overrides?: { taskStore?: TaskStore; settings?: Record<string, unknown>; resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore> }) => ({
pluginId,
taskStore: overrides?.taskStore ?? createMockTaskStore(),
settings: overrides?.settings ?? {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
emitEvent: vi.fn(),
createAiSession: await fusionCore.getCreateAiSessionFactory(),
resolveProjectTaskStore: overrides?.resolveProjectTaskStore,
})),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
createRouteContext: vi.fn().mockImplementation(async (pluginId: string, ctx: Record<string, unknown>) => ({
pluginId,
taskStore: ctx.taskStore,
settings: ctx.settings ?? {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: await fusionCore.getCreateAiSessionFactory(),
resolveProjectTaskStore: ctx.resolveProjectTaskStore,
})),
...overrides,
} as unknown as PluginLoader;
}
@@ -342,6 +370,109 @@ describe("POST /api/plugins mode:install — package root path", () => {
});
// ══════════════════════════════════════════════════════════════════
describe("POST /api/plugins central persistence integration", () => {
let projectDir: string;
let centralDir: string;
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "plugin-route-project-"));
centralDir = mkdtempSync(join(tmpdir(), "plugin-route-central-"));
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
await rm(centralDir, { recursive: true, force: true });
});
function buildRealApp(pluginStore: PluginStore) {
const pluginLoader = createMockPluginLoader();
const store = createMockTaskStore({
getRootDir: vi.fn().mockReturnValue(projectDir),
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
return app;
}
it("writes register mode installs to central tables and not project-local plugins", async () => {
const pluginStore = new fusionCore.PluginStore(projectDir, { centralGlobalDir: centralDir });
await pluginStore.init();
const app = buildRealApp(pluginStore);
const res = await REQUEST(app, "POST", "/api/plugins", {
mode: "register",
id: "central-register",
name: "Central Register",
version: "1.0.0",
path: "/tmp/central-register.js",
});
expect(res.status).toBe(201);
const centralDb = new CentralDatabase(centralDir);
centralDb.init();
const installCount = centralDb
.prepare("SELECT COUNT(*) as count FROM plugin_installs WHERE id = ?")
.get("central-register") as { count: number };
const stateCount = centralDb
.prepare("SELECT COUNT(*) as count FROM project_plugin_states WHERE pluginId = ?")
.get("central-register") as { count: number };
const localDb = new Database(join(projectDir, ".fusion"));
localDb.init();
const legacyCount = localDb
.prepare("SELECT COUNT(*) as count FROM plugins WHERE id = ?")
.get("central-register") as { count: number };
expect(installCount.count).toBe(1);
expect(stateCount.count).toBe(1);
expect(legacyCount.count).toBe(0);
centralDb.close();
localDb.close();
});
it("writes install mode installs to central tables and not project-local plugins", async () => {
const pluginStore = new fusionCore.PluginStore(projectDir, { centralGlobalDir: centralDir });
await pluginStore.init();
const pluginPath = "/tmp/my-plugin";
mockAccess.mockImplementation((p: string) => {
if (p === pluginPath || p === `${pluginPath}/manifest.json`) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockReadFile.mockResolvedValueOnce(JSON.stringify(VALID_MANIFEST));
const app = buildRealApp(pluginStore);
const res = await REQUEST(app, "POST", "/api/plugins", {
mode: "install",
path: pluginPath,
});
expect(res.status).toBe(201);
const centralDb = new CentralDatabase(centralDir);
centralDb.init();
const installCount = centralDb
.prepare("SELECT COUNT(*) as count FROM plugin_installs WHERE id = ?")
.get("my-plugin") as { count: number };
const localDb = new Database(join(projectDir, ".fusion"));
localDb.init();
const legacyCount = localDb
.prepare("SELECT COUNT(*) as count FROM plugins WHERE id = ?")
.get("my-plugin") as { count: number };
expect(installCount.count).toBe(1);
expect(legacyCount.count).toBe(0);
centralDb.close();
localDb.close();
});
});
describe("POST /api/plugins mode:install — negative paths", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
@@ -818,6 +949,39 @@ describe("GET /api/plugins/dashboard-views", () => {
view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" },
});
});
it("keeps dashboard-views payload separate from ui-slots payload", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
{
pluginId: "roadmap-planner",
view: {
viewId: "roadmaps",
label: "Roadmaps",
componentPath: "./dashboard-view",
},
},
]);
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue([
{
pluginId: "roadmap-planner",
slot: {
slotId: "task-detail-tab",
label: "Roadmap Details",
componentPath: "./task-detail.js",
},
},
]);
const viewsRes = await performGet(buildApp(), "/api/plugins/dashboard-views");
const slotsRes = await performGet(buildApp(), "/api/plugins/ui-slots");
expect(viewsRes.status).toBe(200);
expect(slotsRes.status).toBe(200);
expect(viewsRes.body[0]).toHaveProperty("view");
expect(viewsRes.body[0]).not.toHaveProperty("slot");
expect(slotsRes.body[0]).toHaveProperty("slot");
expect(slotsRes.body[0]).not.toHaveProperty("view");
});
});
describe("GET /api/plugins/ui-slots", () => {
@@ -1102,7 +1266,7 @@ describe("createPluginRouter plugin setup routes", () => {
expect(res.body).toEqual({ hasSetup: false });
});
it("returns plugin not loaded status when setup metadata exists but plugin is stopped", async () => {
it("returns deferred setup status when setup metadata exists but plugin is not started", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...INSTALLED_PLUGIN, state: "installed" });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
@@ -1116,9 +1280,12 @@ describe("createPluginRouter plugin setup routes", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual({
hasSetup: false,
status: { status: "error", error: "Plugin not loaded" },
hasSetup: true,
setupCheckDeferred: true,
deferredReason: "plugin-not-started",
pluginState: "installed",
});
expect(pluginRunner.checkPluginSetup).not.toHaveBeenCalled();
});
it("returns setup status when setup metadata exists and plugin is started", async () => {
@@ -1172,12 +1339,25 @@ describe("createPluginRouter plugin setup routes", () => {
});
describe("createPluginRouter plugin-defined route responses", () => {
it("injects request-scoped taskStore and supports explicit status/body responses", async () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(fusionCore, "getCreateAiSessionFactory").mockResolvedValue(undefined);
});
it("injects request-scoped taskStore and scoped plugin settings", async () => {
const defaultTaskStore = createMockTaskStore();
const scopedTaskStore = createMockTaskStore({ getRootDir: vi.fn().mockReturnValue("/scoped") });
const scopedPluginStore = createMockPluginStore({
getPlugin: vi.fn().mockResolvedValue({ ...INSTALLED_PLUGIN, id: "demo", settings: { mode: "scoped" } }),
});
const scopedTaskStore = createMockTaskStore({
getRootDir: vi.fn().mockReturnValue("/scoped"),
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
const pluginStore = createMockPluginStore();
const pluginStore = createMockPluginStore({
getPlugin: vi.fn().mockResolvedValue({ ...INSTALLED_PLUGIN, id: "demo", settings: { mode: "global" } }),
});
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
@@ -1190,7 +1370,7 @@ describe("createPluginRouter plugin-defined route responses", () => {
path: "/status",
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({
status: 201,
body: { scoped: ctx.taskStore.getRootDir() },
body: { scoped: ctx.taskStore.getRootDir(), mode: ctx.settings.mode },
})),
},
},
@@ -1203,7 +1383,103 @@ describe("createPluginRouter plugin-defined route responses", () => {
const res = await REQUEST(app, "POST", "/plugins/demo/status?projectId=p1", { projectId: "p1" });
expect(res.status).toBe(201);
expect(res.body).toEqual({ scoped: "/scoped" });
expect(res.body).toEqual({ scoped: "/scoped", mode: "scoped" });
});
it("falls back to global plugin settings when scoped plugin record is unavailable", async () => {
const scopedPluginStore = createMockPluginStore({
getPlugin: vi.fn().mockRejectedValue(new Error("missing")),
});
const scopedTaskStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
const pluginStore = createMockPluginStore({
getPlugin: vi.fn().mockResolvedValue({ ...INSTALLED_PLUGIN, id: "demo", settings: { mode: "global" } }),
});
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId: "demo",
route: {
method: "GET",
path: "/settings",
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({ mode: ctx.settings.mode })),
},
},
]),
};
const app = express();
app.use(express.json());
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
const res = await REQUEST(app, "GET", "/plugins/demo/settings?projectId=p1");
expect(res.status).toBe(200);
expect(res.body).toEqual({ mode: "global" });
});
it("includes createAiSession in plugin route context when engine has registered a factory", async () => {
const createAiSession = vi.fn();
vi.spyOn(fusionCore, "getCreateAiSessionFactory").mockResolvedValue(createAiSession as unknown as import("@fusion/core").CreateAiSessionFactory);
const pluginStore = createMockPluginStore();
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId: "demo",
route: {
method: "GET",
path: "/ai",
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({ hasFactory: Boolean(ctx.createAiSession) })),
},
},
]),
};
const app = express();
app.use(express.json());
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
const res = await REQUEST(app, "GET", "/plugins/demo/ai");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasFactory: true });
});
it("leaves createAiSession undefined when engine factory is unavailable", async () => {
vi.spyOn(fusionCore, "getCreateAiSessionFactory").mockResolvedValue(undefined);
const pluginStore = createMockPluginStore();
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId: "demo",
route: {
method: "GET",
path: "/ai-none",
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({ hasFactory: Boolean(ctx.createAiSession) })),
},
},
]),
};
const app = express();
app.use(express.json());
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
const res = await REQUEST(app, "GET", "/plugins/demo/ai-none");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasFactory: false });
});
it("maps plugin-defined non-2xx status responses", async () => {

View File

@@ -31,6 +31,10 @@ const {
mockListNodes,
mockGetNode,
mockEnsureMemoryFileWithBackend,
mockListProjectNodePathMappingsForProject,
mockGetProjectNodePathMapping,
mockUpsertProjectNodePathMapping,
mockRemoveProjectNodePathMapping,
} = vi.hoisted(() => ({
mockFsAccess: vi.fn().mockResolvedValue(undefined),
mockFsStat: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })),
@@ -89,6 +93,10 @@ const {
mockListNodes: vi.fn().mockResolvedValue([]),
mockGetNode: vi.fn().mockResolvedValue(null),
mockEnsureMemoryFileWithBackend: vi.fn().mockResolvedValue(true),
mockListProjectNodePathMappingsForProject: vi.fn().mockResolvedValue([]),
mockGetProjectNodePathMapping: vi.fn().mockResolvedValue(undefined),
mockUpsertProjectNodePathMapping: vi.fn(),
mockRemoveProjectNodePathMapping: vi.fn().mockResolvedValue(undefined),
}));
// Mock node:fs for route handler tests that check path existence
@@ -136,6 +144,10 @@ vi.mock("@fusion/core", async () => {
reconcileProjectStatuses: mockReconcileProjectStatuses,
listNodes: mockListNodes,
getNode: mockGetNode,
listProjectNodePathMappingsForProject: mockListProjectNodePathMappingsForProject,
getProjectNodePathMapping: mockGetProjectNodePathMapping,
upsertProjectNodePathMapping: mockUpsertProjectNodePathMapping,
removeProjectNodePathMapping: mockRemoveProjectNodePathMapping,
})),
ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend,
};
@@ -162,6 +174,10 @@ import {
updateGlobalConcurrency,
fetchProjectTasks,
fetchTasks,
fetchProjectPathMappings,
fetchProjectPathMapping,
upsertProjectPathMapping,
removeProjectPathMapping,
type ProjectInfo,
type DetectedProject,
} from "../../app/api.js";
@@ -356,6 +372,63 @@ describe("Project Routes API Functions", () => {
});
});
describe("project path mapping API clients", () => {
it("fetchProjectPathMappings encodes project id", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchProjectPathMappings("proj/test+id");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj%2Ftest%2Bid/path-mappings",
expect.any(Object),
);
});
it("fetchProjectPathMapping encodes project and node ids", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { projectId: "p", nodeId: "n", path: "/tmp", createdAt: "t", updatedAt: "t" }));
await fetchProjectPathMapping("proj/test", "node/a+b");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj%2Ftest/path-mappings/node%2Fa%2Bb",
expect.any(Object),
);
});
it("upsertProjectPathMapping sends PUT with path payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { projectId: "p", nodeId: "n", path: "/tmp", createdAt: "t", updatedAt: "t" }));
await upsertProjectPathMapping("proj_1", "node_1", "/tmp/worktree");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_1/path-mappings/node_1",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ path: "/tmp/worktree" }),
}),
);
});
it("removeProjectPathMapping sends DELETE", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
Promise.resolve({
ok: true,
status: 204,
statusText: "No Content",
headers: { get: () => null },
text: () => Promise.resolve(""),
} as unknown as Response),
);
await removeProjectPathMapping("proj_1", "node_1");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_1/path-mappings/node_1",
expect.objectContaining({ method: "DELETE" }),
);
});
});
describe("fetchProjectHealth", () => {
it("returns health metrics for a project", async () => {
const mockHealth = {
@@ -896,6 +969,137 @@ describe("GET /api/projects route handler", () => {
});
});
describe("project path mapping route handlers", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListProjectNodePathMappingsForProject.mockResolvedValue([]);
mockGetProjectNodePathMapping.mockResolvedValue(undefined);
mockUpsertProjectNodePathMapping.mockResolvedValue({
projectId: "proj_1",
nodeId: "node_1",
path: "/tmp/worktree",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
});
it("GET /api/projects/:id/path-mappings returns project mappings", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockListProjectNodePathMappingsForProject.mockResolvedValue([
{
projectId: "proj_1",
nodeId: "node_1",
path: "/tmp/worktree",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const res = await request(app, "GET", "/api/projects/proj_1/path-mappings");
expect(res.status).toBe(200);
expect(mockListProjectNodePathMappingsForProject).toHaveBeenCalledWith("proj_1");
});
it("GET /api/projects/:id/path-mappings returns 404 when project missing", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockListProjectNodePathMappingsForProject.mockRejectedValue(new Error("Project not found: proj_missing"));
const res = await request(app, "GET", "/api/projects/proj_missing/path-mappings");
expect(res.status).toBe(404);
});
it("GET /api/projects/:id/path-mappings/:nodeId returns 404 when mapping missing", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockGetProjectNodePathMapping.mockResolvedValue(undefined);
const res = await request(app, "GET", "/api/projects/proj_1/path-mappings/node_1");
expect(res.status).toBe(404);
});
it("GET /api/projects/:id/path-mappings/:nodeId returns mapping", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockGetProjectNodePathMapping.mockResolvedValue({
projectId: "proj_1",
nodeId: "node_1",
path: "/tmp/worktree",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const res = await request(app, "GET", "/api/projects/proj_1/path-mappings/node_1");
expect(res.status).toBe(200);
expect(mockGetProjectNodePathMapping).toHaveBeenCalledWith("proj_1", "node_1");
});
it("PUT /api/projects/:id/path-mappings/:nodeId validates absolute path", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
const res = await request(
app,
"PUT",
"/api/projects/proj_1/path-mappings/node_1",
JSON.stringify({ path: "relative/path" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(mockUpsertProjectNodePathMapping).not.toHaveBeenCalled();
});
it("PUT /api/projects/:id/path-mappings/:nodeId upserts mapping", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
const res = await request(
app,
"PUT",
"/api/projects/proj_1/path-mappings/node_1",
JSON.stringify({ path: "/tmp/worktree" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(mockUpsertProjectNodePathMapping).toHaveBeenCalledWith({
projectId: "proj_1",
nodeId: "node_1",
path: "/tmp/worktree",
});
});
it("DELETE /api/projects/:id/path-mappings/:nodeId deletes mapping", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
const res = await request(app, "DELETE", "/api/projects/proj_1/path-mappings/node_1");
expect(res.status).toBe(200);
expect(mockRemoveProjectNodePathMapping).toHaveBeenCalledWith({
projectId: "proj_1",
nodeId: "node_1",
});
});
it("DELETE /api/projects/:id/path-mappings/:nodeId is idempotent for missing mapping", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockRemoveProjectNodePathMapping.mockResolvedValue(undefined);
const res = await request(app, "DELETE", "/api/projects/proj_1/path-mappings/node_missing");
expect(res.status).toBe(200);
expect((res.body as { success: boolean }).success).toBe(true);
});
});
describe("PUT /api/global-concurrency route handler", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -134,6 +134,7 @@ describe("remote access headless parity", () => {
getMissionAutopilot: vi.fn(),
getMissionExecutionLoop: vi.fn(),
}),
getMessageStore: vi.fn().mockReturnValue(undefined),
getHeartbeatMonitor: vi.fn(),
getWorkingDirectory: vi.fn().mockReturnValue("/fake/root"),
getRoutineStore: vi.fn(),

View File

@@ -1,630 +1,21 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect } from "vitest";
import express from "express";
import { get as performGet, request as performRequest } from "../test-request.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { ApiError } from "../api-error.js";
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion/core";
import { registerIntegratedRouters } from "../routes/register-integrated-routers.js";
// vi.mock is hoisted
vi.mock("../roadmap-suggestions.js", () => {
// Define error classes inside the factory - these will be used by the mocked module
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
class MockServiceUnavailableError extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } }
return {
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateSuggestionInput: vi.fn(),
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateFeatureSuggestionInput: vi.fn(),
ValidationError: MockValidationError,
ParseError: MockParseError,
ServiceUnavailableError: MockServiceUnavailableError,
SUGGESTION_TIMEOUT_MS: 120_000,
};
});
const mockGetOrCreateProjectStore = vi.fn();
vi.mock("../project-store-resolver.js", () => ({
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
}));
function createMockRoadmapStore(): RoadmapStore {
const roadmaps = new Map<string, Roadmap>();
const milestones = new Map<string, RoadmapMilestone>();
const features = new Map<string, RoadmapFeature>();
return {
createRoadmap: vi.fn((input: { title: string; description?: string }) => {
const id = "RM-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const roadmap: Roadmap = { id, title: input.title, description: input.description, createdAt: now, updatedAt: now };
roadmaps.set(id, roadmap);
return roadmap;
}),
getRoadmap: vi.fn((id: string) => roadmaps.get(id)),
listRoadmaps: vi.fn(() => Array.from(roadmaps.values())),
updateRoadmap: vi.fn((id: string, updates: Partial<Roadmap>) => {
const roadmap = roadmaps.get(id);
if (!roadmap) throw new Error("Roadmap " + id + " not found");
const updated = { ...roadmap, ...updates, updatedAt: new Date().toISOString() };
roadmaps.set(id, updated);
return updated;
}),
deleteRoadmap: vi.fn((id: string) => { roadmaps.delete(id); }),
createMilestone: vi.fn((roadmapId: string, input: { title: string; description?: string }) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const id = "RMS-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const existingMilestones = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId);
const orderIndex = existingMilestones.length > 0 ? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1 : 0;
const milestone: RoadmapMilestone = { id, roadmapId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
milestones.set(id, milestone);
return milestone;
}),
getMilestone: vi.fn((id: string) => milestones.get(id)),
listMilestones: vi.fn((roadmapId: string) => Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex)),
updateMilestone: vi.fn((id: string, updates: Partial<RoadmapMilestone>) => {
const milestone = milestones.get(id);
if (!milestone) throw new Error("Milestone " + id + " not found");
const updated = { ...milestone, ...updates, updatedAt: new Date().toISOString() };
milestones.set(id, updated);
return updated;
}),
deleteMilestone: vi.fn((id: string) => { milestones.delete(id); }),
createFeature: vi.fn((milestoneId: string, input: { title: string; description?: string }) => {
const milestone = milestones.get(milestoneId);
if (!milestone) throw new Error("Milestone " + milestoneId + " not found");
const id = "RF-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const existingFeatures = Array.from(features.values()).filter((f) => f.milestoneId === milestoneId);
const orderIndex = existingFeatures.length > 0 ? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1 : 0;
const feature: RoadmapFeature = { id, milestoneId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
features.set(id, feature);
return feature;
}),
getFeature: vi.fn((id: string) => features.get(id)),
listFeatures: vi.fn((milestoneId: string) => Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex)),
updateFeature: vi.fn((id: string, updates: Partial<RoadmapFeature>) => {
const feature = features.get(id);
if (!feature) throw new Error("Feature " + id + " not found");
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
deleteFeature: vi.fn((id: string) => { features.delete(id); }),
reorderMilestones: vi.fn((input: { roadmapId: string; orderedMilestoneIds: string[] }) => {
const { roadmapId, orderedMilestoneIds } = input;
orderedMilestoneIds.forEach((id, index) => {
const milestone = milestones.get(id);
if (milestone) milestones.set(id, { ...milestone, orderIndex: index, updatedAt: new Date().toISOString() });
});
return Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
}),
reorderFeatures: vi.fn((input: { roadmapId: string; milestoneId: string; orderedFeatureIds: string[] }) => {
const { milestoneId, orderedFeatureIds } = input;
orderedFeatureIds.forEach((id, index) => {
const feature = features.get(id);
if (feature) features.set(id, { ...feature, orderIndex: index, updatedAt: new Date().toISOString() });
});
return Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex);
}),
moveFeature: vi.fn((input: { roadmapId: string; featureId: string; fromMilestoneId: string; toMilestoneId: string; targetOrderIndex: number }) => {
const { featureId, toMilestoneId, targetOrderIndex } = input;
const feature = features.get(featureId);
if (!feature) throw new Error("Feature " + featureId + " not found");
const updated: RoadmapFeature = { ...feature, milestoneId: toMilestoneId, orderIndex: targetOrderIndex, updatedAt: new Date().toISOString() };
features.set(featureId, updated);
return { movedFeature: updated, sourceMilestoneFeatures: [], targetMilestoneFeatures: [] };
}),
getMilestoneWithFeatures: vi.fn((id: string) => {
const milestone = milestones.get(id);
if (!milestone) return undefined;
return { ...milestone, features: [] };
}),
getRoadmapWithHierarchy: vi.fn((id: string) => {
const roadmap = roadmaps.get(id);
if (!roadmap) return undefined;
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === id).sort((a, b) => a.orderIndex - b.orderIndex);
return { ...roadmap, milestones: ms.map((m) => ({ ...m, features: [] })) };
}),
getRoadmapExport: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const allFeatures = ms.flatMap((m) => Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex));
return { roadmap, milestones: ms, features: allFeatures };
}),
getRoadmapMissionHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
getRoadmapFeatureHandoff: vi.fn((roadmapId: string, milestoneId: string, featureId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const milestone = milestones.get(milestoneId);
if (!milestone) throw new ApiError(500, "Milestone " + milestoneId + " not found");
if (milestone.roadmapId !== roadmapId) throw new ApiError(500, "Milestone " + milestoneId + " does not belong to roadmap " + roadmapId);
const feature = features.get(featureId);
if (!feature) throw new ApiError(500, "Feature " + featureId + " not found");
if (feature.milestoneId !== milestoneId) throw new ApiError(500, "Feature " + featureId + " does not belong to milestone " + milestoneId);
return {
source: {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
},
title: feature.title,
description: feature.description,
};
}),
getMissionPlanningHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
listFeatureTaskPlanningHandoffs: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const handoffs = [];
for (const m of ms) {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
for (const f of fs) {
handoffs.push({
source: {
roadmapId: roadmap.id,
milestoneId: m.id,
featureId: f.id,
roadmapTitle: roadmap.title,
milestoneTitle: m.title,
milestoneOrderIndex: m.orderIndex,
featureOrderIndex: f.orderIndex,
},
title: f.title,
description: f.description,
});
}
}
return handoffs;
}),
} as unknown as RoadmapStore;
}
describe("Roadmap Routes", () => {
let app: express.Express;
let mockStore: { getRoadmapStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
let mockRoadmapStore: ReturnType<typeof createMockRoadmapStore>;
beforeEach(() => {
mockRoadmapStore = createMockRoadmapStore();
mockStore = {
getRoadmapStore: vi.fn(() => mockRoadmapStore),
getRootDir: vi.fn(() => "/test/root"),
};
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
app = express();
app.use(express.json());
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
// Add error handler for tests that check 404 responses
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
if (err instanceof Error) {
res.status(500).json({ error: err.message });
return;
}
res.status(500).json({ error: "Internal server error" });
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("GET /api/roadmaps", () => {
it("returns empty list when no roadmaps exist", async () => {
const response = await performGet(app, "/api/roadmaps");
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
describe("integrated roadmap routes removed", () => {
it("does not register a legacy /roadmaps mount", () => {
const router = express.Router();
registerIntegratedRouters({
router,
store: {} as never,
});
it("returns all roadmaps", async () => {
mockRoadmapStore.createRoadmap({ title: "Roadmap 1" });
mockRoadmapStore.createRoadmap({ title: "Roadmap 2" });
const response = await performGet(app, "/api/roadmaps");
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
});
});
const mountedPaths = (router as unknown as { stack?: Array<{ regexp?: { source?: string } }> }).stack
?.map((layer) => layer.regexp?.source ?? "")
?? [];
describe("POST /api/roadmaps", () => {
it("creates a new roadmap", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "New Roadmap" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.title).toBe("New Roadmap");
});
it("returns 400 when title is missing", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is empty", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is whitespace-only", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: " " }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title exceeds 200 characters", async () => {
const longTitle = "A".repeat(201);
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: longTitle }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("200 characters");
});
});
describe("GET /api/roadmaps/:roadmapId", () => {
it("returns roadmap with hierarchy", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Milestone 1" });
mockRoadmapStore.createFeature(milestone.id, { title: "Feature 1" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id);
expect(response.status).toBe(200);
expect(response.body.title).toBe("Test Roadmap");
expect(response.body.milestones).toHaveLength(1);
});
});
describe("PATCH /api/roadmaps/:roadmapId", () => {
it("updates roadmap title", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Original Title" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/" + roadmap.id, JSON.stringify({ title: "Updated Title" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated Title");
});
});
describe("DELETE /api/roadmaps/:roadmapId", () => {
it("deletes a roadmap", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/" + roadmap.id);
expect(response.status).toBe(204);
});
});
describe("POST /api/roadmaps/:roadmapId/milestones", () => {
it("creates a milestone with auto orderIndex", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones", JSON.stringify({ title: "New Milestone" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.roadmapId).toBe(roadmap.id);
expect(response.body.orderIndex).toBe(0);
});
});
describe("POST /api/roadmaps/:roadmapId/milestones/reorder", () => {
it("reorders milestones", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const m1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "First" });
const m2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Second" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: [m2.id, m1.id] }), { "Content-Type": "application/json" });
expect(response.status).toBe(204);
});
it("returns 400 when orderedMilestoneIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedMilestoneIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: ["id1", 123, "id3"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
});
describe("PATCH /api/roadmaps/milestones/:milestoneId", () => {
it("updates a milestone", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Original" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/milestones/" + milestone.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated");
});
});
describe("DELETE /api/roadmaps/milestones/:milestoneId", () => {
it("deletes a milestone", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/milestones/" + milestone.id);
expect(response.status).toBe(204);
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/features", () => {
it("creates a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({ title: "New Feature" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.title).toBe("New Feature");
});
it("returns 400 when title is missing", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/features/reorder", () => {
it("returns 400 when orderedFeatureIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedFeatureIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: [123, "id2"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
});
describe("PATCH /api/roadmaps/features/:featureId", () => {
it("updates a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Original" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/features/" + feature.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated");
});
});
describe("DELETE /api/roadmaps/features/:featureId", () => {
it("deletes a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/features/" + feature.id);
expect(response.status).toBe(204);
});
});
describe("projectId scoping", () => {
it("uses projectId from query param", async () => {
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
expect(response.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("test-project");
});
});
describe("GET /api/roadmaps/:roadmapId/export", () => {
it("returns export bundle with all entities", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Export Test", description: "Test desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "F1" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/export");
expect(response.status).toBe(200);
expect(response.body.roadmap.id).toBe(roadmap.id);
expect(response.body.roadmap.title).toBe("Export Test");
expect(response.body.milestones.length).toBe(1);
expect(response.body.features.length).toBe(1);
expect(response.body.features[0].id).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff", () => {
it("returns both mission and feature handoffs", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Combined Handoff" });
const milestone1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const milestone2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 2" });
const feature1 = mockRoadmapStore.createFeature(milestone1.id, { title: "Feature A" });
const feature2 = mockRoadmapStore.createFeature(milestone2.id, { title: "Feature B" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
// Verify mission handoff structure
expect(response.body.mission).toBeDefined();
expect(response.body.mission.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.mission.title).toBe("Combined Handoff");
expect(response.body.mission.milestones).toHaveLength(2);
// Verify feature handoffs structure
expect(response.body.features).toBeDefined();
expect(response.body.features).toHaveLength(2);
expect(response.body.features[0].title).toBe("Feature A");
expect(response.body.features[0].source.milestoneId).toBe(milestone1.id);
expect(response.body.features[1].title).toBe("Feature B");
expect(response.body.features[1].source.milestoneId).toBe(milestone2.id);
});
it("returns empty features array when roadmap has no features", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Empty Handoff" });
mockRoadmapStore.createMilestone(roadmap.id, { title: "Empty Phase" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
expect(response.body.features).toHaveLength(0);
});
it("returns 404 when roadmap not found", async () => {
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff");
expect(response.status).toBe(404);
});
it("returns 404 for cross-project isolation", async () => {
// Create roadmap in default store
const roadmap = mockRoadmapStore.createRoadmap({ title: "Isolated Roadmap" });
// Mock a different project store that returns no roadmap
mockGetOrCreateProjectStore.mockResolvedValueOnce({
getRoadmapStore: vi.fn(() => ({
getMissionPlanningHandoff: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
listFeatureTaskPlanningHandoffs: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
})),
getRootDir: vi.fn(() => "/test/root"),
});
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff?projectId=other-project");
expect(response.status).toBe(404);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
it("returns mission handoff payload", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff/mission");
expect(response.status).toBe(200);
expect(response.body.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.title).toBe("Mission Handoff");
expect(response.body.description).toBe("Mission desc");
expect(response.body.milestones.length).toBe(1);
expect(response.body.milestones[0].sourceMilestoneId).toBe(milestone.id);
expect(response.body.milestones[0].features.length).toBe(1);
expect(response.body.milestones[0].features[0].sourceFeatureId).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", () => {
it("returns task handoff payload for feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Feature Handoff" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A", description: "Feature desc" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/milestones/" + milestone.id + "/features/" + feature.id + "/handoff/task");
expect(response.status).toBe(200);
expect(response.body.source.roadmapId).toBe(roadmap.id);
expect(response.body.source.milestoneId).toBe(milestone.id);
expect(response.body.source.featureId).toBe(feature.id);
expect(response.body.source.roadmapTitle).toBe("Feature Handoff");
expect(response.body.source.milestoneTitle).toBe("Phase 1");
expect(response.body.title).toBe("Feature A");
expect(response.body.description).toBe("Feature desc");
});
});
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
it("returns 503 when generation times out", async () => {
// Import the mocked module
const mod = await import("../roadmap-suggestions.js");
// Create an instance of the mocked ServiceUnavailableError
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
// Mock to throw ServiceUnavailableError with timeout message
(mod.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/" + roadmap.id + "/suggestions/milestones",
JSON.stringify({ goalPrompt: "Build a platform", count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("timed out");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
it("returns 503 when generation times out", async () => {
// Import the mocked module - vi.mocked helps with type inference
const mod = vi.mocked(await import("../roadmap-suggestions.js"));
// Create an instance of the mocked ServiceUnavailableError
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
// Mock to throw ServiceUnavailableError with timeout message
mod.generateFeatureSuggestions.mockRejectedValue(error);
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/milestones/" + milestone.id + "/suggestions/features",
JSON.stringify({ count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("timed out");
});
expect(mountedPaths.some((path) => path.includes("roadmaps"))).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1862,6 +1862,7 @@ describe("Agent create/update routes", () => {
reportsTo: agentId,
runtimeConfig: { heartbeatIntervalMs: 60000 },
permissions: { read: true },
permissionPolicy: { presetId: "approval-required" },
instructionsPath: "docs/reviewer.md",
instructionsText: "Check test quality.",
soul: "Analytical and thorough.",
@@ -1879,6 +1880,16 @@ describe("Agent create/update routes", () => {
reportsTo: agentId,
runtimeConfig: { heartbeatIntervalMs: 60000 },
permissions: { read: true },
permissionPolicy: {
presetId: "approval-required",
rules: {
git_write: "require-approval",
file_write_delete: "require-approval",
command_execution: "require-approval",
network_api: "require-approval",
task_agent_mutation: "require-approval",
},
},
instructionsPath: "docs/reviewer.md",
instructionsText: "Check test quality.",
soul: "Analytical and thorough.",
@@ -1900,6 +1911,7 @@ describe("Agent create/update routes", () => {
runtimeConfig: { heartbeatTimeoutMs: 120000 },
pauseReason: "manual",
permissions: { deploy: true },
permissionPolicy: { presetId: "locked-down" },
totalInputTokens: 42,
totalOutputTokens: 21,
instructionsPath: "agents/infra.md",
@@ -1921,6 +1933,16 @@ describe("Agent create/update routes", () => {
runtimeConfig: { heartbeatTimeoutMs: 120000 },
pauseReason: "manual",
permissions: { deploy: true },
permissionPolicy: {
presetId: "locked-down",
rules: {
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
},
},
totalInputTokens: 42,
totalOutputTokens: 21,
instructionsPath: "agents/infra.md",
@@ -1929,6 +1951,67 @@ describe("Agent create/update routes", () => {
});
});
it("POST /api/agents rejects invalid permissionPolicy preset", async () => {
const res = await REQUEST(
buildAgentApp(),
"POST",
"/api/agents",
JSON.stringify({
name: "Invalid Policy Agent",
role: "executor",
permissionPolicy: { presetId: "custom" },
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("permissionPolicy.presetId");
});
it("PATCH /api/agents/:id rejects invalid permissionPolicy payload shape", async () => {
const res = await REQUEST(
buildAgentApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
permissionPolicy: "bad",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("permissionPolicy must be an object");
});
it("POST /api/agents normalizes policy rules from preset and ignores caller-supplied rules", async () => {
const res = await REQUEST(
buildAgentApp(),
"POST",
"/api/agents",
JSON.stringify({
name: "Normalized Policy Agent",
role: "executor",
permissionPolicy: {
presetId: "locked-down",
rules: {
"git-write": "allow",
},
},
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.permissionPolicy.presetId).toBe("locked-down");
expect(res.body.permissionPolicy.rules).toEqual({
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
});
});
it("POST /api/agents returns 409 for duplicate non-ephemeral names", async () => {
const first = await REQUEST(
buildAgentApp(),
@@ -2030,6 +2113,11 @@ describe("Agent create/update routes", () => {
});
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.updateAgentState(agentId, "idle");
const res = await REQUEST(
buildAgentApp(),
"POST",
@@ -3104,6 +3192,250 @@ describe("Messaging Routes", () => {
expect(res.body.id).toBe("msg-runtime-1");
});
it("triggers executeHeartbeat when wakeImmediately is true for agent recipients", async () => {
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-wake-1",
toType: "agent",
content: "wake now",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
await vi.waitFor(() => {
expect(executeHeartbeat).toHaveBeenCalledWith({
agentId: "agent-wake-1",
source: "on_demand",
triggerDetail: "wake-on-message",
});
});
});
it("FN-3751: returns 201 immediately without waiting for executeHeartbeat to resolve", async () => {
let heartbeatResolved = false;
const executeHeartbeat = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
heartbeatResolved = true;
resolve({ id: "run-delayed" });
}, 500);
}),
);
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const startedAt = Date.now();
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-wake-fast",
toType: "agent",
content: "wake without blocking send",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
const elapsedMs = Date.now() - startedAt;
expect(res.status).toBe(201);
expect(elapsedMs).toBeLessThan(100);
expect(heartbeatResolved).toBe(false);
await vi.waitFor(() => {
expect(executeHeartbeat).toHaveBeenCalledWith({
agentId: "agent-wake-fast",
source: "on_demand",
triggerDetail: "wake-on-message",
});
});
});
it("does not trigger executeHeartbeat when wakeImmediately is omitted/false or recipient is not an agent", async () => {
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const noWake = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-no-wake",
toType: "agent",
content: "normal message",
type: "user-to-agent",
}),
{ "Content-Type": "application/json" },
);
const userRecipient = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "dashboard",
toType: "user",
content: "user message",
type: "system",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(noWake.status).toBe(201);
expect(userRecipient.status).toBe(201);
expect(executeHeartbeat).not.toHaveBeenCalled();
});
it("no-ops wakeImmediately when monitor root does not match and no scoped project context is provided", async () => {
const defaultExecuteHeartbeat = vi.fn().mockResolvedValue({ id: "run-default" });
const projectExecuteHeartbeat = vi.fn().mockResolvedValue({ id: "run-project" });
const engineManager = {
getAllEngines: vi.fn().mockReturnValue(new Map([
[
"project-1",
{
getWorkingDirectory: () => rootDir,
getHeartbeatMonitor: () => ({
executeHeartbeat: projectExecuteHeartbeat,
startRun: vi.fn(),
stopRun: vi.fn(),
}),
},
],
])),
};
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat: defaultExecuteHeartbeat, rootDir: join(rootDir, "other-project") } as any,
engineManager: engineManager as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-project-scope",
toType: "agent",
content: "wake in scoped project",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(defaultExecuteHeartbeat).not.toHaveBeenCalled();
expect(projectExecuteHeartbeat).not.toHaveBeenCalled();
});
it("returns created message even when wakeImmediately execution throws", async () => {
const executeHeartbeat = vi.fn().mockRejectedValue(new Error("wake failed"));
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-wake-failure",
toType: "agent",
content: "wake best effort",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.toId).toBe("agent-wake-failure");
await vi.waitFor(() => {
expect(executeHeartbeat).toHaveBeenCalledTimes(1);
});
});
it("supports metadata.wakeRecipient as an immediate-wake request", async () => {
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-wake-meta",
toType: "agent",
content: "wake via metadata",
type: "user-to-agent",
metadata: { wakeRecipient: true },
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
await vi.waitFor(() => {
expect(executeHeartbeat).toHaveBeenCalledWith({
agentId: "agent-wake-meta",
source: "on_demand",
triggerDetail: "wake-on-message",
});
});
});
it("gracefully no-ops wakeImmediately when no heartbeat monitor is configured", async () => {
const res = await REQUEST(
app,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-no-monitor",
toType: "agent",
content: "wake request without monitor",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.toId).toBe("agent-no-monitor");
});
it("GET /api/messages/inbox returns dashboard inbox messages", async () => {
const inboxMessage = messageStore.sendMessage({
fromId: "agent-1",
@@ -3141,6 +3473,7 @@ describe("Messaging Routes", () => {
const unread = await GET(app, "/api/messages/unread-count");
expect(unread.body.unreadCount).toBe(3);
expect(unread.body.pendingApprovalCount).toBe(0);
const readAll = await REQUEST(app, "POST", "/api/messages/read-all");
expect(readAll.status).toBe(200);
@@ -3194,6 +3527,42 @@ describe("Messaging Routes", () => {
expect(unread).toBeDefined();
expect(res.status).toBe(200);
expect(res.body.unreadCount).toBe(1);
expect(res.body.pendingApprovalCount).toBe(0);
});
it("GET /api/messages/unread-count includes pendingApprovalCount and excludes resolved approvals", async () => {
const { ApprovalRequestStore } = await import("@fusion/core");
const approvalStore = new ApprovalRequestStore(store.getDatabase());
const pending = approvalStore.create({
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent One" },
targetAction: {
category: "command_execution",
action: "npm test",
summary: "Run tests",
resourceType: "command",
resourceId: "npm test",
},
});
const resolved = approvalStore.create({
requester: { actorId: "agent-2", actorType: "agent", actorName: "Agent Two" },
targetAction: {
category: "command_execution",
action: "npm run lint",
summary: "Run lint",
resourceType: "command",
resourceId: "npm run lint",
},
});
approvalStore.decide(resolved.id, "denied", {
actor: { actorId: "user", actorType: "user", actorName: "User" },
});
const res = await GET(app, "/api/messages/unread-count");
expect(pending).toBeDefined();
expect(res.status).toBe(200);
expect(res.body.pendingApprovalCount).toBe(1);
});
it("POST /api/messages validates required fields and creates messages", async () => {
@@ -3231,6 +3600,16 @@ describe("Messaging Routes", () => {
},
message: "metadata.replyTo.messageId must be a non-empty string",
},
{
body: {
toId: "agent-1",
toType: "agent",
content: "x",
type: "user-to-agent",
wakeImmediately: "yes",
},
message: "wakeImmediately must be a boolean",
},
];
for (const testCase of invalidCases) {
@@ -3419,11 +3798,14 @@ describe("Agent stale task-link sanitization", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
let routeDb: Database;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-stale-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
routeDb = new Database(fusionDir, { inMemory: false });
routeDb.init();
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
@@ -3436,12 +3818,14 @@ describe("Agent stale task-link sanitization", () => {
});
afterEach(() => {
routeDb.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAgentApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getDatabase: vi.fn().mockReturnValue(routeDb),
} as any);
const app = express();
app.use(express.json());
@@ -3449,6 +3833,88 @@ describe("Agent stale task-link sanitization", () => {
return app;
}
async function createPendingApproval(requesterId: string) {
const { ApprovalRequestStore } = await import("@fusion/core");
const approvalStore = new ApprovalRequestStore(routeDb);
approvalStore.create({
requester: { actorId: requesterId, actorType: "agent", actorName: "Executor" },
targetAction: {
category: "command_execution",
action: "npm test",
summary: "Run tests",
resourceType: "command",
resourceId: "npm test",
},
});
}
it("GET /api/agents returns pendingApprovalCount=0 when no pending approvals exist", async () => {
const app = buildAgentApp();
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent.pendingApprovalCount).toBe(0);
});
it("GET /api/agents and /api/agents/:id include pendingApprovalCount for pending approvals", async () => {
const app = buildAgentApp();
await createPendingApproval(agentId);
await createPendingApproval(agentId);
const listRes = await GET(app, "/api/agents");
expect(listRes.status).toBe(200);
const agents = Array.isArray(listRes.body) ? listRes.body : [listRes.body];
const listed = agents.find((a: { id: string }) => a.id === agentId);
expect(listed).toBeDefined();
expect(listed.pendingApprovalCount).toBe(2);
const detailRes = await GET(app, `/api/agents/${agentId}`);
expect(detailRes.status).toBe(200);
expect(detailRes.body.pendingApprovalCount).toBe(2);
});
it("GET /api/agents pendingApprovalCount excludes approvals that are no longer pending", async () => {
const app = buildAgentApp();
const { ApprovalRequestStore } = await import("@fusion/core");
const approvalStore = new ApprovalRequestStore(routeDb);
const request = approvalStore.create({
requester: { actorId: agentId, actorType: "agent", actorName: "Executor" },
targetAction: {
category: "command_execution",
action: "npm test",
summary: "Run tests",
resourceType: "command",
resourceId: "npm test",
},
});
approvalStore.decide(request.id, "approved", {
actor: { actorId: "user", actorType: "user", actorName: "User" },
});
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const listed = agents.find((a: { id: string }) => a.id === agentId);
expect(listed).toBeDefined();
expect(listed.pendingApprovalCount).toBe(0);
});
it("GET /api/agents pendingApprovalCount ignores approvals for missing agents", async () => {
const app = buildAgentApp();
await createPendingApproval("agent-missing");
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const listed = agents.find((a: { id: string }) => a.id === agentId);
expect(listed).toBeDefined();
expect(listed.pendingApprovalCount).toBe(0);
});
it("GET /api/agents omits taskId when linked task is done", async () => {
const doneTaskId = "FN-DONE";
const store = createMockStore({

View File

@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { get, request } from "../test-request.js";
const state = {
requests: new Map<string, any>(),
audits: new Map<string, any[]>(),
task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" },
agent: { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" },
};
class MockApprovalRequestStore {
constructor(_: unknown) {}
list(input: any = {}) {
let rows = [...state.requests.values()];
if (input.status) rows = rows.filter((r) => r.status === input.status);
const offset = input.offset ?? 0;
const limit = input.limit ?? rows.length;
return rows.slice(offset, offset + limit);
}
get(id: string) {
return state.requests.get(id) ?? null;
}
decide(id: string, status: "approved" | "denied", input?: { actor?: any; note?: string }) {
const req = state.requests.get(id);
if (!req) throw new Error("Approval request not found");
if (req.status !== "pending") throw new Error(`Invalid approval request transition: ${req.status} -> ${status}`);
req.status = status;
req.decidedAt = new Date().toISOString();
req.updatedAt = req.decidedAt;
state.audits.set(id, [...(state.audits.get(id) ?? []), {
id: `evt-${status}`,
eventType: status,
actor: input?.actor ?? { actorId: "user", actorType: "user", actorName: "User" },
note: input?.note,
createdAt: req.decidedAt,
}]);
return req;
}
getAuditHistory(id: string) {
return state.audits.get(id) ?? [];
}
}
const updateAgent = vi.fn(async (_id: string, updates: any) => ({ ...state.agent, ...updates }));
class MockAgentStore {
constructor(_: unknown) {}
async init() {}
async getAgent(id: string) {
return id === state.agent.id ? state.agent : null;
}
async updateAgentState(id: string, nextState: string) {
if (id === state.agent.id) state.agent = { ...state.agent, state: nextState };
}
async updateAgent(id: string, updates: any) {
if (id === state.agent.id) state.agent = { ...state.agent, ...updates };
return updateAgent(id, updates);
}
}
vi.mock("@fusion/core", () => ({
ApprovalRequestStore: MockApprovalRequestStore,
AgentStore: MockAgentStore,
}));
describe("approval routes", async () => {
const { registerApprovalRoutes } = await import("../routes/register-approval-routes.js");
function createApp() {
const router = express.Router();
router.use(express.json());
registerApprovalRoutes({
router,
runtimeLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() } as any,
getProjectContext: async () => ({
store: {
getDatabase: () => ({}),
getFusionDir: () => "/tmp/fusion",
getTask: async () => state.task,
pauseTask: async (_id: string, paused: boolean) => {
state.task = { ...state.task, paused, pausedByAgentId: paused ? state.task.pausedByAgentId : undefined };
},
},
engine: undefined,
projectId: "p1",
}),
rethrowAsApiError: (e: unknown) => {
throw e;
},
} as any);
const app = express();
app.use("/api", router);
app.use((err: any, _req: any, res: any, _next: any) => {
const status = err?.statusCode ?? 500;
res.status(status).json({ error: err?.message ?? String(err) });
});
return app;
}
beforeEach(() => {
updateAgent.mockClear();
const now = new Date().toISOString();
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" };
state.requests = new Map([
["apr-1", {
id: "apr-1",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: { category: "command_execution", summary: "Run command", action: "bash", resourceType: "command", resourceId: "cmd-1" },
taskId: "FN-1",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
["apr-2", {
id: "apr-2",
status: "denied",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: { category: "network_api", summary: "Fetch URL", action: "web_fetch", resourceType: "url", resourceId: "https://example.com" },
taskId: "FN-1",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
]);
state.audits = new Map([
["apr-1", [{ id: "evt-created", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]],
["apr-2", [{ id: "evt-denied", eventType: "denied", actor: { actorId: "dashboard", actorType: "user", actorName: "User" }, createdAt: now }]],
]);
});
it("lists with status filtering and pendingCount", async () => {
const app = createApp();
const res = await get(app, "/api/approvals?status=pending");
expect(res.status).toBe(200);
expect(res.body.total).toBe(1);
expect(res.body.pendingCount).toBe(1);
expect(res.body.requests).toHaveLength(1);
expect(res.body.requests[0]).toMatchObject({
id: "apr-1",
actionCategory: "command_execution",
actionSummary: "Run command",
agentId: "agent-1",
});
});
it("returns detail with history", async () => {
const app = createApp();
const res = await get(app, "/api/approvals/apr-1");
expect(res.status).toBe(200);
expect(res.body.id).toBe("apr-1");
expect(res.body.history).toHaveLength(1);
expect(res.body.targetAction.summary).toBe("Run command");
});
it("returns 404 for missing request", async () => {
const app = createApp();
const res = await get(app, "/api/approvals/missing");
expect(res.status).toBe(404);
});
it("decides approval and unpauses task/agent", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-1/decision",
JSON.stringify({ decision: "approve", comment: "looks good" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("approved");
expect(res.body.history.at(-1)?.eventType).toBe("approved");
expect(res.body.history.at(-1)?.note).toBe("looks good");
expect(state.task.paused).toBe(false);
expect(updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: undefined });
});
it("supports deny decision", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-1/decision",
JSON.stringify({ decision: "deny" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("denied");
});
it("returns 409 for invalid transition", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-2/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(409);
});
});

View File

@@ -2027,5 +2027,183 @@ describe("GET /tasks/:id/file-diffs", () => {
});
});
describe("GET /tasks/:id/review", () => {
let store: ReturnType<typeof createMockStore>;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns normalized reviewer-agent payload in direct mode", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "code review Step 2: REVISE" }],
});
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([
{
timestamp: "2026-05-01T10:00:01.000Z",
taskId: "FN-001",
type: "text",
text: "## Code Review:\n\n### Verdict: REVISE\n\n### Summary\nNeeds null guard\n",
agent: "reviewer",
},
]);
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
expect(res.status).toBe(200);
expect(res.body.mode).toBe("reviewer-agent");
expect(res.body.items[0].sourceMode).toBe("reviewer-agent");
expect(res.body.items[0].reviewState).toBe("REVISE");
});
it("returns exact empty payload/message when no reviewer feedback exists", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
log: [],
});
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
expect(res.status).toBe(200);
expect(res.body.mode).toBe("reviewer-agent");
expect(res.body.summary).toBeNull();
expect(res.body.items).toEqual([]);
});
it("falls back to task log summary when reviewer output is incomplete", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "plan review Step 1: APPROVE" }],
});
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([
{
timestamp: "2026-05-01T10:00:01.000Z",
taskId: "FN-001",
type: "text",
text: "partial stream",
agent: "reviewer",
},
]);
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
expect(res.status).toBe(200);
expect(res.body.items[0].title).toContain("plan review APPROVE");
expect(res.body.items[0].itemId).toContain("step-1");
});
it("returns 404 when task is missing", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-404/review");
expect(res.status).toBe(404);
});
});
describe("POST /tasks/:id/review/refresh", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("refreshes PR-backed review payload", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-1",
baseBranch: "main",
commentCount: 0,
},
});
vi.spyOn(GitHubClient.prototype, "getPrReviewDetails").mockResolvedValue({
mode: "pull-request",
refreshable: true,
fetchedAt: "2026-05-01T10:00:00.000Z",
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: ["needs work"], checks: [] },
items: [],
});
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.mode).toBe("pull-request");
});
it("returns scoped refresh error payload in PR mode when GitHub refresh fails", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-1",
baseBranch: "main",
commentCount: 0,
},
reviewState: { source: "pull-request", items: [], addressing: [] },
});
vi.spyOn(GitHubClient.prototype, "getPrReviewDetails").mockRejectedValue(new Error("GitHub outage"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(500);
expect(res.body.error).toContain("GitHub outage");
});
it("refreshes direct-mode review payload without PR", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewState: {
source: "reviewer-agent",
items: [],
addressing: [],
},
});
const getDetailsSpy = vi.spyOn(GitHubClient.prototype, "getPrReviewDetails");
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.mode).toBe("reviewer-agent");
expect(getDetailsSpy).not.toHaveBeenCalled();
});
it("returns 404 when task is missing", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-404/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(404);
});
});
// --- Git Management route tests ---
// These are integration tests that run against the actual git repository

View File

@@ -31,6 +31,8 @@ const mockGetSettingsSyncState = vi.fn();
const mockUpdateSettingsSyncState = vi.fn();
const mockApplyRemoteSettings = vi.fn();
const mockGetSettingsForSync = vi.fn();
const mockGetAuthMaterialSnapshot = vi.fn();
const mockApplyAuthMaterialSnapshot = vi.fn();
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
@@ -47,6 +49,8 @@ vi.mock("@fusion/core", () => {
updateSettingsSyncState = mockUpdateSettingsSyncState;
applyRemoteSettings = mockApplyRemoteSettings;
getSettingsForSync = mockGetSettingsForSync;
getAuthMaterialSnapshot = mockGetAuthMaterialSnapshot;
applyAuthMaterialSnapshot = mockApplyAuthMaterialSnapshot;
},
ChatStore: class MockChatStore {
init = mockChatStoreInit;
@@ -173,6 +177,17 @@ describe("Node settings sync routes", () => {
mockUpdateSettingsSyncState.mockResolvedValue({});
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockGetSettingsForSync.mockResolvedValue({});
mockGetAuthMaterialSnapshot.mockReturnValue({
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-test" } } },
});
mockApplyAuthMaterialSnapshot.mockReturnValue({
success: true,
authCount: 1,
providerAuth: { anthropic: { type: "api_key", key: "sk-ant-received" } },
});
mockAuthStorageSet.mockResolvedValue(undefined);
mockAuthStorageGetOAuthProviders.mockReturnValue([]);
@@ -636,11 +651,25 @@ describe("Node settings sync routes", () => {
it("emits structured redacted diagnostics for pull-mode auth sync", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockApplyAuthMaterialSnapshot.mockReturnValueOnce({
success: true,
authCount: 1,
providerAuth: {
google: { type: "api_key", key: "sk-pull-secret-123" },
},
});
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
providers: {
google: { type: "api_key", key: "sk-pull-secret-123" },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: {
providerAuth: {
google: { type: "api_key", key: "sk-pull-secret-123" },
},
},
},
sourceNodeId: "node-other",
timestamp: "2026-04-14T10:00:00.000Z",
@@ -781,8 +810,11 @@ describe("Node settings sync routes", () => {
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: {
anthropic: { type: "api_key", key: "sk-ant-received" },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-received" } } },
},
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
@@ -801,7 +833,12 @@ describe("Node settings sync routes", () => {
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant" } },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant" } } },
},
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
}),
@@ -819,7 +856,7 @@ describe("Node settings sync routes", () => {
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({ providers: "not-an-object" }),
JSON.stringify({ authMaterial: "not-an-object" }),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
@@ -835,7 +872,12 @@ describe("Node settings sync routes", () => {
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant-secret" } },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-secret" } } },
},
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
}),
@@ -890,11 +932,11 @@ describe("Node settings sync routes", () => {
);
expect(res.status).toBe(200);
expect(res.body.providers).toBeDefined();
expect(res.body.authMaterial).toBeDefined();
expect(res.body.sourceNodeId).toBe("node-local-001");
// The actual providers depend on what's in ~/.pi/agent/auth.json
// Just verify we got a providers object
expect(typeof res.body.providers).toBe("object");
// Just verify we got a providerAuth snapshot payload
expect(typeof res.body.authMaterial.payload.providerAuth).toBe("object");
});
it("returns 401 when auth header is missing", async () => {

View File

@@ -1400,6 +1400,7 @@ describe("Planning Mode Routes", () => {
title: "Edited auth task",
description: "Edited description from summary view",
dependencies: ["FN-500"],
priority: "normal",
}),
);
expect(store.updateTask).toHaveBeenCalledWith("FN-099", { size: "S" });
@@ -1473,6 +1474,130 @@ describe("Planning Mode Routes", () => {
expect(mockAiSessionStore.delete).toHaveBeenCalledWith(sessionId);
});
it("creates task with explicit summary priority", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-100",
description: "Priority task",
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 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;
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" });
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({
sessionId,
summary: {
title: "Priority auth task",
description: "High-priority planning output",
suggestedSize: "M",
priority: "high",
suggestedDependencies: [],
keyDeliverables: ["Login flow"],
},
}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: "Priority auth task",
priority: "high",
}),
);
});
it("creates multiple planning tasks with per-subtask priorities and defaults", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
id: "FN-201",
description: "First",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
})
.mockResolvedValueOnce({
id: "FN-202",
description: "Second",
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 startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const planningSessionId = startRes.body.sessionId;
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-tasks",
JSON.stringify({
planningSessionId,
subtasks: [
{
id: "subtask-1",
title: "Auth backend",
description: "Implement backend",
suggestedSize: "M",
priority: "urgent",
dependsOn: [],
},
{
id: "subtask-2",
title: "Auth UI",
description: "Implement UI",
suggestedSize: "S",
dependsOn: ["subtask-1"],
},
],
}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ title: "Auth backend", priority: "urgent" }),
);
expect(store.createTask).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ title: "Auth UI", priority: "normal" }),
);
});
it("returns 400 if session is not complete", async () => {
// Create a session but don't complete it
const startRes = await REQUEST(

View File

@@ -44,11 +44,20 @@ const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync, mockExecFile } = vi.hoisted(() => ({
const {
mockPerformUpdateCheck,
mockClearUpdateCheckCache,
mockExecSync,
mockExecFile,
mockReloadExemptTools,
mockGetExemptToolNames,
} = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(),
mockExecSync: vi.fn(),
mockExecFile: vi.fn(),
mockReloadExemptTools: vi.fn(),
mockGetExemptToolNames: vi.fn().mockReturnValue(["read", "find"]),
}));
vi.mock("../update-check.js", async () => {
@@ -111,6 +120,8 @@ vi.mock("@fusion/core", async (importOriginal) => {
vi.mock("@fusion/engine", async () => {
const { createEngineMock } = await import("../test/mockCoreEngine.js");
return createEngineMock({
reloadExemptTools: mockReloadExemptTools,
getExemptToolNames: mockGetExemptToolNames,
createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({
session: {
state: {
@@ -310,6 +321,54 @@ describe("route registrar ordering invariants", () => {
});
});
describe("POST /api/action-gate/reload", () => {
function buildApp(store: TaskStore, options?: Parameters<typeof createApiRoutes>[1]) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, options));
return app;
}
beforeEach(() => {
mockReloadExemptTools.mockReset();
mockGetExemptToolNames.mockReset();
mockGetExemptToolNames.mockReturnValue(["read", "find"]);
});
it("reloads defaults when no tools body is provided", async () => {
const store = createMockStore();
const res = await REQUEST(buildApp(store), "POST", "/api/action-gate/reload", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(mockReloadExemptTools).toHaveBeenCalledWith();
expect(res.body).toEqual({ ok: true, tools: ["read", "find"] });
});
it("reloads explicit tool list when tools are provided", async () => {
const store = createMockStore();
mockGetExemptToolNames.mockReturnValue(["custom_tool"]);
const res = await REQUEST(buildApp(store), "POST", "/api/action-gate/reload", JSON.stringify({ tools: ["custom_tool"] }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(mockReloadExemptTools).toHaveBeenCalledWith(["custom_tool"]);
expect(res.body).toEqual({ ok: true, tools: ["custom_tool"] });
});
it("returns bad request when tools is not a string array", async () => {
const store = createMockStore();
const res = await REQUEST(buildApp(store), "POST", "/api/action-gate/reload", JSON.stringify({ tools: ["ok", 1] }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(mockReloadExemptTools).not.toHaveBeenCalled();
});
});
describe("GET /api/system-stats", () => {
const projectId = "proj-system-stats";

View File

@@ -2054,6 +2054,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
let reviewerAgentId: string;
let store: TaskStore;
// Agent store init + createAgent is ~50ms per call; hoisted to beforeAll
@@ -2070,13 +2071,19 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
name: "Assignment test agent",
role: "executor",
});
const reviewer = await agentStore.createAgent({
name: "Assignment reviewer agent",
role: "reviewer",
});
agentId = agent.id;
reviewerAgentId = reviewer.id;
}, 30_000);
beforeEach(() => {
store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
updateTask: vi.fn(),
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-200", column: "todo" }),
listTasks: vi.fn().mockResolvedValue([]),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
} as any);
@@ -2109,6 +2116,39 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.body.assignedAgentId).toBe(agentId);
}, 20000);
it("returns 409 when assigning implementation task to non-executor without override", async () => {
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-200/assign",
JSON.stringify({ agentId: reviewerAgentId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("requires an \"executor\"-role agent");
expect(store.updateTask).not.toHaveBeenCalled();
}, 20000);
it("allows non-executor assignment when override is true", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-200",
assignedAgentId: reviewerAgentId,
});
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-200/assign",
JSON.stringify({ agentId: reviewerAgentId, override: true }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: reviewerAgentId });
}, 20000);
it("returns 404 when assigning to a non-existent agent", async () => {
const res = await REQUEST(
buildApp(),

View File

@@ -727,6 +727,66 @@ describe("POST /tasks", () => {
expect(res.body.error).toContain("nodeId must be a string");
});
it("retries reserved-id create when first reservation overlaps an existing task id", async () => {
const reserveDistributedTaskId = vi
.fn()
.mockResolvedValueOnce({ reservationId: "res-1", taskId: "FN-7001" })
.mockResolvedValueOnce({ reservationId: "res-2", taskId: "FN-7002" });
const commitDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
const abortDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
const createTaskWithReservedId = vi
.fn()
.mockRejectedValueOnce(new Error("Task ID already exists: FN-7001"))
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "FN-7002",
column: "triage",
createdAt: "2026-05-05T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
});
const deleteTask = vi.fn().mockResolvedValue(undefined);
const getTask = vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7002\n\nBig initiative\n" });
const storeWithReservedCreate = createMockStore({
createTaskWithReservedId,
deleteTask,
getTask,
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
reserveDistributedTaskId,
commitDistributedTaskIdReservation,
abortDistributedTaskIdReservation,
}),
});
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(storeWithReservedCreate));
const res = await REQUEST(
app,
"POST",
"/api/tasks",
JSON.stringify({ description: "Big initiative" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(createTaskWithReservedId).toHaveBeenCalledTimes(2);
expect(createTaskWithReservedId).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ description: "Big initiative" }),
expect.objectContaining({ taskId: "FN-7001" }),
);
expect(createTaskWithReservedId).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ description: "Big initiative" }),
expect.objectContaining({ taskId: "FN-7002" }),
);
expect(abortDistributedTaskIdReservation).toHaveBeenCalledTimes(1);
expect(abortDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-1", reason: "failed-create" }));
expect(commitDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-2" }));
expect(deleteTask).not.toHaveBeenCalled();
});
it("aborts reservation and deletes local task on replication failure", async () => {
const reserveDistributedTaskId = vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7002" });
const commitDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
@@ -768,6 +828,7 @@ describe("POST /tasks", () => {
expect(abortDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-1", reason: "failed-create" }));
expect(deleteTask).toHaveBeenCalledWith("FN-7002");
expect(commitDistributedTaskIdReservation).not.toHaveBeenCalled();
expect(reserveDistributedTaskId).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
mockCentralListNodes.mockResolvedValue([]);
});
@@ -2076,3 +2137,96 @@ describe("POST /subtasks/*", () => {
});
});
describe("POST /tasks/:id/review/address", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({ updateStep: vi.fn() } as unknown as Partial<TaskStore>);
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("resumes in-review tasks to in-progress using selected review payload", async () => {
const taskWithReview = {
...FAKE_TASK_DETAIL,
id: "FN-001",
column: "in-review",
status: "awaiting-user-review",
assignedAgentId: null,
steps: [{ id: "s1", title: "Step 1", status: "done" }],
reviewState: {
source: "reviewer-agent",
items: [{ id: "ri-1", body: "Fix tests", summary: "Fix tests", author: { login: "reviewer" }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }],
addressing: [],
},
};
const movedTask = { ...taskWithReview, column: "in-progress", status: null, sessionFile: null, assignedAgentId: null };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(taskWithReview).mockResolvedValueOnce({ ...taskWithReview, reviewState: { ...taskWithReview.reviewState, addressing: [{ itemId: "ri-1", status: "queued", selectedAt: new Date().toISOString() }] } });
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "reviewer-agent", summary: "Fix tests", body: "Fix tests" }] }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", { preserveProgress: true });
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
});
it("for in-progress tasks injects steering without moving task", async () => {
const taskWithReview = {
...FAKE_TASK_DETAIL,
id: "FN-001",
column: "in-progress",
sessionFile: "active.session.json",
reviewState: {
source: "pull-request",
items: [{ id: "ri-1", body: "Fix tests", summary: "Fix tests", author: { login: "reviewer" }, createdAt: new Date().toISOString(), path: "src/a.ts", line: 4 }],
addressing: [],
},
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(taskWithReview).mockResolvedValueOnce(taskWithReview);
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "pr-review", summary: "Fix tests", body: "Fix tests", filePath: "src/a.ts", lineNumber: 4 }] }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.moveTask).not.toHaveBeenCalled();
});
it("rejects empty selection", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-001", reviewState: { source: "reviewer-agent", items: [], addressing: [] } });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [] }), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("selectedItems must be a non-empty array");
});
it("rejects unsupported review source", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-001", reviewState: { source: "reviewer-agent", items: [{ id: "ri-1", body: "x", summary: "x", author: { login: "reviewer" }, createdAt: new Date().toISOString() }], addressing: [] } });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "other", summary: "x", body: "x" }] }), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Unsupported review source");
});
it("rejects source mismatch and unknown item ids", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-001",
reviewState: { source: "pull-request", items: [{ id: "ri-1", body: "x", summary: "x", author: { login: "reviewer" }, createdAt: new Date().toISOString() }], addressing: [] },
});
const mismatch = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "reviewer-agent", summary: "x", body: "x" }] }), { "Content-Type": "application/json" });
expect(mismatch.status).toBe(400);
expect(mismatch.body.error).toContain("does not match task review mode");
const unknown = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-2", source: "pr-review", summary: "x", body: "x" }] }), { "Content-Type": "application/json" });
expect(unknown.status).toBe(400);
expect(unknown.body.error).toContain("must reference existing review items");
expect(store.createTask).not.toHaveBeenCalled();
});
});

View File

@@ -446,6 +446,45 @@ describe("API Error Handling Middleware", () => {
expect(res.body).not.toContain("<html");
}
});
it("redirects /tasks/:id to canonical ?task query", async () => {
const app = createServer(store);
const res = await GET(app, "/tasks/FN-9999");
expect(res.status).toBe(301);
expect(res.headers.location).toBe("/?task=FN-9999");
});
it("preserves project query when redirecting /tasks/:id", async () => {
const app = createServer(store);
const res = await GET(app, "/tasks/FN-9999?project=demo");
expect(res.status).toBe(301);
expect(res.headers.location).toBe("/?task=FN-9999&project=demo");
});
it("does not redirect invalid /tasks/:id", async () => {
const app = createServer(store, { headless: true });
const res = await GET(app, "/tasks/not-a-task");
expect(res.status).toBe(404);
expect(res.headers.location).toBeUndefined();
});
it("keeps canonical query deep-link behavior unchanged", async () => {
const previousClientDir = process.env.FUSION_CLIENT_DIR;
process.env.FUSION_CLIENT_DIR = join(__dirname, "..", "..", "app");
try {
const app = createServer(store);
const res = await GET(app, "/?task=FN-9999");
expect(res.status).toBe(200);
expect(typeof res.body).toBe("string");
expect(res.body).toContain("<div id=\"root\"></div>");
} finally {
process.env.FUSION_CLIENT_DIR = previousClientDir;
}
});
});
describe("planning API route content types", () => {

View File

@@ -0,0 +1,136 @@
import { EventEmitter } from "node:events";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";
import { ChatStore, Database } from "@fusion/core";
import type { TaskStore } from "@fusion/core";
import { createSSE } from "../sse.js";
class MockSocket extends EventEmitter {
destroyed = false;
setKeepAlive = vi.fn();
destroy = vi.fn(() => {
if (this.destroyed) return;
this.destroyed = true;
this.emit("close");
});
}
class MockResponse extends EventEmitter {
headers = new Map<string, string>();
writableEnded = false;
destroyed = false;
write = vi.fn();
flushHeaders = vi.fn();
end = vi.fn(() => {
if (this.writableEnded) return;
this.writableEnded = true;
this.emit("close");
});
constructor(readonly socket: MockSocket) {
super();
}
setHeader(name: string, value: string): void {
this.headers.set(name, value);
}
}
function createMockStore(): TaskStore {
const researchStore = {
on: vi.fn(),
off: vi.fn(),
};
return {
on: vi.fn(),
off: vi.fn(),
getResearchStore: vi.fn(() => researchStore),
} as unknown as TaskStore;
}
function openSseConnection(chatStore: ChatStore) {
const store = createMockStore();
const socket = new MockSocket();
const req = new EventEmitter() as Request & { query: Record<string, string>; socket: MockSocket };
req.query = { clientId: "chat-room-events" };
req.socket = socket;
const res = new MockResponse(socket);
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(
req,
res as unknown as Response,
);
return { req, res };
}
afterEach(() => {
vi.useRealTimers();
});
describe("chat room SSE events", () => {
it("relays room lifecycle/member/message events and cleans up listeners", async () => {
const tempRoot = mkdtempSync(join(tmpdir(), "fusion-sse-chat-room-"));
const fusionDir = join(tempRoot, ".fusion");
const db = new Database(fusionDir, { inMemory: true });
db.init();
const chatStore = new ChatStore(fusionDir, db);
const { req, res } = openSseConnection(chatStore);
const room = chatStore.createRoom({
name: "engineering",
projectId: "proj-1",
createdBy: "agent-owner",
memberAgentIds: ["agent-owner"],
});
const member = chatStore.addRoomMember(room.id, "agent-2", "member");
const message = chatStore.addRoomMessage(room.id, {
role: "user",
content: "hello room",
senderAgentId: null,
mentions: [],
});
const updatedRoom = chatStore.updateRoom(room.id, { description: "updated" });
expect(updatedRoom).toBeDefined();
chatStore.removeRoomMember(room.id, "agent-2");
const attachmentUpdatedMessage = chatStore.addRoomMessageAttachment(room.id, message.id, {
id: "att-1",
filename: "doc.txt",
originalName: "doc.txt",
mimeType: "text/plain",
size: 3,
createdAt: new Date().toISOString(),
});
chatStore.deleteRoomMessage(message.id);
chatStore.deleteRoom(room.id);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:created\ndata: ${JSON.stringify(room)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:member:added\ndata: ${JSON.stringify(member)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:message:added\ndata: ${JSON.stringify(message)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:updated\ndata: ${JSON.stringify(updatedRoom)}\n\n`);
expect(res.write).toHaveBeenCalledWith(
`event: chat:room:member:removed\ndata: ${JSON.stringify({ roomId: room.id, agentId: "agent-2" })}\n\n`,
);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:message:updated\ndata: ${JSON.stringify(attachmentUpdatedMessage)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:message:deleted\ndata: ${JSON.stringify({ id: message.id })}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:deleted\ndata: ${JSON.stringify({ id: room.id })}\n\n`);
req.emit("close");
expect(EventEmitter.listenerCount(chatStore, "chat:room:created")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:updated")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:deleted")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:member:added")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:member:removed")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:message:added")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:message:updated")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:message:deleted")).toBe(0);
db.close();
await rm(tempRoot, { recursive: true, force: true });
});
});

View File

@@ -2,7 +2,13 @@ import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";
import type { TaskStore, AutomationStore } from "@fusion/core";
import { createSSE, disconnectSSEClient, getActiveSSEConnections, markSSEClientAlive } from "../sse.js";
import {
createSSE,
disconnectSSEClient,
emitApprovalSseEvent,
getActiveSSEConnections,
markSSEClientAlive,
} from "../sse.js";
class MockSocket extends EventEmitter {
destroyed = false;
@@ -101,6 +107,45 @@ afterEach(() => {
vi.useRealTimers();
});
describe("approval SSE events", () => {
it("relays approval events to connected clients", () => {
const connection = openSseConnection("approval-relay");
emitApprovalSseEvent("approval:requested", { id: "apr-1", status: "pending" });
emitApprovalSseEvent("approval:updated", { id: "apr-1", status: "pending" });
emitApprovalSseEvent("approval:decided", { id: "apr-1", status: "approved" });
expect(connection.res.write).toHaveBeenCalledWith(
`event: approval:requested\ndata: ${JSON.stringify({ id: "apr-1", status: "pending" })}\n\n`,
);
expect(connection.res.write).toHaveBeenCalledWith(
`event: approval:updated\ndata: ${JSON.stringify({ id: "apr-1", status: "pending" })}\n\n`,
);
expect(connection.res.write).toHaveBeenCalledWith(
`event: approval:decided\ndata: ${JSON.stringify({ id: "apr-1", status: "approved" })}\n\n`,
);
connection.req.emit("close");
});
it("filters project-scoped approval events to matching project connections", () => {
const projectA = openSseConnection("approval-project", "project-a");
const projectB = openSseConnection("approval-project", "project-b");
emitApprovalSseEvent("approval:requested", { id: "apr-a" }, "project-a");
expect(projectA.res.write).toHaveBeenCalledWith(
`event: approval:requested\ndata: ${JSON.stringify({ id: "apr-a" })}\n\n`,
);
expect(projectB.res.write).not.toHaveBeenCalledWith(
`event: approval:requested\ndata: ${JSON.stringify({ id: "apr-a" })}\n\n`,
);
projectA.req.emit("close");
projectB.req.emit("close");
});
});
describe("automation store SSE events", () => {
it("subscribes to all automation store events", () => {
const connection = openSseConnectionWithAutomation("automation-subscribe");

View File

@@ -318,11 +318,13 @@ describe("normalizeSubtaskItem", () => {
8,
);
// Priority now normalizes to "normal" when omitted.
expect(result).toEqual({
id: "subtask-9",
title: "Title",
description: "Description",
suggestedSize: "L",
priority: "normal",
dependsOn: ["subtask-1"],
});
});
@@ -341,6 +343,7 @@ describe("normalizeSubtaskItem", () => {
title: "Plan",
description: "Work",
suggestedSize: "M",
priority: "normal",
dependsOn: [],
});
});