feat(FN-1675): merge fusion/fn-1675

This commit is contained in:
gsxdsm
2026-04-15 18:23:05 -07:00
parent 7695497cfd
commit 6fc1a1bd4f
9 changed files with 974 additions and 27 deletions

View File

@@ -357,14 +357,41 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(201);
expect((response.body as any).session.id).toBe("chat-abc123");
// Model is resolved from agent's runtimeConfig.model
// projectId is null when no projectId query param is provided
expect(mockCreateSession).toHaveBeenCalledWith({
agentId: "agent-001",
title: null,
projectId: null,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
it("creates session with projectId when projectId query param is provided", async () => {
const sessionWithProject = {
...sampleSession,
projectId: "proj-001",
};
mockCreateSession.mockReturnValue(sessionWithProject);
const response = await request(
app,
"POST",
"/api/chat/sessions?projectId=proj-001",
JSON.stringify({ agentId: "agent-001" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect((response.body as any).session.projectId).toBe("proj-001");
// Verify projectId is passed to createSession
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "proj-001",
}),
);
});
it("creates session with title and resolves model from agent config", async () => {
const sessionWithOptions = {
...sampleSession,
@@ -387,6 +414,13 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(201);
expect((response.body as any).session.title).toBe("Custom Title");
// projectId is null when no projectId query param is provided
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
title: "Custom Title",
projectId: null,
}),
);
});
it("returns 400 when agentId is missing", async () => {
@@ -455,9 +489,11 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(201);
// No model resolved from agent config
// projectId is null when no projectId query param is provided
expect(mockCreateSession).toHaveBeenCalledWith({
agentId: "agent-002",
title: null,
projectId: null,
modelProvider: null,
modelId: null,
});

View File

@@ -276,6 +276,31 @@ describe("Roadmap Routes", () => {
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", () => {
@@ -325,6 +350,20 @@ describe("Roadmap Routes", () => {
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", () => {
@@ -354,6 +393,32 @@ describe("Roadmap Routes", () => {
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", () => {

View File

@@ -7817,6 +7817,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Create a new chat session.
* Body: { agentId: string, title?: string }
* The model is resolved from the agent's runtimeConfig.model setting.
* The session is scoped to the project identified by projectId query param or header.
*/
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
@@ -7825,7 +7826,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw internalError("Chat store not available");
}
const scopedStore = await getScopedStore(req);
// Get project context to scope the session and resolve agent from the correct store
const { store: scopedStore, projectId } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
@@ -7852,9 +7854,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : undefined;
const resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : undefined;
// Create the chat session with projectId for multi-project scoping
const session = chatStore.createSession({
agentId: agentId.trim(),
title: title?.trim() || null,
projectId: projectId ?? null,
modelProvider: resolvedProvider ?? null,
modelId: resolvedModelId ?? null,
});