Merge branch 'main' into timothyjlaurent/Anthropic-error
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
236
packages/dashboard/src/__tests__/chat-room-routes.test.ts
Normal file
236
packages/dashboard/src/__tests__/chat-room-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
218
packages/dashboard/src/__tests__/evals-routes.test.ts
Normal file
218
packages/dashboard/src/__tests__/evals-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
119
packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts
Normal file
119
packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
205
packages/dashboard/src/__tests__/routes-approval.test.ts
Normal file
205
packages/dashboard/src/__tests__/routes-approval.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
136
packages/dashboard/src/__tests__/sse-chat-rooms.test.ts
Normal file
136
packages/dashboard/src/__tests__/sse-chat-rooms.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user