feat(FN-1974): merge fusion/fn-1974
This commit is contained in:
@@ -1,26 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as coreModule from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const piMocks = vi.hoisted(() => ({
|
||||
createKbAgent: vi.fn(),
|
||||
promptWithFallback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/engine")>("@fusion/engine");
|
||||
return {
|
||||
...actual,
|
||||
createKbAgent: piMocks.createKbAgent,
|
||||
promptWithFallback: piMocks.promptWithFallback,
|
||||
};
|
||||
});
|
||||
|
||||
const mockListRuns = vi.fn().mockReturnValue([]);
|
||||
const mockGetRun = vi.fn();
|
||||
const mockCreateRun = vi.fn();
|
||||
const mockUpdateRun = vi.fn();
|
||||
const mockListInsights = vi.fn().mockReturnValue([]);
|
||||
const mockCountInsights = vi.fn().mockReturnValue(0);
|
||||
const mockGetInsight = vi.fn();
|
||||
const mockUpdateInsight = vi.fn();
|
||||
const mockDeleteInsight = vi.fn();
|
||||
const mockUpsertInsight = vi.fn();
|
||||
|
||||
const readWorkingMemorySpy = vi.spyOn(coreModule, "readWorkingMemory");
|
||||
const readInsightsMemorySpy = vi.spyOn(coreModule, "readInsightsMemory");
|
||||
const writeInsightsMemorySpy = vi.spyOn(coreModule, "writeInsightsMemory");
|
||||
const buildPromptSpy = vi.spyOn(coreModule, "buildInsightExtractionPrompt");
|
||||
const parseResponseSpy = vi.spyOn(coreModule, "parseInsightExtractionResponse");
|
||||
const mergeInsightsSpy = vi.spyOn(coreModule, "mergeInsights");
|
||||
const computeFingerprintSpy = vi.spyOn(coreModule, "computeInsightFingerprint");
|
||||
|
||||
const mockInsightStore = {
|
||||
listRuns: mockListRuns,
|
||||
getRun: mockGetRun,
|
||||
createRun: mockCreateRun,
|
||||
updateRun: mockUpdateRun,
|
||||
listInsights: mockListInsights,
|
||||
countInsights: mockCountInsights,
|
||||
getInsight: mockGetInsight,
|
||||
updateInsight: mockUpdateInsight,
|
||||
deleteInsight: mockDeleteInsight,
|
||||
upsertInsight: mockUpsertInsight,
|
||||
};
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
@@ -49,14 +76,80 @@ describe("Insights routes", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
let runRecord = {
|
||||
id: "IR-run-new",
|
||||
projectId: "",
|
||||
trigger: "manual" as const,
|
||||
status: "pending" as const,
|
||||
summary: null,
|
||||
error: null,
|
||||
insightsCreated: 0,
|
||||
insightsUpdated: 0,
|
||||
inputMetadata: {},
|
||||
outputMetadata: {},
|
||||
createdAt: "2026-04-16T00:00:00.000Z",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
mockListRuns.mockReturnValue([]);
|
||||
mockGetRun.mockReturnValue(null);
|
||||
mockCreateRun.mockReturnValue({ id: "IR-test-1", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
|
||||
mockCreateRun.mockImplementation((projectId: string, input: { trigger: "manual" }) => {
|
||||
runRecord = {
|
||||
...runRecord,
|
||||
projectId,
|
||||
trigger: input.trigger,
|
||||
};
|
||||
return { ...runRecord };
|
||||
});
|
||||
mockUpdateRun.mockImplementation((_id: string, input: Record<string, unknown>) => {
|
||||
runRecord = {
|
||||
...runRecord,
|
||||
...input,
|
||||
} as typeof runRecord;
|
||||
return { ...runRecord };
|
||||
});
|
||||
mockListInsights.mockReturnValue([]);
|
||||
mockCountInsights.mockReturnValue(0);
|
||||
mockGetInsight.mockReturnValue(null);
|
||||
mockUpdateInsight.mockReturnValue(null);
|
||||
mockDeleteInsight.mockReturnValue(false);
|
||||
mockUpsertInsight.mockImplementation((_projectId: string, input: { title: string; content: string; category: string; fingerprint: string; provenance: Record<string, unknown> }) => ({
|
||||
id: "INS-created-1",
|
||||
projectId: _projectId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
category: input.category,
|
||||
status: "confirmed",
|
||||
fingerprint: input.fingerprint,
|
||||
provenance: input.provenance,
|
||||
lastRunId: "IR-run-new",
|
||||
createdAt: "2026-04-16T00:10:00.000Z",
|
||||
updatedAt: "2026-04-16T00:10:00.000Z",
|
||||
}));
|
||||
|
||||
readWorkingMemorySpy.mockResolvedValue("Test working memory content");
|
||||
readInsightsMemorySpy.mockResolvedValue(null);
|
||||
writeInsightsMemorySpy.mockResolvedValue(undefined);
|
||||
buildPromptSpy.mockReturnValue("Test prompt");
|
||||
parseResponseSpy.mockReturnValue({
|
||||
summary: "Test extraction",
|
||||
insights: [],
|
||||
extractedAt: "2026-04-16T00:00:00.000Z",
|
||||
});
|
||||
mergeInsightsSpy.mockReturnValue("# merged insights");
|
||||
computeFingerprintSpy.mockImplementation((title: string, category: string) => `fp-${category}-${title.length}`);
|
||||
|
||||
piMocks.createKbAgent.mockImplementation((options: { onText?: (delta: string) => void }) => {
|
||||
options.onText?.('{"summary":"Test extraction","insights":[]}');
|
||||
return {
|
||||
session: {
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
piMocks.promptWithFallback.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Route ordering regression: static routes must not be shadowed by /:id ──
|
||||
@@ -78,8 +171,48 @@ describe("Insights routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/insights/run creates a run successfully (not shadowed by /:id)", async () => {
|
||||
mockCreateRun.mockReturnValue({ id: "IR-run-new", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
|
||||
it("POST /api/insights/run executes AI extraction and returns completed run", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({ trigger: "manual" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockCreateRun).toHaveBeenCalledWith("", { trigger: "manual", inputMetadata: undefined });
|
||||
expect(mockUpdateRun).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"IR-run-new",
|
||||
expect.objectContaining({ status: "running", startedAt: expect.any(String) }),
|
||||
);
|
||||
expect(readWorkingMemorySpy).toHaveBeenCalledWith("/tmp/fn-1909");
|
||||
expect(buildPromptSpy).toHaveBeenCalledWith("Test working memory content", null);
|
||||
expect(piMocks.createKbAgent).toHaveBeenCalledTimes(1);
|
||||
expect(piMocks.promptWithFallback).toHaveBeenCalledTimes(1);
|
||||
expect(parseResponseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateRun).toHaveBeenLastCalledWith(
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
insightsCreated: 0,
|
||||
insightsUpdated: 0,
|
||||
summary: "Test extraction",
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "IR-run-new",
|
||||
status: "completed",
|
||||
summary: "Test extraction",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run marks run failed when working memory is empty", async () => {
|
||||
readWorkingMemorySpy.mockResolvedValue(" \n ");
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
@@ -90,7 +223,119 @@ describe("Insights routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ id: "IR-run-new", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
|
||||
expect(mockUpdateRun).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
error: "No working memory to analyze",
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(piMocks.createKbAgent).not.toHaveBeenCalled();
|
||||
expect(piMocks.promptWithFallback).not.toHaveBeenCalled();
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "IR-run-new",
|
||||
status: "failed",
|
||||
error: "No working memory to analyze",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run marks run failed and returns 500 when AI execution errors", async () => {
|
||||
piMocks.promptWithFallback.mockRejectedValue(new Error("AI execution failed"));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({ trigger: "manual" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect((res.body as { error?: string }).error).toContain("AI execution failed");
|
||||
expect(mockUpdateRun).toHaveBeenLastCalledWith(
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
error: "AI execution failed",
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run persists generated insights and tracks created vs updated counts", async () => {
|
||||
const longContent = "x".repeat(110);
|
||||
parseResponseSpy.mockReturnValue({
|
||||
summary: "Generated two insights",
|
||||
insights: [
|
||||
{
|
||||
category: "pattern",
|
||||
content: "Prefer shared hooks for common dashboard logic",
|
||||
extractedAt: "2026-04-16T00:20:00.000Z",
|
||||
},
|
||||
{
|
||||
category: "pitfall",
|
||||
content: longContent,
|
||||
extractedAt: "2026-04-16T00:20:00.000Z",
|
||||
},
|
||||
],
|
||||
extractedAt: "2026-04-16T00:20:00.000Z",
|
||||
});
|
||||
mergeInsightsSpy.mockReturnValue("# merged result");
|
||||
|
||||
mockUpsertInsight
|
||||
.mockReturnValueOnce({
|
||||
id: "INS-created",
|
||||
projectId: "",
|
||||
title: "Prefer shared hooks for common dashboard logic",
|
||||
content: "Prefer shared hooks for common dashboard logic",
|
||||
category: "workflow",
|
||||
status: "confirmed",
|
||||
fingerprint: "fp-workflow-46",
|
||||
provenance: { trigger: "manual" },
|
||||
lastRunId: "IR-run-new",
|
||||
createdAt: "2026-04-16T00:20:01.000Z",
|
||||
updatedAt: "2026-04-16T00:20:01.000Z",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
id: "INS-updated",
|
||||
projectId: "",
|
||||
title: `${"x".repeat(100)}...`,
|
||||
content: longContent,
|
||||
category: "quality",
|
||||
status: "confirmed",
|
||||
fingerprint: "fp-quality-103",
|
||||
provenance: { trigger: "manual" },
|
||||
lastRunId: "IR-run-new",
|
||||
createdAt: "2026-04-16T00:00:00.000Z",
|
||||
updatedAt: "2026-04-16T00:20:01.000Z",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({ trigger: "manual" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(writeInsightsMemorySpy).toHaveBeenCalledWith("/tmp/fn-1909", "# merged result");
|
||||
expect(mockUpsertInsight).toHaveBeenCalledTimes(2);
|
||||
expect(computeFingerprintSpy).toHaveBeenNthCalledWith(1, "Prefer shared hooks for common dashboard logic", "workflow");
|
||||
expect(computeFingerprintSpy).toHaveBeenNthCalledWith(2, `${"x".repeat(100)}...`, "quality");
|
||||
expect(mockUpdateRun).toHaveBeenLastCalledWith(
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
insightsCreated: 1,
|
||||
insightsUpdated: 1,
|
||||
summary: "Generated two insights",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /api/insights/runs/:id returns run by id", async () => {
|
||||
|
||||
Reference in New Issue
Block a user