2422 lines
87 KiB
TypeScript
2422 lines
87 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 } = vi.hoisted(() => ({
|
|
mockPerformUpdateCheck: vi.fn(),
|
|
mockClearUpdateCheckCache: vi.fn(),
|
|
mockExecSync: vi.fn(),
|
|
mockExecFile: 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);
|
|
}),
|
|
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;
|
|
}
|
|
|
|
|
|
|
|
type GitTestRepo = {
|
|
root: string;
|
|
repoDir: string;
|
|
headSha: string;
|
|
};
|
|
|
|
let sharedGitTestRepo: GitTestRepo | null = null;
|
|
|
|
function getSharedGitTestRepo(): GitTestRepo {
|
|
if (sharedGitTestRepo) {
|
|
return sharedGitTestRepo;
|
|
}
|
|
|
|
const root = mkdtempSync(join(tmpdir(), "kb-dashboard-git-"));
|
|
const remoteDir = join(root, "remote.git");
|
|
const repoDir = join(root, "repo");
|
|
|
|
mkdirSync(repoDir, { recursive: true });
|
|
execFileSync("git", ["init", "--bare", "--initial-branch=main", remoteDir], { stdio: "pipe" });
|
|
execFileSync("git", ["init", "--initial-branch=main", repoDir], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
|
writeFileSync(join(repoDir, "README.md"), "# Test Repo\n");
|
|
execFileSync("git", ["-C", repoDir, "add", "README.md"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", repoDir, "commit", "-m", "Initial commit"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", repoDir, "branch", "-M", "main"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", repoDir, "remote", "add", "origin", remoteDir], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", repoDir, "push", "-u", "origin", "HEAD"], { stdio: "pipe" });
|
|
|
|
const headSha = execFileSync("git", ["-C", repoDir, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
sharedGitTestRepo = { root, repoDir, headSha };
|
|
return sharedGitTestRepo;
|
|
}
|
|
|
|
afterAll(() => {
|
|
if (sharedGitTestRepo) {
|
|
rmSync(sharedGitTestRepo.root, { recursive: true, force: true });
|
|
sharedGitTestRepo = null;
|
|
}
|
|
});
|
|
|
|
|
|
afterEach(() => {
|
|
resetDiagnosticsSink();
|
|
});
|
|
|
|
// Several nested describe blocks call `vi.restoreAllMocks()` in afterEach,
|
|
// which wipes the passthrough implementation installed in the `vi.mock` factory.
|
|
// Reinstalling here ensures real `git` / `pgrep` calls work for every test
|
|
// regardless of ordering.
|
|
beforeEach(async () => {
|
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
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);
|
|
});
|
|
});
|
|
|
|
|
|
describe("GET /github/issues/recent", () => {
|
|
let store: TaskStore;
|
|
let listIssuesSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore();
|
|
mockIsGhAuthenticated.mockReturnValue(true);
|
|
listIssuesSpy = vi.fn();
|
|
vi.spyOn(GitHubClient.prototype, "listIssues").mockImplementation(listIssuesSpy);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("returns filtered recent issues and caches responses", async () => {
|
|
const gitRepo = getSharedGitTestRepo();
|
|
execFileSync("git", ["-C", gitRepo.repoDir, "remote", "set-url", "origin", "https://github.com/owner/repo.git"], { stdio: "pipe" });
|
|
store.getRootDir = vi.fn().mockReturnValue(gitRepo.repoDir);
|
|
|
|
listIssuesSpy.mockResolvedValue([
|
|
{ number: 42, title: "Feature polish", body: null, html_url: "https://github.com/owner/repo/issues/42", labels: [], state: "open", updatedAt: "2026-05-16T00:00:00Z" },
|
|
{ number: 12, title: "Fix tests", body: null, html_url: "https://github.com/owner/repo/issues/12", labels: [], state: "closed", updatedAt: "2026-05-15T00:00:00Z" },
|
|
{ number: 99, title: "Draft PR", body: null, html_url: "https://github.com/owner/repo/pull/99", labels: [], state: "open", updatedAt: "2026-05-14T00:00:00Z" },
|
|
]);
|
|
|
|
const first = await REQUEST(buildApp(), "GET", "/api/github/issues/recent?q=feat&limit=20");
|
|
const second = await REQUEST(buildApp(), "GET", "/api/github/issues/recent?q=42&limit=20");
|
|
|
|
expect(first.status).toBe(200);
|
|
expect(first.body).toHaveLength(1);
|
|
expect(first.body[0]).toMatchObject({ number: 42, repository: "owner/repo", state: "open" });
|
|
|
|
expect(second.status).toBe(200);
|
|
expect(second.body).toHaveLength(1);
|
|
expect(second.body[0].number).toBe(42);
|
|
|
|
const all = await REQUEST(buildApp(), "GET", "/api/github/issues/recent");
|
|
expect(all.body.some((item: { number: number }) => item.number === 99)).toBe(false);
|
|
expect(listIssuesSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns empty list when no remote exists", async () => {
|
|
const res = await REQUEST(buildApp(), "GET", "/api/github/issues/recent");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual([]);
|
|
expect(listIssuesSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns empty list when not authenticated", async () => {
|
|
const gitRepo = getSharedGitTestRepo();
|
|
execFileSync("git", ["-C", gitRepo.repoDir, "remote", "set-url", "origin", "https://github.com/owner/repo.git"], { stdio: "pipe" });
|
|
store.getRootDir = vi.fn().mockReturnValue(gitRepo.repoDir);
|
|
mockIsGhAuthenticated.mockReturnValueOnce(false);
|
|
|
|
const res = await REQUEST(buildApp(), "GET", "/api/github/issues/recent");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual([]);
|
|
expect(listIssuesSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("POST /github/issues/fetch", () => {
|
|
let store: TaskStore;
|
|
let listIssuesSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore();
|
|
mockIsGhAuthenticated.mockReturnValue(true);
|
|
listIssuesSpy = vi.fn();
|
|
vi.spyOn(GitHubClient.prototype, "listIssues").mockImplementation(listIssuesSpy);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
const mockGitHubIssue = {
|
|
number: 1,
|
|
title: "Test Issue",
|
|
body: "Test body",
|
|
html_url: "https://github.com/owner/repo/issues/1",
|
|
labels: [{ name: "bug" }],
|
|
};
|
|
|
|
it("fetches issues successfully", async () => {
|
|
listIssuesSpy.mockResolvedValueOnce([mockGitHubIssue]);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(1);
|
|
expect(res.body[0].number).toBe(1);
|
|
expect(res.body[0].title).toBe("Test Issue");
|
|
});
|
|
|
|
it("returns 400 when owner is missing", async () => {
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ repo: "repo" }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("owner is required");
|
|
});
|
|
|
|
it("returns 400 when repo is missing", async () => {
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner" }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("repo is required");
|
|
});
|
|
|
|
it("returns 404 when repository not found", async () => {
|
|
listIssuesSpy.mockRejectedValueOnce(new Error("Repository not found: owner/repo"));
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toContain("Repository not found");
|
|
});
|
|
|
|
it("returns 401 when gh not authenticated", async () => {
|
|
mockIsGhAuthenticated.mockReturnValueOnce(false);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toContain("Not authenticated with GitHub");
|
|
expect(res.body.error).toContain("gh auth login");
|
|
});
|
|
|
|
it("returns 502 when gh CLI fails", async () => {
|
|
listIssuesSpy.mockRejectedValueOnce(new Error("Some gh CLI error"));
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(502);
|
|
expect(res.body.error).toContain("GitHub CLI error");
|
|
});
|
|
|
|
it("filters out pull requests (gh CLI already filters them)", async () => {
|
|
// gh issue list already filters out PRs, so we just verify the response
|
|
listIssuesSpy.mockResolvedValueOnce([mockGitHubIssue]);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo", limit: 10 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(1);
|
|
expect(res.body[0].number).toBe(1);
|
|
});
|
|
|
|
it("respects limit parameter", async () => {
|
|
const manyIssues = Array.from({ length: 50 }, (_, i) => ({ ...mockGitHubIssue, number: i + 1 }));
|
|
listIssuesSpy.mockResolvedValueOnce(manyIssues.slice(0, 10));
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo", limit: 10 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(10);
|
|
});
|
|
});
|
|
|
|
describe("POST /github/issues/import", () => {
|
|
let store: TaskStore;
|
|
let getIssueSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
mockIsGhAuthenticated.mockReturnValue(true);
|
|
getIssueSpy = vi.fn();
|
|
vi.spyOn(GitHubClient.prototype, "getIssue").mockImplementation(getIssueSpy);
|
|
|
|
store = createMockStore({
|
|
createTask: vi.fn().mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test Issue",
|
|
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
|
|
column: "triage",
|
|
}),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
const mockGitHubIssue = {
|
|
number: 1,
|
|
title: "Test Issue",
|
|
body: "Test body",
|
|
html_url: "https://github.com/owner/repo/issues/1",
|
|
state: "open",
|
|
};
|
|
|
|
it("imports a single issue successfully", async () => {
|
|
getIssueSpy.mockResolvedValueOnce(mockGitHubIssue);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.id).toBe("FN-001");
|
|
expect(store.createTask).toHaveBeenCalledWith({
|
|
title: "Test Issue",
|
|
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
|
|
column: "triage",
|
|
dependencies: [],
|
|
sourceIssue: {
|
|
provider: "github",
|
|
repository: "owner/repo",
|
|
externalIssueId: "1",
|
|
issueNumber: 1,
|
|
url: "https://github.com/owner/repo/issues/1",
|
|
},
|
|
source: {
|
|
sourceType: "github_import",
|
|
sourceMetadata: {
|
|
issueUrl: "https://github.com/owner/repo/issues/1",
|
|
issueNumber: 1,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("logs the import action", async () => {
|
|
getIssueSpy.mockResolvedValueOnce(mockGitHubIssue);
|
|
|
|
await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Imported from GitHub", "https://github.com/owner/repo/issues/1");
|
|
});
|
|
|
|
it("returns 400 when issueNumber is missing", async () => {
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo" }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("issueNumber is required");
|
|
});
|
|
|
|
it("returns 400 when issue not found or is a pull request", async () => {
|
|
// getIssue returns null for both "not found" and "PR" cases
|
|
getIssueSpy.mockResolvedValueOnce(null);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 999 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("is a pull request");
|
|
});
|
|
|
|
it("returns 401 when gh not authenticated", async () => {
|
|
mockIsGhAuthenticated.mockReturnValueOnce(false);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toContain("Not authenticated with GitHub");
|
|
expect(res.body.error).toContain("gh auth login");
|
|
});
|
|
|
|
it("returns 502 when gh CLI fails", async () => {
|
|
getIssueSpy.mockRejectedValueOnce(new Error("Some gh CLI error"));
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(502);
|
|
expect(res.body.error).toContain("GitHub CLI error");
|
|
});
|
|
|
|
it("returns 409 when issue already imported", async () => {
|
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
|
{
|
|
id: "FN-002",
|
|
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
|
column: "triage",
|
|
},
|
|
]);
|
|
|
|
getIssueSpy.mockResolvedValueOnce(mockGitHubIssue);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toContain("already imported");
|
|
expect(res.body.details?.existingTaskId).toBe("FN-002");
|
|
expect(store.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("truncates long titles to 200 chars", async () => {
|
|
const longTitleIssue = {
|
|
...mockGitHubIssue,
|
|
title: "A".repeat(250),
|
|
};
|
|
getIssueSpy.mockResolvedValueOnce(longTitleIssue);
|
|
|
|
await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
|
"Content-Type": "application/json",
|
|
});
|
|
|
|
expect(store.createTask).toHaveBeenCalledWith({
|
|
title: "A".repeat(200),
|
|
description: expect.stringContaining("Source:"),
|
|
column: "triage",
|
|
dependencies: [],
|
|
sourceIssue: {
|
|
provider: "github",
|
|
repository: "owner/repo",
|
|
externalIssueId: "1",
|
|
issueNumber: 1,
|
|
url: "https://github.com/owner/repo/issues/1",
|
|
},
|
|
source: {
|
|
sourceType: "github_import",
|
|
sourceMetadata: {
|
|
issueUrl: "https://github.com/owner/repo/issues/1",
|
|
issueNumber: 1,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("POST /github/issues/batch-import", () => {
|
|
let store: TaskStore;
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
__resetBatchImportRateLimiter();
|
|
|
|
fetchSpy = vi.fn();
|
|
globalThis.fetch = fetchSpy as any;
|
|
|
|
store = createMockStore({
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
createTask: vi.fn().mockImplementation((input) =>
|
|
Promise.resolve({
|
|
id: `KB-${String(Math.floor(Math.random() * 999)).padStart(3, "0")}`,
|
|
title: input.title,
|
|
description: input.description,
|
|
column: "triage",
|
|
})
|
|
),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
const mockGitHubIssue = (number: number, title = `Issue ${number}`) => ({
|
|
number,
|
|
title,
|
|
body: `Body for issue ${number}`,
|
|
html_url: `https://github.com/owner/repo/issues/${number}`,
|
|
labels: [{ name: "bug" }],
|
|
});
|
|
|
|
it("imports multiple issues successfully", async () => {
|
|
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: mockGitHubIssue(1, "First Issue"),
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: mockGitHubIssue(2, "Second Issue"),
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: mockGitHubIssue(3, "Third Issue"),
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(3);
|
|
expect(res.body.results.every((r: { success: boolean }) => r.success)).toBe(true);
|
|
expect(throttledSpy).toHaveBeenCalledTimes(3);
|
|
expect(store.createTask).toHaveBeenCalledTimes(3);
|
|
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
|
sourceIssue: {
|
|
provider: "github",
|
|
repository: "owner/repo",
|
|
externalIssueId: "1",
|
|
issueNumber: 1,
|
|
url: "https://github.com/owner/repo/issues/1",
|
|
},
|
|
}));
|
|
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
|
sourceIssue: {
|
|
provider: "github",
|
|
repository: "owner/repo",
|
|
externalIssueId: "2",
|
|
issueNumber: 2,
|
|
url: "https://github.com/owner/repo/issues/2",
|
|
},
|
|
}));
|
|
expect(store.createTask).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
|
sourceIssue: {
|
|
provider: "github",
|
|
repository: "owner/repo",
|
|
externalIssueId: "3",
|
|
issueNumber: 3,
|
|
url: "https://github.com/owner/repo/issues/3",
|
|
},
|
|
}));
|
|
});
|
|
|
|
it("skips already-imported issues", async () => {
|
|
// Mock issue 1 fetch
|
|
fetchSpy.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockGitHubIssue(1, "Already Imported Issue")),
|
|
} as Response);
|
|
|
|
// First import - should create a new task
|
|
const res1 = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res1.status).toBe(200);
|
|
expect(res1.body.results).toHaveLength(1);
|
|
expect(res1.body.results[0].success).toBe(true);
|
|
expect(res1.body.results[0].skipped).toBeUndefined();
|
|
const createdTaskId = res1.body.results[0].taskId;
|
|
expect(createdTaskId).toBeDefined();
|
|
|
|
// Now verify that if we import again with the task in the list, it gets skipped
|
|
// Update the listTasks mock to return the created task
|
|
const createdTaskDescription = `Already Imported Issue\n\nSource: https://github.com/owner/repo/issues/1`;
|
|
store.listTasks = vi.fn().mockResolvedValue([
|
|
{
|
|
id: createdTaskId,
|
|
description: createdTaskDescription,
|
|
column: "triage",
|
|
},
|
|
]);
|
|
|
|
// Second import - should skip
|
|
const res2 = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res2.status).toBe(200);
|
|
expect(res2.body.results).toHaveLength(1);
|
|
expect(res2.body.results[0].success).toBe(true);
|
|
expect(res2.body.results[0].skipped).toBe(true);
|
|
expect(res2.body.results[0].taskId).toBe(createdTaskId);
|
|
});
|
|
|
|
it("returns 400 for empty issueNumbers array", async () => {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [] }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("at least 1");
|
|
});
|
|
|
|
it("returns 400 for more than 50 issue numbers", async () => {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: Array.from({ length: 51 }, (_, i) => i + 1) }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("more than 50");
|
|
});
|
|
|
|
it("returns 400 for invalid issueNumbers (non-integers)", async () => {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, "two", 3] }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("positive integers");
|
|
});
|
|
|
|
it("handles partial failures (some succeed, some fail)", async () => {
|
|
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: mockGitHubIssue(1),
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
|
.mockResolvedValueOnce({
|
|
success: false,
|
|
error: "GitHub API error (404): Not Found",
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: mockGitHubIssue(3),
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(3);
|
|
expect(res.body.results[0].success).toBe(true);
|
|
expect(res.body.results[1].success).toBe(false);
|
|
expect(res.body.results[1].error).toContain("404");
|
|
expect(res.body.results[2].success).toBe(true);
|
|
expect(throttledSpy).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("rejects pull requests with appropriate error", async () => {
|
|
fetchSpy.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockGitHubIssue(1), pull_request: {} }),
|
|
} as Response);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results[0].success).toBe(false);
|
|
expect(res.body.results[0].error).toContain("pull request");
|
|
});
|
|
|
|
it("handles rate limit (429) with retry and eventual success", async () => {
|
|
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
|
|
success: true,
|
|
data: mockGitHubIssue(1, "Issue After Rate Limit"),
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(1);
|
|
expect(res.body.results[0].success).toBe(true);
|
|
expect(res.body.results[0].taskId).toBeDefined();
|
|
expect(throttledSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns error after max retries exceeded on 429", async () => {
|
|
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
|
|
success: false,
|
|
error: "GitHub API rate limit exceeded. Retry after 1 seconds.",
|
|
retryAfter: 1,
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(1);
|
|
expect(res.body.results[0].success).toBe(false);
|
|
expect(res.body.results[0].error).toContain("rate limit");
|
|
expect(res.body.results[0].retryAfter).toBe(1);
|
|
expect(throttledSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("processes issues sequentially (not parallel)", async () => {
|
|
const startedIssues: number[] = [];
|
|
const resolvers = new Map<number, () => void>();
|
|
|
|
vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockImplementation(async (url) => {
|
|
const issueNumber = Number(String(url).split("/").pop());
|
|
startedIssues.push(issueNumber);
|
|
await new Promise<void>((resolve) => {
|
|
resolvers.set(issueNumber, resolve);
|
|
});
|
|
return {
|
|
success: true,
|
|
data: mockGitHubIssue(issueNumber),
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>;
|
|
});
|
|
|
|
const requestPromise = REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
await vi.waitFor(() => expect(startedIssues).toEqual([1]));
|
|
resolvers.get(1)?.();
|
|
|
|
await vi.waitFor(() => expect(startedIssues).toEqual([1, 2]));
|
|
resolvers.get(2)?.();
|
|
|
|
await vi.waitFor(() => expect(startedIssues).toEqual([1, 2, 3]));
|
|
resolvers.get(3)?.();
|
|
|
|
const res = await requestPromise;
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(3);
|
|
});
|
|
|
|
it("requires owner parameter", async () => {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ repo: "repo", issueNumbers: [1] }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("owner");
|
|
});
|
|
|
|
it("requires repo parameter", async () => {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", issueNumbers: [1] }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("repo");
|
|
});
|
|
|
|
it("logs import actions for created tasks", async () => {
|
|
fetchSpy.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockGitHubIssue(1)),
|
|
} as Response);
|
|
|
|
await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"Imported from GitHub",
|
|
"https://github.com/owner/repo/issues/1"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("projectId store scoping regressions", () => {
|
|
const projectId = "proj-scoped";
|
|
let defaultStore: TaskStore;
|
|
let scopedStore: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
__resetBatchImportRateLimiter();
|
|
__resetPlanningState();
|
|
__resetSubtaskBreakdownState();
|
|
mockIsGhAuthenticated.mockReturnValue(true);
|
|
|
|
defaultStore = createMockStore({
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
createTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
getTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
getRootDir: vi.fn().mockReturnValue("/fake/default"),
|
|
});
|
|
|
|
scopedStore = createMockStore({
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
createTask: vi.fn(),
|
|
updateTask: vi.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => ({
|
|
...FAKE_TASK_DETAIL,
|
|
id,
|
|
column: "triage",
|
|
...patch,
|
|
})),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
getTask: vi.fn().mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-PARENT",
|
|
column: "triage",
|
|
}),
|
|
deleteTask: vi.fn().mockResolvedValue(undefined),
|
|
getRootDir: vi.fn().mockReturnValue("/fake/scoped"),
|
|
});
|
|
|
|
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
|
|
});
|
|
|
|
afterEach(() => {
|
|
__setCreateFnAgent(undefined as any);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function buildApp(options?: Parameters<typeof createApiRoutes>[1]) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(defaultStore, options));
|
|
return app;
|
|
}
|
|
|
|
it("routes github issue import mutations to scoped store when projectId is provided", async () => {
|
|
vi.spyOn(GitHubClient.prototype, "getIssue").mockResolvedValue({
|
|
number: 1,
|
|
title: "Scoped issue",
|
|
body: "Body",
|
|
html_url: "https://github.com/owner/repo/issues/1",
|
|
state: "open",
|
|
});
|
|
(scopedStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-SCOPE-1",
|
|
column: "triage",
|
|
});
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1, projectId }),
|
|
{ "Content-Type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
|
|
expect(scopedStore.createTask).toHaveBeenCalledTimes(1);
|
|
expect(defaultStore.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes github batch-import mutations to scoped store when projectId is provided", async () => {
|
|
vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: {
|
|
number: 1,
|
|
title: "One",
|
|
body: "Body one",
|
|
html_url: "https://github.com/owner/repo/issues/1",
|
|
},
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: {
|
|
number: 2,
|
|
title: "Two",
|
|
body: "Body two",
|
|
html_url: "https://github.com/owner/repo/issues/2",
|
|
},
|
|
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
|
|
|
(scopedStore.createTask as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-2", column: "triage" })
|
|
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-3", column: "triage" });
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/issues/batch-import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2], delayMs: 1, projectId }),
|
|
{ "Content-Type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(scopedStore.createTask).toHaveBeenCalledTimes(2);
|
|
expect(defaultStore.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes github pull import mutations to scoped store when projectId is provided", async () => {
|
|
vi.spyOn(GitHubClient.prototype, "getPullRequest").mockResolvedValue({
|
|
number: 9,
|
|
title: "Scoped pull",
|
|
body: "PR body",
|
|
html_url: "https://github.com/owner/repo/pull/9",
|
|
headBranch: "feature/branch",
|
|
baseBranch: "main",
|
|
state: "open",
|
|
});
|
|
|
|
(scopedStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-SCOPE-4",
|
|
column: "triage",
|
|
});
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/github/pulls/import",
|
|
JSON.stringify({ owner: "owner", repo: "repo", prNumber: 9, projectId }),
|
|
{ "Content-Type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(scopedStore.createTask).toHaveBeenCalledTimes(1);
|
|
expect(defaultStore.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes planning create-task mutations to scoped store when projectId is provided", async () => {
|
|
vi.spyOn(planningModule, "getSession").mockReturnValue({
|
|
id: "plan-session-1",
|
|
initialPlan: "Scoped initial plan",
|
|
history: [],
|
|
thinkingOutput: "",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} as any);
|
|
vi.spyOn(planningModule, "getSummary").mockReturnValue({
|
|
title: "Scoped planned task",
|
|
description: "Create task in scoped project",
|
|
suggestedSize: "M",
|
|
suggestedDependencies: [],
|
|
keyDeliverables: [],
|
|
});
|
|
vi.spyOn(planningModule, "cleanupSession").mockImplementation(() => {});
|
|
|
|
(scopedStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-SCOPE-5",
|
|
column: "triage",
|
|
});
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/planning/create-task",
|
|
JSON.stringify({ sessionId: "plan-session-1", projectId }),
|
|
{ "Content-Type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(scopedStore.createTask).toHaveBeenCalledTimes(1);
|
|
expect(defaultStore.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes planning create-tasks mutations to scoped store when projectId is provided", async () => {
|
|
vi.spyOn(planningModule, "getSession").mockReturnValue({
|
|
id: "plan-session-2",
|
|
initialPlan: "Scoped multi task plan",
|
|
history: [],
|
|
summary: {
|
|
title: "Plan",
|
|
description: "Plan description",
|
|
suggestedSize: "M",
|
|
suggestedDependencies: [],
|
|
keyDeliverables: ["Deliverable 1", "Deliverable 2"],
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} as any);
|
|
vi.spyOn(planningModule, "formatInterviewQA").mockReturnValue("Q: Scope\nA: Medium");
|
|
vi.spyOn(planningModule, "cleanupSession").mockImplementation(() => {});
|
|
|
|
(scopedStore.createTask as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-6", column: "triage" })
|
|
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-7", column: "triage" });
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/planning/create-tasks",
|
|
JSON.stringify({
|
|
planningSessionId: "plan-session-2",
|
|
projectId,
|
|
subtasks: [
|
|
{ id: "subtask-1", title: "First scoped task", description: "First", suggestedSize: "S", dependsOn: [] },
|
|
{ id: "subtask-2", title: "Second scoped task", description: "Second", suggestedSize: "M", dependsOn: ["subtask-1"] },
|
|
],
|
|
}),
|
|
{ "Content-Type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(scopedStore.createTask).toHaveBeenCalledTimes(2);
|
|
expect(defaultStore.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes subtask create-tasks mutations to scoped store when projectId is provided", async () => {
|
|
vi.spyOn(subtaskBreakdownModule, "getSubtaskSession").mockReturnValue({
|
|
sessionId: "subtask-session-1",
|
|
initialDescription: "Break down scoped work",
|
|
subtasks: [],
|
|
status: "complete",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
thinkingOutput: "",
|
|
} as any);
|
|
vi.spyOn(subtaskBreakdownModule, "cleanupSubtaskSession").mockImplementation(() => {});
|
|
|
|
(scopedStore.createTask as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-8", column: "triage" })
|
|
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-9", column: "triage" });
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/subtasks/create-tasks",
|
|
JSON.stringify({
|
|
sessionId: "subtask-session-1",
|
|
projectId,
|
|
parentTaskId: "FN-PARENT",
|
|
subtasks: [
|
|
{ tempId: "temp-1", title: "Scoped subtask one", description: "One", size: "S", dependsOn: [] },
|
|
{ tempId: "temp-2", title: "Scoped subtask two", description: "Two", size: "M", dependsOn: ["temp-1"] },
|
|
],
|
|
}),
|
|
{ "Content-Type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(scopedStore.getTask).toHaveBeenCalledWith("FN-PARENT");
|
|
expect(defaultStore.getTask).not.toHaveBeenCalled();
|
|
expect(scopedStore.createTask).toHaveBeenCalledTimes(2);
|
|
expect(defaultStore.createTask).not.toHaveBeenCalled();
|
|
expect(scopedStore.deleteTask).toHaveBeenCalledWith("FN-PARENT");
|
|
expect(defaultStore.deleteTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// --- Spec Revision route tests ---
|
|
|
|
describe("POST /tasks/:id/spec/revise", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore({
|
|
getTask: vi.fn(),
|
|
moveTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("requests spec revision and moves task from todo to triage", async () => {
|
|
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-revise-"));
|
|
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
|
mkdirSync(taskDir, { recursive: true });
|
|
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
|
|
|
|
try {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "Please add more details about error handling" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
"AI spec revision requested",
|
|
"Please add more details about error handling"
|
|
);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
|
} finally {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("requests spec revision and moves task from in-progress to triage", async () => {
|
|
const inProgressTask = { ...FAKE_TASK_DETAIL, column: "in-progress" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inProgressTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "Split this into smaller steps" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
});
|
|
|
|
it("allows spec revision for task already in triage", async () => {
|
|
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-replan" as const };
|
|
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-revise-triage-"));
|
|
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
|
mkdirSync(taskDir, { recursive: true });
|
|
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce(triageTask)
|
|
.mockResolvedValueOnce(updatedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
|
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
|
|
|
|
try {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "Some feedback" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
"AI spec revision requested",
|
|
"Some feedback"
|
|
);
|
|
expect(store.moveTask).not.toHaveBeenCalled();
|
|
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
|
} finally {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows spec revision when task is in in-review (in-review can transition to triage)", async () => {
|
|
const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inReviewTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "Some feedback" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
|
});
|
|
|
|
it("allows spec revision when task is in done (done can transition to triage)", async () => {
|
|
const doneTask = { ...FAKE_TASK_DETAIL, column: "done" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "Some feedback" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
});
|
|
|
|
it("returns 400 when feedback is missing", async () => {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({}),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("feedback is required");
|
|
});
|
|
|
|
it("returns 400 when feedback is empty string", async () => {
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("feedback is required");
|
|
});
|
|
|
|
it("returns 400 when feedback exceeds 2000 characters", async () => {
|
|
const longFeedback = "a".repeat(2001);
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: longFeedback }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("feedback must be between 1 and 2000");
|
|
});
|
|
|
|
it("returns 404 when task not found", async () => {
|
|
const error = new Error("Task not found") as Error & { code?: string };
|
|
error.code = "ENOENT";
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
|
|
|
const res = await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-999/spec/revise",
|
|
JSON.stringify({ feedback: "Some feedback" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("queues multiple revision requests as multiple log entries", async () => {
|
|
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
|
|
// First request
|
|
await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "First feedback" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
// Second request
|
|
await REQUEST(
|
|
buildApp(),
|
|
"POST",
|
|
"/api/tasks/KB-001/spec/revise",
|
|
JSON.stringify({ feedback: "Second feedback" }),
|
|
{ "Content-Type": "application/json" }
|
|
);
|
|
|
|
expect(store.logEntry).toHaveBeenCalledTimes(2);
|
|
expect(store.logEntry).toHaveBeenNthCalledWith(1, "FN-001", "AI spec revision requested", "First feedback");
|
|
expect(store.logEntry).toHaveBeenNthCalledWith(2, "FN-001", "AI spec revision requested", "Second feedback");
|
|
});
|
|
});
|
|
|
|
|
|
// --- Spec Rebuild route tests ---
|
|
|
|
describe("POST /tasks/:id/spec/rebuild", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore({
|
|
getTask: vi.fn(),
|
|
moveTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("rebuilds spec and moves task from todo to triage", async () => {
|
|
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-"));
|
|
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
|
mkdirSync(taskDir, { recursive: true });
|
|
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
|
|
|
|
try {
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
"Specification rebuild requested by user"
|
|
);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
|
} finally {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rebuilds spec and moves task from in-progress to triage", async () => {
|
|
const inProgressTask = { ...FAKE_TASK_DETAIL, column: "in-progress" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inProgressTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
|
});
|
|
|
|
it("rebuilds spec and moves task from done to triage", async () => {
|
|
const doneTask = { ...FAKE_TASK_DETAIL, column: "done" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
});
|
|
|
|
it("allows rebuild for task already in triage", async () => {
|
|
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-replan" as const };
|
|
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-triage-"));
|
|
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
|
mkdirSync(taskDir, { recursive: true });
|
|
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce(triageTask)
|
|
.mockResolvedValueOnce(updatedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
|
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
|
|
|
|
try {
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
"Specification rebuild requested by user"
|
|
);
|
|
expect(store.moveTask).not.toHaveBeenCalled();
|
|
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
|
} finally {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows spec rebuild when task is in in-review (in-review can transition to triage)", async () => {
|
|
const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inReviewTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
|
});
|
|
|
|
it("returns 404 when task not found", async () => {
|
|
const error = new Error("Task not found") as Error & { code?: string };
|
|
error.code = "ENOENT";
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/spec/rebuild");
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// --- Plan Approval route tests ---
|
|
|
|
describe("POST /tasks/:id/approve-plan", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore({
|
|
getTask: vi.fn(),
|
|
moveTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
|
});
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("approves plan and moves task from triage to todo", async () => {
|
|
const awaitingTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "awaiting-approval" as const };
|
|
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(awaitingTask);
|
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...movedTask, status: undefined });
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user");
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
|
expect(res.body.column).toBe("todo");
|
|
expect(res.body.status).toBeUndefined();
|
|
});
|
|
|
|
it("returns 400 when task is not in triage column", async () => {
|
|
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const, status: "awaiting-approval" as const };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("triage");
|
|
expect(store.moveTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when task does not have awaiting-approval status", async () => {
|
|
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "planning" as const };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("awaiting-approval");
|
|
expect(store.moveTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 when task not found", async () => {
|
|
const error = new Error("Task not found") as Error & { code?: string };
|
|
error.code = "ENOENT";
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/approve-plan");
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 500 on unexpected errors", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toBe("Database error");
|
|
});
|
|
});
|
|
|
|
describe("POST /tasks/:id/reject-plan", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore({
|
|
getTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
|
});
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("rejects plan and clears status for regeneration", async () => {
|
|
const awaitingTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "awaiting-approval" as const };
|
|
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: undefined };
|
|
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(awaitingTask);
|
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan rejected by user", "Specification will be regenerated");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
|
expect(res.body.column).toBe("triage");
|
|
});
|
|
|
|
it("returns 400 when task is not in triage column", async () => {
|
|
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const, status: "awaiting-approval" as const };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("triage");
|
|
expect(store.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when task does not have awaiting-approval status", async () => {
|
|
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "planning" as const };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("awaiting-approval");
|
|
expect(store.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 when task not found", async () => {
|
|
const error = new Error("Task not found") as Error & { code?: string };
|
|
error.code = "ENOENT";
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/reject-plan");
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 500 on unexpected errors", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toBe("Database error");
|
|
});
|
|
});
|
|
|
|
// --- Task diff route tests ---
|
|
|
|
describe("GET /tasks/:id/diff", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("returns 404 when task not found", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
|
|
const res = await GET(buildApp(), "/api/tasks/FN-999/diff");
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toContain("Task not found");
|
|
});
|
|
|
|
describe("done tasks without commit SHA", () => {
|
|
it("returns safe empty file list with zero stats", async () => {
|
|
const doneTask = {
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: {
|
|
filesChanged: 3,
|
|
insertions: 10,
|
|
deletions: 2,
|
|
},
|
|
};
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
|
|
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.files).toEqual([]);
|
|
expect(res.body.stats).toEqual({
|
|
filesChanged: 0,
|
|
additions: 0,
|
|
deletions: 0,
|
|
});
|
|
});
|
|
|
|
it("returns zeros when mergeDetails has no summary numbers", async () => {
|
|
const doneTask = {
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: {},
|
|
};
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
|
|
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.files).toEqual([]);
|
|
expect(res.body.stats).toEqual({
|
|
filesChanged: 0,
|
|
additions: 0,
|
|
deletions: 0,
|
|
});
|
|
});
|
|
|
|
it("returns zeros when mergeDetails is undefined", async () => {
|
|
const doneTask = {
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: undefined,
|
|
};
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
|
|
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.files).toEqual([]);
|
|
expect(res.body.stats).toEqual({
|
|
filesChanged: 0,
|
|
additions: 0,
|
|
deletions: 0,
|
|
});
|
|
});
|
|
|
|
it("response is schema-compatible with TaskDiff type", async () => {
|
|
const doneTask = {
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: { filesChanged: 5, insertions: 20, deletions: 3 },
|
|
};
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
|
|
const res = await GET(buildApp(), "/api/tasks/FN-001/diff");
|
|
|
|
// Must have both `files` array and `stats` object
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body.files)).toBe(true);
|
|
expect(res.body.stats).toHaveProperty("filesChanged");
|
|
expect(res.body.stats).toHaveProperty("additions");
|
|
expect(res.body.stats).toHaveProperty("deletions");
|
|
});
|
|
});
|
|
|
|
describe("done tasks with commit SHA", () => {
|
|
it("attempts git diff when commitSha is present", async () => {
|
|
const gitRepo = getSharedGitTestRepo();
|
|
const localStore = createMockStore({
|
|
getRootDir: vi.fn().mockReturnValue(gitRepo.repoDir),
|
|
});
|
|
const doneTask = {
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: { commitSha: gitRepo.headSha },
|
|
};
|
|
(localStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(localStore));
|
|
|
|
const res = await GET(app, "/api/tasks/FN-001/diff");
|
|
expect(res.status).toBe(200);
|
|
// The diff should be schema-compatible even if it returns empty
|
|
expect(Array.isArray(res.body.files)).toBe(true);
|
|
expect(res.body.stats).toHaveProperty("filesChanged");
|
|
});
|
|
});
|
|
|
|
it("resolves missing done-task commitSha from run-audit commit events", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "kb-dashboard-done-audit-"));
|
|
try {
|
|
execFileSync("git", ["init", "--initial-branch=main", root], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
|
writeFileSync(join(root, "tracked.ts"), "export const value = 1;\n");
|
|
execFileSync("git", ["-C", root, "add", "tracked.ts"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "commit", "-m", "initial"], { stdio: "pipe" });
|
|
writeFileSync(join(root, "tracked.ts"), "export const value = 2;\n");
|
|
execFileSync("git", ["-C", root, "commit", "-am", "update"], { stdio: "pipe" });
|
|
const mergeSha = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
|
|
const localStore = createMockStore({
|
|
getRootDir: vi.fn().mockReturnValue(root),
|
|
getRunAuditEvents: vi.fn().mockReturnValue([{ mutationType: "commit:create", target: mergeSha }]),
|
|
getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]),
|
|
});
|
|
(localStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: { filesChanged: 1, insertions: 1, deletions: 1 },
|
|
});
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(localStore));
|
|
|
|
const res = await GET(app, "/api/tasks/FN-001/diff");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.stats.filesChanged).toBe(1);
|
|
expect(res.body.stats.additions).toBeGreaterThan(0);
|
|
expect(res.body.stats.deletions).toBeGreaterThan(0);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("resolves missing done-task commitSha from lineage associations when audit has no sha", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "kb-dashboard-done-lineage-"));
|
|
try {
|
|
execFileSync("git", ["init", "--initial-branch=main", root], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
|
writeFileSync(join(root, "tracked.ts"), "export const value = 1;\n");
|
|
execFileSync("git", ["-C", root, "add", "tracked.ts"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "commit", "-m", "initial"], { stdio: "pipe" });
|
|
writeFileSync(join(root, "tracked.ts"), "export const value = 3;\n");
|
|
execFileSync("git", ["-C", root, "commit", "-am", "lineage change"], { stdio: "pipe" });
|
|
const lineageSha = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
|
|
const localStore = createMockStore({
|
|
getRootDir: vi.fn().mockReturnValue(root),
|
|
getRunAuditEvents: vi.fn().mockReturnValue([]),
|
|
getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([{ commitSha: lineageSha, authoredAt: new Date().toISOString() }]),
|
|
});
|
|
(localStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
lineageId: "lineage-1",
|
|
column: "done",
|
|
mergeDetails: { filesChanged: 1, insertions: 1, deletions: 1 },
|
|
});
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(localStore));
|
|
|
|
const res = await GET(app, "/api/tasks/FN-001/diff");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.stats.filesChanged).toBe(1);
|
|
expect(res.body.stats.additions).toBeGreaterThan(0);
|
|
expect(res.body.stats.deletions).toBeGreaterThan(0);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("returns zero stats when no done-task commit sha can be resolved", async () => {
|
|
const localStore = createMockStore({
|
|
getRunAuditEvents: vi.fn().mockReturnValue([{ mutationType: "commit:create", target: "HEAD" }]),
|
|
getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]),
|
|
});
|
|
(localStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
lineageId: "lineage-1",
|
|
column: "done",
|
|
mergeDetails: { filesChanged: 3, insertions: 10, deletions: 2 },
|
|
});
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(localStore));
|
|
|
|
const res = await GET(app, "/api/tasks/FN-001/diff");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.files).toEqual([]);
|
|
expect(res.body.stats).toEqual({ filesChanged: 0, additions: 0, deletions: 0 });
|
|
});
|
|
|
|
it("uses destination path for rename entries in active-task name-status parsing", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "kb-dashboard-rename-"));
|
|
try {
|
|
execFileSync("git", ["init", "--initial-branch=main", root], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
|
writeFileSync(join(root, "old.ts"), "export const value = 1;\n");
|
|
execFileSync("git", ["-C", root, "add", "old.ts"], { stdio: "pipe" });
|
|
execFileSync("git", ["-C", root, "commit", "-m", "add old"], { stdio: "pipe" });
|
|
const baseSha = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
execFileSync("git", ["-C", root, "mv", "old.ts", "new.ts"], { stdio: "pipe" });
|
|
writeFileSync(join(root, "new.ts"), "export const value = 2;\n");
|
|
|
|
const localStore = createMockStore({ getRootDir: vi.fn().mockReturnValue(root) });
|
|
(localStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "in-progress",
|
|
branch: "main",
|
|
worktree: root,
|
|
baseCommitSha: baseSha,
|
|
});
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(localStore));
|
|
|
|
const res = await GET(app, "/api/tasks/FN-001/diff");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.files.map((file: { path: string }) => file.path)).toContain("new.ts");
|
|
expect(res.body.files.map((file: { path: string }) => file.path)).not.toContain("old.ts");
|
|
expect(res.body.stats.filesChanged).toBe(1);
|
|
expect(Number.isInteger(res.body.stats.additions)).toBe(true);
|
|
expect(Number.isInteger(res.body.stats.deletions)).toBe(true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("GET /tasks/:id/file-diffs", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
describe("done tasks without commit SHA", () => {
|
|
it("returns empty array instead of scanning repository", async () => {
|
|
const doneTask = {
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: { filesChanged: 3 },
|
|
};
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
|
|
const res = await GET(buildApp(), "/api/tasks/FN-001/file-diffs");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array when mergeDetails is undefined", async () => {
|
|
const doneTask = {
|
|
...FAKE_TASK_DETAIL,
|
|
id: "FN-001",
|
|
column: "done",
|
|
mergeDetails: undefined,
|
|
};
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
|
|
|
const res = await GET(buildApp(), "/api/tasks/FN-001/file-diffs");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("GET /tasks/:id/review", () => {
|
|
let store: ReturnType<typeof createMockStore>;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("returns normalized reviewer-agent payload in direct mode", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
|
|
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "code review Step 2: REVISE" }],
|
|
});
|
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{
|
|
timestamp: "2026-05-01T10:00:01.000Z",
|
|
taskId: "FN-001",
|
|
type: "text",
|
|
text: "## Code Review:\n\n### Verdict: REVISE\n\n### Summary\nNeeds null guard\n",
|
|
agent: "reviewer",
|
|
},
|
|
]);
|
|
|
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mode).toBe("reviewer-agent");
|
|
expect(res.body.items[0].sourceMode).toBe("reviewer-agent");
|
|
expect(res.body.items[0].reviewState).toBe("REVISE");
|
|
});
|
|
|
|
it("returns exact empty payload/message when no reviewer feedback exists", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
|
|
log: [],
|
|
});
|
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
|
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mode).toBe("reviewer-agent");
|
|
expect(res.body.summary).toBeNull();
|
|
expect(res.body.items).toEqual([]);
|
|
});
|
|
|
|
it("falls back to task log summary when reviewer output is incomplete", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
|
|
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "plan review Step 1: APPROVE" }],
|
|
});
|
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{
|
|
timestamp: "2026-05-01T10:00:01.000Z",
|
|
taskId: "FN-001",
|
|
type: "text",
|
|
text: "partial stream",
|
|
agent: "reviewer",
|
|
},
|
|
]);
|
|
|
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.items[0].title).toContain("plan review APPROVE");
|
|
expect(res.body.items[0].itemId).toContain("step-1");
|
|
});
|
|
|
|
it("returns 404 when task is missing", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-404/review");
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("POST /tasks/:id/review/refresh", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore();
|
|
});
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(store));
|
|
return app;
|
|
}
|
|
|
|
it("refreshes PR-backed review payload", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
prInfo: {
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
number: 42,
|
|
status: "open",
|
|
title: "PR",
|
|
headBranch: "fusion/fn-1",
|
|
baseBranch: "main",
|
|
commentCount: 0,
|
|
},
|
|
});
|
|
vi.spyOn(GitHubClient.prototype, "getPrReviewDetails").mockResolvedValue({
|
|
mode: "pull-request",
|
|
refreshable: true,
|
|
fetchedAt: "2026-05-01T10:00:00.000Z",
|
|
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: ["needs work"], checks: [] },
|
|
items: [],
|
|
});
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
|
"content-type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mode).toBe("pull-request");
|
|
});
|
|
|
|
it("returns scoped refresh error payload in PR mode when GitHub refresh fails", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
prInfo: {
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
number: 42,
|
|
status: "open",
|
|
title: "PR",
|
|
headBranch: "fusion/fn-1",
|
|
baseBranch: "main",
|
|
commentCount: 0,
|
|
},
|
|
reviewState: { source: "pull-request", items: [], addressing: [] },
|
|
});
|
|
vi.spyOn(GitHubClient.prototype, "getPrReviewDetails").mockRejectedValue(new Error("GitHub outage"));
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
|
"content-type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toContain("GitHub outage");
|
|
});
|
|
|
|
it("refreshes direct-mode review payload without PR", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...FAKE_TASK_DETAIL,
|
|
reviewState: {
|
|
source: "reviewer-agent",
|
|
items: [],
|
|
addressing: [],
|
|
},
|
|
});
|
|
|
|
const getDetailsSpy = vi.spyOn(GitHubClient.prototype, "getPrReviewDetails");
|
|
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
|
"content-type": "application/json",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mode).toBe("reviewer-agent");
|
|
expect(getDetailsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 when task is missing", async () => {
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-404/review/refresh", JSON.stringify({}), {
|
|
"content-type": "application/json",
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// --- Git Management route tests ---
|
|
// These are integration tests that run against the actual git repository
|