Files
fusion/packages/dashboard/src/__tests__/routes-settings.test.ts
Fusion 95f6cd8457 feat(FN-3875): add GitHub tracking auth resolver and forced client auth mod
Implements a tracking auth resolver with forced GitHub client authentication mode, wiring it across the GitHub tracking lifecycle and settings UI. The feature spans six steps: adding the resolver, forced auth mode, routing tracking issue creation through the resolver, and wiring into lifecycle and s

Fusion-Task-Id: FN-3875
2026-05-10 05:20:26 -07:00

2763 lines
92 KiB
TypeScript

// @vitest-environment node
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { createHmac } from "node:crypto";
import { createApiRoutes } from "../routes.js";
import {
getProjectIdFromRequest as getProjectIdFromRouteRequest,
getProjectContext as resolveRouteProjectContext,
getScopedStore as resolveRouteScopedStore,
} from "../routes/context.js";
import { GitHubClient } from "../github.js";
import * as resolveDiffBaseModule from "../routes/resolve-diff-base.js";
import { githubRateLimiter } from "../github-poll.js";
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, ChatSession, ChatMessage } from "@fusion/core";
import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
import * as agentGenerationModule from "../agent-generation.js";
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
import * as planningModule from "../planning.js";
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "../subtask-breakdown.js";
import * as subtaskBreakdownModule from "../subtask-breakdown.js";
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "../ai-session-store.js";
import * as usageModule from "../usage.js";
import * as claudeCliProbeModule from "../claude-cli-probe.js";
import * as droidCliProbeModule from "../droid-cli-probe.js";
import * as projectStoreResolver from "../project-store-resolver.js";
import * as terminalServiceModule from "../terminal-service.js";
import { get as performGet, request as performRequest } from "../test-request.js";
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
import * as updateCheckModule from "../update-check.js";
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
// Mock @fusion/core for gh CLI auth checks
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, mockGetActiveNotificationService } = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(),
mockExecSync: vi.fn(),
mockExecFile: vi.fn(),
mockGetActiveNotificationService: vi.fn(),
}));
vi.mock("../update-check.js", async () => {
const actual = await vi.importActual<typeof import("../update-check.js")>("../update-check.js");
return {
...actual,
performUpdateCheck: mockPerformUpdateCheck,
clearUpdateCheckCache: mockClearUpdateCheckCache,
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
// Default execFile mock blocks host-process pgrep calls used by /kill-vitest
// but passes through all other commands (including git) to preserve route
// behavior for integration-style API tests in this file.
mockExecFile.mockImplementation((...callArgs: unknown[]) => {
const [file, argsOrCb, maybeOptions, maybeCb] = callArgs as [string, unknown, unknown, unknown];
const args = Array.isArray(argsOrCb) ? argsOrCb : [];
const cb =
typeof maybeCb === "function"
? (maybeCb as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof maybeOptions === "function"
? (maybeOptions as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof argsOrCb === "function"
? (argsOrCb as (err: unknown, stdout?: string, stderr?: string) => void)
: null;
if (file === "pgrep" && args[0] === "-f" && args[1] === "vitest") {
if (cb) queueMicrotask(() => cb(null, "", ""));
return;
}
return (actual.execFile as (...innerArgs: unknown[]) => unknown)(...callArgs);
});
return {
...actual,
execSync: mockExecSync,
execFile: mockExecFile,
};
});
vi.mock("@fusion/core", async (importOriginal) => {
const { createCoreMock } = await import("../test/mockCoreEngine.js");
return createCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-test"),
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false),
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
listProjects: mockCentralListProjects,
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
})),
});
});
vi.mock("@fusion/engine", async () => {
const { createEngineMock } = await import("../test/mockCoreEngine.js");
return createEngineMock({
createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({
session: {
state: {
messages: [] as Array<{ role: string; content: string }>,
},
prompt: vi.fn(async function (this: { state?: { messages?: Array<{ role: string; content: string }> } }, message: string) {
options?.onText?.("mock-ai-output");
const messages = this.state?.messages ?? [];
messages.push({ role: "user", content: message });
messages.push({
role: "assistant",
content: JSON.stringify({
subtasks: [
{
id: "subtask-1",
title: "Mock subtask",
description: "Generated by the route test engine mock",
suggestedSize: "S",
dependsOn: [],
},
],
}),
});
}),
dispose: vi.fn(),
},
})),
promptWithFallback: vi.fn(async (session: { prompt: (message: string) => Promise<void> }, prompt: string) => {
await session.prompt(prompt);
}),
getActiveNotificationService: mockGetActiveNotificationService,
AgentReflectionService: class MockAgentReflectionService {
async generateReflection(): Promise<import("@fusion/core").AgentReflection | null> {
throw new Error("Reflection service unavailable in route tests");
}
async buildReflectionContext(): Promise<never> {
throw new Error("Reflection service unavailable in route tests");
}
},
});
});
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { createFnAgent } from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
function createMockGlobalSettingsStore() {
return {
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.fusion/settings.json"),
init: vi.fn().mockResolvedValue(false),
invalidateCache: vi.fn(),
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
getTaskDocuments: vi.fn().mockResolvedValue([]),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
getAllDocuments: vi.fn().mockResolvedValue([]),
upsertTaskDocument: vi.fn(),
deleteTaskDocument: vi.fn().mockResolvedValue(undefined),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
...overrides,
} as unknown as TaskStore;
}
const TASK_TOKEN_USAGE_FIXTURE = {
inputTokens: 1200,
outputTokens: 450,
cachedTokens: 210,
totalTokens: 1860,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
};
const FAKE_TASK_DETAIL: TaskDetail = {
id: "FN-001",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
tokenUsage: TASK_TOKEN_USAGE_FIXTURE,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# KB-001\n\nTest task",
};
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
}
async function REQUEST(
app: express.Express,
method: string,
path: string,
body?: Buffer | string,
headers?: Record<string, string>,
): Promise<{ status: number; body: any }> {
const res = await performRequest(app, method, path, body, headers);
return { status: res.status, body: res.body };
}
function collectOrderedRouteKeys(router: express.Router): string[] {
const stack = (router as unknown as {
stack?: Array<{ route?: { path?: string; methods?: Record<string, boolean> } }>;
}).stack ?? [];
const orderedKeys: string[] = [];
for (const layer of stack) {
const route = layer.route;
if (!route?.path || !route.methods) continue;
const method = Object.keys(route.methods).find((name) => route.methods?.[name]);
if (!method) continue;
orderedKeys.push(`${method.toUpperCase()} ${route.path}`);
}
return orderedKeys;
}
afterEach(() => {
resetDiagnosticsSink();
});
import { DEFAULT_SETTINGS } from "@fusion/core";
describe("GET /settings", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
mockIsGhAvailable.mockReturnValue(false);
mockIsGhAuthenticated.mockReturnValue(false);
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { githubToken: "ghp_test_token" }));
return app;
}
it("returns persisted settings merged with defaults", async () => {
const persistedSettings = { maxConcurrent: 5, autoMerge: false };
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, ...persistedSettings });
const res = await GET(buildApp(), "/api/settings");
expect(res.status).toBe(200);
expect(res.body.maxConcurrent).toBe(5);
expect(res.body.autoMerge).toBe(false);
expect(res.body.pollIntervalMs).toBe(DEFAULT_SETTINGS.pollIntervalMs);
});
it("injects prAuthAvailable as true when gh is available and authenticated", async () => {
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue(DEFAULT_SETTINGS);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store)); // no githubToken option
const res = await GET(app, "/api/settings");
expect(res.status).toBe(200);
expect(res.body.prAuthAvailable).toBe(true);
expect(res.body.trackingAuthAvailable).toBe(true);
expect(res.body.trackingAuthReason).toBeNull();
expect(res.body.githubTokenConfigured).toBeUndefined();
});
it("injects prAuthAvailable as true when token fallback is configured", async () => {
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue(DEFAULT_SETTINGS);
const res = await GET(buildApp(), "/api/settings");
expect(res.status).toBe(200);
expect(res.body.prAuthAvailable).toBe(true);
expect(res.body.githubTokenConfigured).toBeUndefined();
});
it("injects prAuthAvailable as false when neither gh auth nor token fallback is available", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store)); // no githubToken option
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue(DEFAULT_SETTINGS);
const res = await GET(app, "/api/settings");
expect(res.status).toBe(200);
expect(res.body.prAuthAvailable).toBe(false);
expect(res.body.trackingAuthAvailable).toBe(false);
expect(res.body.trackingAuthReason).toBe("gh_not_installed");
expect(res.body.githubTokenConfigured).toBeUndefined();
});
it("returns defaultNodeId and unavailableNodePolicy when configured", async () => {
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
defaultNodeId: "node-abc",
unavailableNodePolicy: "fallback-local",
});
const res = await GET(buildApp(), "/api/settings");
expect(res.status).toBe(200);
expect(res.body.defaultNodeId).toBe("node-abc");
expect(res.body.unavailableNodePolicy).toBe("fallback-local");
});
it("returns 500 on store error", async () => {
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Config read failed"));
const res = await GET(buildApp(), "/api/settings");
expect(res.status).toBe(500);
expect(res.body.error).toContain("Config read failed");
});
});
describe("PUT /settings", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp(routeOptions: Parameters<typeof createApiRoutes>[1] = { githubToken: "ghp_test_token" }) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, routeOptions));
return app;
}
it("updates settings with valid payload", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 8 };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ maxConcurrent: 8 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8 });
});
it("updates settings with auto-backup enabled without logging routine sync failure", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-backup-routine-"));
const db = new Database(join(tempDir, ".fusion"));
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS routines (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
triggerType TEXT NOT NULL,
triggerConfig TEXT NOT NULL,
command TEXT,
enabled INTEGER DEFAULT 1,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '55')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.close();
const routineStore = new RoutineStore(tempDir);
await routineStore.init();
const updatedSettings = {
...DEFAULT_SETTINGS,
autoBackupEnabled: true,
autoBackupSchedule: "0 2 * * *",
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const runtimeEvents: Array<{ level: string; scope: string; message: string; context?: Record<string, unknown> }> = [];
setRuntimeLogSink((level, scope, message, context) => {
runtimeEvents.push({ level, scope, message, context });
});
try {
const res = await REQUEST(
buildApp({ githubToken: "ghp_test_token", routineStore }),
"PUT",
"/api/settings",
JSON.stringify({ autoBackupEnabled: true, autoBackupSchedule: "0 2 * * *" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(runtimeEvents.some((event) => event.message === "Failed to sync backup routine")).toBe(false);
} finally {
resetRuntimeLogSink();
rmSync(tempDir, { recursive: true, force: true });
}
});
it("updates defaultNodeId when provided", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, defaultNodeId: "node-abc" };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ defaultNodeId: "node-abc" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ defaultNodeId: "node-abc" });
});
it("clears defaultNodeId when null is provided", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, defaultNodeId: undefined };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ defaultNodeId: null }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ defaultNodeId: null });
});
it("updates unavailableNodePolicy to fallback-local", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, unavailableNodePolicy: "fallback-local" };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ unavailableNodePolicy: "fallback-local" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ unavailableNodePolicy: "fallback-local" });
});
it("updates unavailableNodePolicy to block", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, unavailableNodePolicy: "block" };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ unavailableNodePolicy: "block" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ unavailableNodePolicy: "block" });
});
it("strips server-owned fields before calling store.updateSettings", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 4 };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({
maxConcurrent: 4,
githubTokenConfigured: true,
prAuthAvailable: true,
trackingAuthAvailable: true,
trackingAuthReason: null,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
// The server should strip server-computed fields before passing to store
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 4 });
});
it("strips multiple server-owned fields if present", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, maxWorktrees: 10 };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({
maxWorktrees: 10,
githubTokenConfigured: true,
prAuthAvailable: true,
trackingAuthAvailable: false,
trackingAuthReason: "token_missing",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ maxWorktrees: 10 });
});
it("validates and forwards model presets", async () => {
const updatedSettings = {
...DEFAULT_SETTINGS,
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }],
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini", validatorProvider: undefined, validatorModelId: undefined }],
}));
});
it("resolves duplicate preset ids by auto-generating unique ids", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget" }, { id: "budget", name: "Budget 2" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
modelPresets: [
expect.objectContaining({ id: "budget", name: "Budget" }),
// "budget" collides; falls back to slug of name "Budget 2" → "budget-2"
expect.objectContaining({ id: "budget-2", name: "Budget 2" }),
],
}));
});
it("auto-generates preset id from name when id is omitted", async () => {
const updatedSettings = {
...DEFAULT_SETTINGS,
modelPresets: [{ id: "my-custom-preset", name: "My Custom Preset" }],
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ name: "My Custom Preset" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
modelPresets: [expect.objectContaining({ id: "my-custom-preset", name: "My Custom Preset" })],
}));
});
it("preserves explicit preset id when provided", async () => {
const updatedSettings = {
...DEFAULT_SETTINGS,
modelPresets: [{ id: "custom-id", name: "My Preset" }],
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "custom-id", name: "My Preset" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
modelPresets: [expect.objectContaining({ id: "custom-id", name: "My Preset" })],
}));
});
it("rejects incomplete model provider/modelId pairs", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId or neither");
});
it("rejects global-only fields with 400 error and helpful message", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ themeMode: "dark", maxConcurrent: 4 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("global settings");
expect(res.body.error).toContain("themeMode");
expect(res.body.error).toContain("/settings/global");
});
it("rejects when only global fields are sent", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("defaultProvider");
});
it("allows project-only fields to pass through successfully", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 8 };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ maxConcurrent: 8, autoMerge: false }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8, autoMerge: false });
});
it("accepts valid nested evalSettings payload", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
evalSettings: {
enabled: true,
intervalMs: 300000,
evaluatorProvider: "openai",
evaluatorModelId: "gpt-5",
followUpPolicy: "suggest-only",
retentionDays: 45,
},
});
const payload = {
evalSettings: {
enabled: true,
intervalMs: 300000,
evaluatorProvider: "openai",
evaluatorModelId: "gpt-5",
followUpPolicy: "suggest-only",
retentionDays: 45,
},
};
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify(payload), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(payload);
});
it("rejects invalid evalSettings payloads", async () => {
const invalidPayloads = [
{
payload: { evalSettings: { intervalMs: 59_999 } },
message: "evalSettings.intervalMs",
},
{
payload: { evalSettings: { retentionDays: 0 } },
message: "evalSettings.retentionDays",
},
{
payload: { evalSettings: { followUpPolicy: "create" } },
message: "evalSettings.followUpPolicy",
},
{
payload: { evalSettings: { evaluatorProvider: "openai" } },
message: "evalSettings.evaluatorProvider and evalSettings.evaluatorModelId",
},
];
for (const { payload, message } of invalidPayloads) {
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify(payload), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain(message);
}
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("accepts partial remoteAccess patches and GET /settings returns merged sibling branches", async () => {
const mergedRemoteAccess = {
enabled: true,
activeProvider: "tailscale",
providers: {
tailscale: {
enabled: true,
hostname: "tail.example.ts.net",
targetPort: 5173,
acceptRoutes: true,
},
cloudflare: {
enabled: false,
quickTunnel: false,
tunnelName: "existing-tunnel",
tunnelToken: null,
ingressUrl: "",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: null,
},
shortLived: {
enabled: false,
ttlMs: 900000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: false,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
};
const updatedSettings = {
...DEFAULT_SETTINGS,
remoteAccess: mergedRemoteAccess,
};
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const app = buildApp();
const patch = {
remoteAccess: {
activeProvider: "tailscale",
providers: {
tailscale: {
enabled: true,
hostname: "tail.example.ts.net",
targetPort: 5173,
acceptRoutes: true,
},
},
},
};
const updateRes = await REQUEST(
app,
"PUT",
"/api/settings/global",
JSON.stringify(patch),
{ "Content-Type": "application/json" },
);
expect(updateRes.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith(patch);
const getRes = await GET(app, "/api/settings");
expect(getRes.status).toBe(200);
expect(getRes.body.remoteAccess.providers.tailscale.hostname).toBe("tail.example.ts.net");
expect(getRes.body.remoteAccess.providers.cloudflare.tunnelName).toBe("existing-tunnel");
expect(getRes.body.remoteAccess.tokenStrategy.persistent.enabled).toBe(true);
expect(getRes.body.remoteAccess.lifecycle.rememberLastRunning).toBe(false);
});
it("validates auto-archive age settings", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ autoArchiveDoneAfterMs: 0 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("autoArchiveDoneAfterMs");
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("validates archive agent log mode", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ archiveAgentLogMode: "everything" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("archiveAgentLogMode");
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("accepts unavailableNodePolicy fallback-local", async () => {
const updatedSettings = {
...DEFAULT_SETTINGS,
unavailableNodePolicy: "fallback-local",
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ unavailableNodePolicy: "fallback-local" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ unavailableNodePolicy: "fallback-local" });
});
it("rejects invalid unavailableNodePolicy values", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ unavailableNodePolicy: "auto-retry" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("unavailableNodePolicy");
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("rejects non-string unavailableNodePolicy values", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ unavailableNodePolicy: 42 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("unavailableNodePolicy");
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("returns 500 on store update error", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ maxConcurrent: 3 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Write failed");
});
it("updates planning and validator model settings via store.updateSettings", async () => {
const updatedSettings = {
...DEFAULT_SETTINGS,
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
});
});
it("persists planning/validator settings and returns them via GET /settings", async () => {
// First, update the settings
const updatedSettings = {
...DEFAULT_SETTINGS,
planningProvider: "anthropic",
planningModelId: "claude-opus-4",
validatorProvider: "openai",
validatorModelId: "gpt-4-turbo",
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const updateRes = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({
planningProvider: "anthropic",
planningModelId: "claude-opus-4",
validatorProvider: "openai",
validatorModelId: "gpt-4-turbo",
}),
{ "Content-Type": "application/json" },
);
expect(updateRes.status).toBe(200);
// Then, verify GET /settings returns the persisted values
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const getRes = await GET(buildApp(), "/api/settings");
expect(getRes.status).toBe(200);
expect(getRes.body.planningProvider).toBe("anthropic");
expect(getRes.body.planningModelId).toBe("claude-opus-4");
expect(getRes.body.validatorProvider).toBe("openai");
expect(getRes.body.validatorModelId).toBe("gpt-4-turbo");
});
});
describe("GET /settings/global", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns global settings from the global settings store", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockResolvedValue({ themeMode: "light", colorTheme: "ocean" });
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
const res = await GET(buildApp(), "/api/settings/global");
expect(res.status).toBe(200);
expect(res.body.themeMode).toBe("light");
expect(res.body.colorTheme).toBe("ocean");
// Should NOT include server-only fields
expect(res.body.githubTokenConfigured).toBeUndefined();
});
it("returns 500 on global store error", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockRejectedValue(new Error("Read failed"));
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
const res = await GET(buildApp(), "/api/settings/global");
expect(res.status).toBe(500);
expect(res.body.error).toContain("Read failed");
});
});
describe("PUT /settings/global", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("updates global settings via store.updateGlobalSettings", async () => {
const updatedMerged = { themeMode: "light", maxConcurrent: 2 };
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedMerged);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings/global",
JSON.stringify({ themeMode: "light" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ themeMode: "light" });
});
it("accepts persistAgentToolOutput in global updates", async () => {
const updatedMerged = { persistAgentToolOutput: false };
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedMerged);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings/global",
JSON.stringify({ persistAgentToolOutput: false }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ persistAgentToolOutput: false });
expect(res.body.persistAgentToolOutput).toBe(false);
});
it("returns 500 on update error", async () => {
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings/global",
JSON.stringify({ themeMode: "light" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Write failed");
});
it("persists modelOnboardingComplete flag", async () => {
const updated = { modelOnboardingComplete: true };
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updated);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings/global",
JSON.stringify(updated),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
expect(res.body.modelOnboardingComplete).toBe(true);
});
it("GET /settings/global returns modelOnboardingComplete value", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockResolvedValue({ modelOnboardingComplete: true, themeMode: "dark" });
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
const res = await GET(buildApp(), "/api/settings/global");
expect(res.status).toBe(200);
expect(res.body.modelOnboardingComplete).toBe(true);
});
it("invalidates all global settings caches including engine stores", async () => {
const updated = { defaultModelId: "claude-3-5-sonnet" };
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updated);
// Create mock engine with GlobalSettingsStore spy
const engineGlobalStore = createMockGlobalSettingsStore();
const mockEngineStore = createMockStore();
(mockEngineStore.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(engineGlobalStore);
const mockEngine = {
getTaskStore: vi.fn().mockReturnValue(mockEngineStore),
};
// Create mock engine manager
const mockEngineManager = {
getAllEngines: vi.fn().mockReturnValue(new Map([["project-1", mockEngine]])),
getEngine: vi.fn(),
ensureEngine: vi.fn(),
};
// Spy on invalidateAllGlobalSettingsCaches
const invalidateAllSpy = vi.spyOn(projectStoreResolver, "invalidateAllGlobalSettingsCaches");
// Build app with engineManager option
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { engineManager: mockEngineManager as any }));
const res = await REQUEST(
app,
"PUT",
"/api/settings/global",
JSON.stringify(updated),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith(updated);
// Verify project-store-resolver caches are invalidated
expect(invalidateAllSpy).toHaveBeenCalledOnce();
// Verify engine store cache is invalidated
expect(mockEngine.getTaskStore).toHaveBeenCalled();
expect(engineGlobalStore.invalidateCache).toHaveBeenCalledOnce();
});
it("handles missing engineManager gracefully", async () => {
const updated = { defaultModelId: "claude-3-5-sonnet" };
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updated);
// Spy on invalidateAllGlobalSettingsCaches
const invalidateAllSpy = vi.spyOn(projectStoreResolver, "invalidateAllGlobalSettingsCaches");
// Build app WITHOUT engineManager option
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(
app,
"PUT",
"/api/settings/global",
JSON.stringify(updated),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith(updated);
// Should still invalidate project-store-resolver caches
expect(invalidateAllSpy).toHaveBeenCalledOnce();
});
});
describe("GET /settings/scopes", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns settings separated by scope", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: { themeMode: "dark", defaultProvider: "anthropic", persistAgentToolOutput: true },
project: { maxConcurrent: 4, autoMerge: false },
});
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(200);
expect(res.body.global.themeMode).toBe("dark");
expect(res.body.global.defaultProvider).toBe("anthropic");
expect(res.body.global.persistAgentToolOutput).toBe(true);
expect(res.body.project.maxConcurrent).toBe(4);
expect(res.body.project.autoMerge).toBe(false);
expect(res.body.project.persistAgentToolOutput).toBeUndefined();
});
it("returns exact response envelope shape with only global and project keys", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: { themeMode: "dark" },
project: { maxConcurrent: 4 },
});
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(200);
// Assert exact envelope shape
expect(res.body).toHaveProperty("global");
expect(res.body).toHaveProperty("project");
// No unexpected top-level keys
const keys = Object.keys(res.body);
expect(keys).toHaveLength(2);
expect(keys).toEqual(["global", "project"]);
});
it("global settings include model defaults", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: {
themeMode: "dark",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
},
project: {},
});
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(200);
expect(res.body.global.defaultProvider).toBe("anthropic");
expect(res.body.global.defaultModelId).toBe("claude-sonnet-4-5");
expect(res.body.global.planningGlobalProvider).toBe("google");
expect(res.body.global.planningGlobalModelId).toBe("gemini-2.5-pro");
});
it("project settings include execution and planning overrides", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: { themeMode: "dark" },
project: {
executionProvider: "openai",
executionModelId: "gpt-4o",
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
titleSummarizerProvider: "google",
titleSummarizerModelId: "gemini-2.5-pro",
},
});
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(200);
expect(res.body.project.executionProvider).toBe("openai");
expect(res.body.project.executionModelId).toBe("gpt-4o");
expect(res.body.project.planningProvider).toBe("anthropic");
expect(res.body.project.planningModelId).toBe("claude-sonnet-4-5");
expect(res.body.project.titleSummarizerProvider).toBe("google");
expect(res.body.project.titleSummarizerModelId).toBe("gemini-2.5-pro");
});
it("returns evalSettings in project scope only", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: { themeMode: "dark" },
project: {
evalSettings: {
enabled: true,
intervalMs: 300000,
followUpPolicy: "auto-create",
retentionDays: 14,
},
},
});
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(200);
expect(res.body.project.evalSettings).toEqual({
enabled: true,
intervalMs: 300000,
followUpPolicy: "auto-create",
retentionDays: 14,
});
expect(res.body.global.evalSettings).toBeUndefined();
});
it("returns remoteAccess only under project scope", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: { themeMode: "dark" },
project: {
remoteAccess: {
activeProvider: "tailscale",
providers: {
tailscale: {
enabled: true,
hostname: "tail.example.ts.net",
targetPort: 5173,
acceptRoutes: true,
},
cloudflare: {
enabled: false,
quickTunnel: false,
tunnelName: "",
tunnelToken: null,
ingressUrl: "",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: null,
},
shortLived: {
enabled: false,
ttlMs: 900000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: false,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
},
},
});
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(200);
expect(res.body.project.remoteAccess).toBeDefined();
expect(res.body.global.remoteAccess).toBeUndefined();
});
it("returns 500 on store error", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Failed"));
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(500);
expect(res.body.error).toContain("Failed");
});
});
describe("GET /settings/scopes with projectId scoping", () => {
const projectId = "proj-scopes-scoped";
let defaultStore: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultStore = createMockStore();
scopedStore = createMockStore();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
});
afterEach(() => {
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(defaultStore));
return app;
}
it("uses scoped store when projectId is provided", async () => {
(scopedStore.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
global: { themeMode: "light" },
project: { maxConcurrent: 8, planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
});
const res = await GET(buildApp(), `/api/settings/scopes?projectId=${projectId}`);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getSettingsByScope).toHaveBeenCalled();
expect(defaultStore.getSettingsByScope).not.toHaveBeenCalled();
expect(res.body.project.maxConcurrent).toBe(8);
expect(res.body.project.planningProvider).toBe("anthropic");
});
it("does not leak default store values into scoped response", async () => {
// Default store with conflicting values
(defaultStore.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
global: { defaultProvider: "default-global-provider", defaultModelId: "default-global-model" },
project: { maxConcurrent: 2 },
});
// Scoped store with different values
(scopedStore.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
global: { themeMode: "dark" },
project: { maxConcurrent: 6 },
});
const res = await GET(buildApp(), `/api/settings/scopes?projectId=${projectId}`);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
// Verify scoped store was used exclusively
expect(scopedStore.getSettingsByScope).toHaveBeenCalled();
expect(defaultStore.getSettingsByScope).not.toHaveBeenCalled();
// Verify values come from scoped store
expect(res.body.project.maxConcurrent).toBe(6);
// No leakage from default store
expect(res.body.global.defaultProvider).toBeUndefined();
});
it("returns project settings from scoped store only", async () => {
(scopedStore.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
global: { themeMode: "light" },
project: {
executionProvider: "scoped-execution-provider",
executionModelId: "scoped-execution-model",
planningProvider: "scoped-planning-provider",
planningModelId: "scoped-planning-model",
},
});
const res = await GET(buildApp(), `/api/settings/scopes?projectId=${projectId}`);
expect(res.status).toBe(200);
expect(scopedStore.getSettingsByScope).toHaveBeenCalled();
expect(res.body.project.executionProvider).toBe("scoped-execution-provider");
expect(res.body.project.executionModelId).toBe("scoped-execution-model");
expect(res.body.project.planningProvider).toBe("scoped-planning-provider");
expect(res.body.project.planningModelId).toBe("scoped-planning-model");
});
it("returns 500 when scoped store getSettingsByScope fails", async () => {
(scopedStore.getSettingsByScope as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Scoped store failed"));
const res = await GET(buildApp(), `/api/settings/scopes?projectId=${projectId}`);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Scoped store failed");
});
});
describe("POST /settings/test-ntfy", () => {
let store: TaskStore;
let fetchSpy: ReturnType<typeof vi.spyOn<any, any>>;
beforeEach(() => {
store = createMockStore();
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
});
afterEach(() => {
fetchSpy.mockRestore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("sends Fusion-branded test notification", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
// Verify the ntfy request uses Fusion branding
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0]?.[0] as string;
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(url).toBe("https://ntfy.sh/test-topic");
expect(options?.method).toBe("POST");
expect(options?.headers).toHaveProperty("Title", "Fusion test notification");
});
it("sends Fusion-branded body text", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
expect(res.status).toBe(200);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(options?.body).toBe("Fusion test notification — your notifications are working!");
});
it("uses configured ntfyBaseUrl from settings when present", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.internal.example///",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe("https://ntfy.internal.example/my-topic");
});
it("uses request ntfyBaseUrl override when provided", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: "https://ntfy.override.example//" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe("https://ntfy.override.example/my-topic");
});
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: " " }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe("https://ntfy.saved.example/my-topic");
});
it("returns 400 when ntfy is not enabled", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: false,
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
expect(res.status).toBe(400);
expect(res.body.error).toContain("not enabled");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns 400 when topic is missing", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: undefined,
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
expect(res.status).toBe(400);
expect(res.body.error).toContain("not configured or invalid");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns 400 when request ntfyBaseUrl uses non-http protocol", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: "ftp://ntfy.example.com" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("http:// or https://");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns 400 when request ntfyBaseUrl is malformed", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: "not-a-url" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("must be a valid URL");
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Memory Routes ─────────────────────────────────────────────
describe("GET /api/memory", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns memory content from the store", async () => {
// The memory endpoint uses readProjectFile from file-service
// which is mocked at the module level
const res = await GET(buildApp(), "/api/memory");
// Without mocking file-service, it will return empty or error
// This test validates the route exists and is reachable
expect([200, 500]).toContain(res.status);
});
});
describe("POST /settings/test-notification", () => {
let store: TaskStore;
let fetchSpy: ReturnType<typeof vi.spyOn<any, any>>;
beforeEach(() => {
store = createMockStore();
mockGetActiveNotificationService.mockReset();
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
});
afterEach(() => {
fetchSpy.mockRestore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("ntfy provider sends Fusion-branded test notification", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "ntfy" }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(fetchSpy).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
Title: "Fusion test notification",
}),
}),
);
});
it("ntfy provider dispatches a message-event pipeline test when messageEventType is provided", async () => {
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({ providerId: "ntfy", messageEventType: "message:agent-to-user" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(dispatchSpy).toHaveBeenCalledWith(
"message:agent-to-user",
expect.objectContaining({
event: "message:agent-to-user",
metadata: expect.objectContaining({
fromId: "system",
toId: "user",
preview: "Fusion test message notification",
}),
}),
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("ntfy provider uses config override for baseUrl", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({ providerId: "ntfy", ntfyBaseUrl: "https://ntfy.override.example//" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
});
it("ntfy provider returns 400 when ntfy not enabled", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ntfyEnabled: false });
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "ntfy" }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not enabled");
});
it("ntfy provider returns 400 when topic missing", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ntfyEnabled: true, ntfyTopic: undefined });
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "ntfy" }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
});
it("webhook provider sends test notification (generic format)", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
webhookEnabled: true,
webhookUrl: "https://hooks.example.com/test",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://hooks.example.com/test");
const payload = JSON.parse(String(options.body)) as Record<string, string>;
expect(payload.event).toBe("test");
expect(payload.message).toBe("Fusion test notification");
expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("webhook provider sends Slack format", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
webhookEnabled: true,
webhookUrl: "https://hooks.slack.com/test",
webhookFormat: "slack",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(String(options.body))).toEqual({
text: "Fusion test notification — your webhook notifications are working!",
});
});
it("webhook provider sends Discord format", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
webhookEnabled: true,
webhookUrl: "https://discord.com/api/webhooks/test",
webhookFormat: "discord",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(String(options.body))).toEqual({
content: "Fusion test notification — your webhook notifications are working!",
});
});
it("webhook provider uses config override for format", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
webhookEnabled: true,
webhookUrl: "https://hooks.example.com/test",
webhookFormat: "generic",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({ providerId: "webhook", webhookFormat: "slack" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(String(options.body))).toEqual({
text: "Fusion test notification — your webhook notifications are working!",
});
});
it("webhook provider returns 400 when not enabled", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ webhookEnabled: false });
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not enabled");
});
it("webhook provider returns 400 when URL missing", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ webhookEnabled: true, webhookUrl: undefined });
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not configured");
});
it("webhook provider returns 502 on server error", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
webhookEnabled: true,
webhookUrl: "https://hooks.example.com/test",
});
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500, statusText: "Internal Server Error" }));
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
"content-type": "application/json",
});
expect(res.status).toBe(502);
});
it("unknown provider returns 400", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "email" }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Unknown notification provider: email");
});
it("missing providerId returns 400", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("providerId");
});
it("backward compat — test-ntfy still works", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "compat-topic",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it("client→server contract for ntfy override via testNotification pattern", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({ providerId: "ntfy", ntfyBaseUrl: "https://ntfy.override.example/" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
});
});
describe("GET /api/memory/backend", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns current backend and capabilities", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryBackendType: "file",
memoryEnabled: true,
});
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("currentBackend");
expect(res.body).toHaveProperty("capabilities");
expect(res.body).toHaveProperty("availableBackends");
expect(Array.isArray(res.body.availableBackends)).toBe(true);
expect(res.body.availableBackends).toContain("file");
expect(res.body.availableBackends).toContain("readonly");
});
it("includes capabilities for file backend", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryBackendType: "file",
memoryEnabled: true,
});
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
expect(res.body.capabilities).toEqual({
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
});
});
it("includes capabilities for readonly backend", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryBackendType: "readonly",
memoryEnabled: true,
});
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
expect(res.body.currentBackend).toBe("readonly");
expect(res.body.capabilities).toEqual({
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: false,
});
});
it("defaults to qmd backend when no backend type is set", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryEnabled: true,
});
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
expect(res.body.currentBackend).toBe("qmd");
});
it("returns qmd backend for unknown custom backend type (fallback)", async () => {
// Unknown backend types are persisted but fallback to qmd at runtime
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryBackendType: "unknown-custom-backend",
memoryEnabled: true,
});
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
// currentBackend reflects the effective backend (qmd fallback)
expect(res.body.currentBackend).toBe("qmd");
// But availableBackends is still the list of registered backends
expect(res.body.availableBackends).toContain("file");
expect(res.body.availableBackends).toContain("readonly");
});
it("includes qmd backend in available backends when qmd is registered", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryBackendType: "file",
memoryEnabled: true,
});
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
// qmd should be in the available backends list
expect(res.body.availableBackends).toContain("qmd");
});
it("returns qmd backend capabilities when configured", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryBackendType: "qmd",
memoryEnabled: true,
});
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
expect(res.body.currentBackend).toBe("qmd");
// qmd has writable and persistent capabilities
expect(res.body.capabilities).toMatchObject({
readable: true,
writable: true,
persistent: true,
});
});
});
describe("PUT /api/memory", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 400 when content is not a string", async () => {
const res = await REQUEST(buildApp(), "PUT", "/api/memory", JSON.stringify({ content: 123 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("content must be a string");
});
it("returns 400 when content is missing", async () => {
const res = await REQUEST(buildApp(), "PUT", "/api/memory", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("content must be a string");
});
});
describe("POST /api/memory/compact", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-compact-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 400 when memory content is too short", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryEnabled: true,
memoryBackendType: "file",
});
writeFileSync(join(rootDir, ".fusion", "memory", "DREAMS.md"), "Short content");
const res = await REQUEST(buildApp(), "POST", "/api/memory/compact", JSON.stringify({ path: ".fusion/memory/DREAMS.md" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("too short to compact");
});
it("returns 409 for read-only memory backends before compacting", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryEnabled: true,
memoryBackendType: "readonly",
});
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "Long memory content.\n".repeat(20));
const res = await REQUEST(
buildApp(),
"POST",
"/api/memory/compact",
JSON.stringify({ path: ".fusion/memory/MEMORY.md" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("read-only");
});
});
describe("POST /api/memory/dream", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-dream-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "# Memory\n\nLong-term context");
writeFileSync(join(rootDir, ".fusion", "memory", `${new Date().toISOString().slice(0, 10)}.md`), "# Daily Memory\n\n- notable note");
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
getFusionDir: vi.fn().mockReturnValue(join(rootDir, ".fusion")),
getSettings: vi.fn().mockResolvedValue({
memoryEnabled: true,
memoryDreamsEnabled: true,
memoryBackendType: "file",
}),
});
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with dream results on success", async () => {
const session = {
prompt: vi.fn().mockImplementation(async function (this: { state: { messages: unknown[] } }) {
this.state.messages.push({
role: "assistant",
content: "## DREAMS\nSynthesis\n\n## LONG_TERM_UPDATES\nLesson",
});
}),
dispose: vi.fn(),
state: { messages: [] as unknown[] },
};
vi.mocked(createFnAgent).mockResolvedValue({ session } as never);
const res = await REQUEST(buildApp(), "POST", "/api/memory/dream", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
success: true,
dreamsWritten: true,
longTermUpdatesWritten: true,
});
});
it("returns 200 with empty results when no daily notes to process", async () => {
writeFileSync(join(rootDir, ".fusion", "memory", `${new Date().toISOString().slice(0, 10)}.md`), "");
const session = {
prompt: vi.fn().mockImplementation(async function (this: { state: { messages: unknown[] } }) {
this.state.messages.push({
role: "assistant",
content: "## DREAMS\n\n## LONG_TERM_UPDATES\n",
});
}),
dispose: vi.fn(),
state: { messages: [] as unknown[] },
};
vi.mocked(createFnAgent).mockResolvedValue({ session } as never);
const res = await REQUEST(buildApp(), "POST", "/api/memory/dream", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
success: true,
dreamsWritten: false,
longTermUpdatesWritten: false,
});
});
it("returns 400 when dreams are disabled in settings", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryEnabled: true,
memoryDreamsEnabled: false,
memoryBackendType: "file",
});
const res = await REQUEST(buildApp(), "POST", "/api/memory/dream", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Memory dreams are disabled");
});
it("returns 503 when AI service is unavailable", async () => {
vi.mocked(createFnAgent).mockRejectedValue(Object.assign(new Error("AI down"), { name: "AiServiceError" }));
const res = await REQUEST(buildApp(), "POST", "/api/memory/dream", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(503);
expect(res.body.error).toContain("AI");
});
it("parses assistant text from session.state when content is an array (regression: undefined .match crash)", async () => {
const session = {
prompt: vi.fn().mockImplementation(async function (this: { state: { messages: unknown[] } }) {
this.state.messages.push({
role: "assistant",
content: [{ text: "## DREAMS\nArrayContent\n\n## LONG_TERM_UPDATES\nArrayLesson" }],
});
}),
dispose: vi.fn(),
state: { messages: [] as unknown[] },
};
vi.mocked(createFnAgent).mockResolvedValue({ session } as never);
const res = await REQUEST(buildApp(), "POST", "/api/memory/dream", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
success: true,
dreamsWritten: true,
longTermUpdatesWritten: true,
});
});
it("returns 500 on unexpected processing failure", async () => {
vi.mocked(createFnAgent).mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("boom")),
dispose: vi.fn(),
},
} as never);
const res = await REQUEST(buildApp(), "POST", "/api/memory/dream", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(500);
expect(res.body.error).toContain("boom");
});
});
describe("GET /api/memory/insights", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-insights-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with content and exists:true when insights file exists", async () => {
// Insights file is at .fusion/memory/memory-insights.md
writeFileSync(join(rootDir, ".fusion", "memory", "memory-insights.md"), "## Patterns\n- Pattern 1\n- Pattern 2");
const res = await GET(buildApp(), "/api/memory/insights");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("content");
expect(res.body).toHaveProperty("exists", true);
expect(typeof res.body.content).toBe("string");
});
it("returns 200 with content:null and exists:false when insights file does not exist", async () => {
const res = await GET(buildApp(), "/api/memory/insights");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("content", null);
expect(res.body).toHaveProperty("exists", false);
});
});
describe("PUT /api/memory/insights", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-insights-write-"));
mkdirSync(join(rootDir, ".fusion"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with success:true for valid content", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/memory/insights",
JSON.stringify({ content: "## Patterns\n- New insight" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("success", true);
// Verify file was written (insights file is .fusion/memory/memory-insights.md)
const insightsPath = join(rootDir, ".fusion", "memory", "memory-insights.md");
expect(existsSync(insightsPath)).toBe(true);
});
it("returns 400 when content is missing", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/memory/insights",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("content must be a string");
});
it("returns 400 when content is not a string", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/memory/insights",
JSON.stringify({ content: 123 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("content must be a string");
});
});
describe("POST /api/memory/extract", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-extract-"));
mkdirSync(join(rootDir, ".fusion"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 400 when working memory is empty", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/memory/extract",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("No working memory");
});
it("returns 200 with extraction result on success", async () => {
// Working memory is at .fusion/memory/MEMORY.md
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "Working memory content for extraction that is long enough.");
const session = {
state: {
messages: [] as Array<{ role: string; content: string }>,
},
prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }) {
const response = JSON.stringify({
summary: "Extracted insights",
insights: [{ category: "pattern", content: "Persist reusable conventions" }],
prunedMemory: "## Architecture\n\nDurable architecture notes.",
});
this.state.messages.push({ role: "assistant", content: response });
return response;
}),
dispose: vi.fn(),
};
vi.mocked(createFnAgent).mockResolvedValue({ session } as never);
const res = await REQUEST(
buildApp(),
"POST",
"/api/memory/extract",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("success", true);
expect(res.body).toHaveProperty("summary");
expect(res.body).toHaveProperty("insightCount");
expect(res.body).toHaveProperty("pruned");
expect(existsSync(join(rootDir, ".fusion", "memory", "memory-insights.md"))).toBe(true);
expect(existsSync(join(rootDir, ".fusion", "memory", "memory-audit.md"))).toBe(true);
expect(existsSync(join(rootDir, ".fusion", "memory", "memory-audit-state.json"))).toBe(true);
});
});
describe("GET /api/memory/audit", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-audit-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with audit report shape", async () => {
const res = await GET(buildApp(), "/api/memory/audit");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("generatedAt");
expect(res.body).toHaveProperty("workingMemory");
expect(res.body).toHaveProperty("insightsMemory");
expect(res.body).toHaveProperty("extraction");
expect(res.body).toHaveProperty("pruning");
expect(res.body).toHaveProperty("checks");
expect(res.body).toHaveProperty("health");
expect(["healthy", "warning", "issues"]).toContain(res.body.health);
});
it("includes working memory stats in audit", async () => {
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "# Working Memory\n\nSome content.");
const res = await GET(buildApp(), "/api/memory/audit");
expect(res.status).toBe(200);
expect(res.body.workingMemory).toHaveProperty("exists");
expect(res.body.workingMemory).toHaveProperty("size");
expect(res.body.workingMemory).toHaveProperty("sectionCount");
});
it("preserves extraction metadata across extract then audit requests", async () => {
writeFileSync(
join(rootDir, ".fusion", "memory", "MEMORY.md"),
"## Architecture\n\nDurable architecture\n\n## Conventions\n\nDurable conventions\n\n## Pitfalls\n\nDurable pitfalls",
);
const extractRes = await REQUEST(
buildApp(),
"POST",
"/api/memory/extract",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(extractRes.status).toBe(200);
expect(extractRes.body.success).toBe(true);
const auditRes = await GET(buildApp(), "/api/memory/audit");
expect(auditRes.status).toBe(200);
expect(auditRes.body.extraction.runAt).toBeTruthy();
expect(auditRes.body.extraction.summary).not.toBe("No extraction runs recorded");
expect(auditRes.body.checks.find((check: { id: string; details: string }) => check.id === "recent-extraction")?.details).not.toContain("No extraction runs recorded");
});
});
describe("GET /api/memory/stats", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-stats-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with workingMemorySize, insightsSize, and insightsExists", async () => {
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "Working memory content.");
const res = await GET(buildApp(), "/api/memory/stats");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("workingMemorySize");
expect(res.body).toHaveProperty("insightsSize");
expect(res.body).toHaveProperty("insightsExists");
expect(typeof res.body.workingMemorySize).toBe("number");
expect(typeof res.body.insightsSize).toBe("number");
expect(typeof res.body.insightsExists).toBe("boolean");
});
it("returns insightsExists:false when insights file does not exist", async () => {
const res = await GET(buildApp(), "/api/memory/stats");
expect(res.status).toBe(200);
expect(res.body.insightsExists).toBe(false);
expect(res.body.insightsSize).toBe(0);
});
});
describe("PUT /api/settings - memoryBackendType validation", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("accepts memoryBackendType as string", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
memoryBackendType: "file",
});
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ memoryBackendType: "file" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({ memoryBackendType: "file" }));
});
it("accepts memoryBackendType as null for explicit clear", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
memoryBackendType: null,
});
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ memoryBackendType: null }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({ memoryBackendType: null }));
});
it("accepts memoryBackendType as unknown custom backend (persisted verbatim)", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
memoryBackendType: "custom-backend-v1",
});
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ memoryBackendType: "custom-backend-v1" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
// Unknown backend IDs should be persisted verbatim
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({ memoryBackendType: "custom-backend-v1" }));
});
it("accepts memoryBackendType as qmd", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
memoryBackendType: "qmd",
});
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ memoryBackendType: "qmd" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({ memoryBackendType: "qmd" }));
});
it("returns 400 when memoryBackendType is not string or null", async () => {
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ memoryBackendType: 123 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("memoryBackendType must be a string or null");
});
it("returns 400 when memoryBackendType is an object", async () => {
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ memoryBackendType: { type: "file" } }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("memoryBackendType must be a string or null");
});
});
// ── Workflow Step Routes ─────────────────────────────────────────────