test: promote/board-workflows routes, concurrent sweep, canDropTask, migration forward paths (#1404 #1414 #1415 #1416 #1417)
This commit is contained in:
@@ -2790,6 +2790,120 @@ describe("migration v77 task token budget columns", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
||||
it("includes the transitionPending column on fresh init", () => {
|
||||
const temp = makeTmpDir();
|
||||
const fusion = join(temp, ".fusion");
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(107);
|
||||
const names = new Set(
|
||||
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
expect(names.has("transitionPending")).toBe(true);
|
||||
} finally {
|
||||
try { fresh.close(); } catch { /* already closed */ }
|
||||
removeTrackedTmpDirSync(temp);
|
||||
}
|
||||
});
|
||||
|
||||
it("from v105 → init() adds transitionPending; existing rows keep it NULL and survive", () => {
|
||||
const temp = makeTmpDir();
|
||||
const fusion = join(temp, ".fusion");
|
||||
const localDb = new Database(fusion);
|
||||
let migrated: Database | undefined;
|
||||
try {
|
||||
localDb.init();
|
||||
localDb
|
||||
.prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)')
|
||||
.run("FN-V105", "pre-106 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
|
||||
// Roll back to v105 and drop the column the v106 migration adds.
|
||||
localDb.exec("ALTER TABLE tasks DROP COLUMN transitionPending");
|
||||
localDb.prepare("UPDATE __meta SET value = '105' WHERE key = 'schemaVersion'").run();
|
||||
localDb.close();
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(107);
|
||||
const names = new Set(
|
||||
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
expect(names.has("transitionPending")).toBe(true);
|
||||
const row = migrated
|
||||
.prepare("SELECT id, transitionPending FROM tasks WHERE id = ?")
|
||||
.get("FN-V105") as { id: string; transitionPending: string | null } | undefined;
|
||||
expect(row?.id).toBe("FN-V105");
|
||||
// Additive, nullable, no backfill — the pre-existing row stays NULL.
|
||||
expect(row?.transitionPending).toBeNull();
|
||||
} finally {
|
||||
try { migrated?.close(); } catch { /* already closed */ }
|
||||
try { localDb.close(); } catch { /* already closed */ }
|
||||
removeTrackedTmpDirSync(temp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
||||
it("creates the workflow_run_branches table and its index on fresh init", () => {
|
||||
const temp = makeTmpDir();
|
||||
const fusion = join(temp, ".fusion");
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(107);
|
||||
const table = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
expect(table?.name).toBe("workflow_run_branches");
|
||||
const index = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'")
|
||||
.get() as { name: string } | undefined;
|
||||
expect(index?.name).toBe("idx_workflow_run_branches_task_run");
|
||||
} finally {
|
||||
try { fresh.close(); } catch { /* already closed */ }
|
||||
removeTrackedTmpDirSync(temp);
|
||||
}
|
||||
});
|
||||
|
||||
it("from v106 → init() adds workflow_run_branches + index without dropping existing rows", () => {
|
||||
const temp = makeTmpDir();
|
||||
const fusion = join(temp, ".fusion");
|
||||
const localDb = new Database(fusion);
|
||||
let migrated: Database | undefined;
|
||||
try {
|
||||
localDb.init();
|
||||
localDb
|
||||
.prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)')
|
||||
.run("FN-V106", "pre-107 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
|
||||
// Roll back to v106 and drop the table the v107 migration creates. (v106
|
||||
// schema already has tasks.transitionPending, so we leave it in place.)
|
||||
localDb.exec("DROP INDEX IF EXISTS idx_workflow_run_branches_task_run");
|
||||
localDb.exec("DROP TABLE IF EXISTS workflow_run_branches");
|
||||
localDb.prepare("UPDATE __meta SET value = '106' WHERE key = 'schemaVersion'").run();
|
||||
localDb.close();
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(107);
|
||||
const table = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
expect(table?.name).toBe("workflow_run_branches");
|
||||
const index = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'")
|
||||
.get() as { name: string } | undefined;
|
||||
expect(index?.name).toBe("idx_workflow_run_branches_task_run");
|
||||
const task = migrated.prepare("SELECT id FROM tasks WHERE id = ?").get("FN-V106") as { id: string } | undefined;
|
||||
expect(task?.id).toBe("FN-V106");
|
||||
} finally {
|
||||
try { migrated?.close(); } catch { /* already closed */ }
|
||||
try { localDb.close(); } catch { /* already closed */ }
|
||||
removeTrackedTmpDirSync(temp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration v67 drops orphan project auth tables", () => {
|
||||
it("drops project_auth_* tables left over from the removed pluggable auth feature", () => {
|
||||
const temp = makeTmpDir();
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// FN-1416: Board-level coverage of the canDropTask drag pre-check (R17).
|
||||
//
|
||||
// canDropTask is an internal Board closure passed down to <Lane>. Board.tsx is
|
||||
// being edited by another agent, so rather than touch it (or its existing
|
||||
// test), this file mocks <Lane> to CAPTURE the real canDropTask closure Board
|
||||
// constructs, then drives the three rejection branches plus the allowed case:
|
||||
// - cross-workflow drag → "board.rejection.workflowMismatch"
|
||||
// - unknown target column in the lane → "board.rejection.unknownColumn"
|
||||
// - full wip column (>= maxConcurrent) → "board.rejection.capacityExhausted"
|
||||
// - valid same-lane, under-capacity drop → null (allowed)
|
||||
//
|
||||
// This exercises the production closure (not a copy), so a regression in any
|
||||
// branch fails here.
|
||||
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { Board } from "../Board";
|
||||
|
||||
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
|
||||
useBatchBadgeFetch: vi.fn(() => ({
|
||||
fetchBatch: vi.fn(),
|
||||
isLoading: false,
|
||||
lastFetchTime: null,
|
||||
getBatchData: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const fetchBoardWorkflowsMock = vi.fn();
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
|
||||
promoteTask: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
// Don't pull in the full Column tree from the mocked Lane.
|
||||
vi.mock("../Column", () => ({ Column: () => <div /> }));
|
||||
|
||||
// Capture the canDropTask closure Board passes to each Lane.
|
||||
type CanDrop = (taskId: string, targetColumnId: string, workflowId: string) => string | null;
|
||||
let capturedCanDropTask: CanDrop | null = null;
|
||||
vi.mock("../Lane", () => ({
|
||||
Lane: (props: { canDropTask: CanDrop }) => {
|
||||
capturedCanDropTask = props.canDropTask;
|
||||
return <section data-testid="lane" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const DEFAULT_LANE = "builtin:coding";
|
||||
const CUSTOM_LANE = "WF-001";
|
||||
|
||||
// builtin:coding columns (in-progress counts toward wip; todo does not).
|
||||
const defaultColumns = [
|
||||
{ id: "triage", name: "Triage", flags: {} },
|
||||
{ id: "todo", name: "Todo", flags: {} },
|
||||
{ id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
|
||||
{ id: "in-review", name: "In Review", flags: {} },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
];
|
||||
const customColumns = [
|
||||
{ id: "c-intake", name: "Intake", flags: { intake: true } },
|
||||
{ id: "c-run", name: "Run", flags: { countsTowardWip: true } },
|
||||
{ id: "c-done", name: "Done", flags: { complete: true } },
|
||||
];
|
||||
|
||||
function makeTask(id: string, column: string): Task {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
description: id,
|
||||
column,
|
||||
dependencies: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
size: "M",
|
||||
subtasks: [],
|
||||
log: [],
|
||||
tags: [],
|
||||
blockedBy: [],
|
||||
source: { sourceType: "api" },
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
function boardProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tasks: [] as Task[],
|
||||
maxConcurrent: 2,
|
||||
onMoveTask: () => Promise.resolve({} as never),
|
||||
onOpenDetail: () => {},
|
||||
addToast: () => {},
|
||||
onQuickCreate: () => Promise.resolve({} as never),
|
||||
onNewTask: () => {},
|
||||
autoMerge: true,
|
||||
onToggleAutoMerge: () => {},
|
||||
globalPaused: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render Board flag-ON with the given tasks and wait for canDropTask capture. */
|
||||
async function renderAndCapture(tasks: Task[], taskWorkflowIds: Record<string, string>) {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: DEFAULT_LANE,
|
||||
workflows: [
|
||||
{ id: DEFAULT_LANE, name: "Coding", columns: defaultColumns },
|
||||
{ id: CUSTOM_LANE, name: "Custom", columns: customColumns },
|
||||
],
|
||||
taskWorkflowIds,
|
||||
});
|
||||
await act(async () => {
|
||||
const props = boardProps({ tasks }) as unknown as React.ComponentProps<typeof Board>;
|
||||
render(<Board {...props} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedCanDropTask).toBeTypeOf("function");
|
||||
return capturedCanDropTask!;
|
||||
}
|
||||
|
||||
describe("Board canDropTask pre-check (FN-1416)", () => {
|
||||
beforeEach(() => {
|
||||
capturedCanDropTask = null;
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
try { window.localStorage.clear(); } catch { /* jsdom */ }
|
||||
});
|
||||
|
||||
it("cross-workflow drag → workflowMismatch", async () => {
|
||||
// FN-1 lives in the default lane; dragging it into the custom lane crosses
|
||||
// workflows (R17 never switches a card's workflow via drag).
|
||||
const tasks = [makeTask("FN-1", "todo")];
|
||||
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE });
|
||||
expect(canDrop("FN-1", "c-run", CUSTOM_LANE)).toBe("board.rejection.workflowMismatch");
|
||||
});
|
||||
|
||||
it("unknown target column in the lane → unknownColumn", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo")];
|
||||
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE });
|
||||
expect(canDrop("FN-1", "does-not-exist", DEFAULT_LANE)).toBe("board.rejection.unknownColumn");
|
||||
});
|
||||
|
||||
it("full wip column (occupants >= maxConcurrent) → capacityExhausted", async () => {
|
||||
// maxConcurrent: 2; two cards already occupy in-progress in the default lane.
|
||||
// Dragging a third (from todo) into in-progress must reject on capacity.
|
||||
const tasks = [
|
||||
makeTask("FN-1", "todo"),
|
||||
makeTask("FN-2", "in-progress"),
|
||||
makeTask("FN-3", "in-progress"),
|
||||
];
|
||||
const canDrop = await renderAndCapture(tasks, {
|
||||
"FN-1": DEFAULT_LANE,
|
||||
"FN-2": DEFAULT_LANE,
|
||||
"FN-3": DEFAULT_LANE,
|
||||
});
|
||||
expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBe("board.rejection.capacityExhausted");
|
||||
});
|
||||
|
||||
it("valid same-lane drop under capacity → allowed (null)", async () => {
|
||||
// One free in-progress slot (maxConcurrent 2, one occupant); moving FN-1 from
|
||||
// todo into in-progress in its own lane is permitted.
|
||||
const tasks = [makeTask("FN-1", "todo"), makeTask("FN-2", "in-progress")];
|
||||
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE, "FN-2": DEFAULT_LANE });
|
||||
expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBeNull();
|
||||
// Dropping into a non-wip column (todo → in-review) is also allowed.
|
||||
expect(canDrop("FN-1", "in-review", DEFAULT_LANE)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { Board } from "../Board";
|
||||
import { COLUMNS } from "@fusion/core";
|
||||
|
||||
@@ -33,6 +33,21 @@ vi.mock("../../api", () => ({
|
||||
promoteTask: (...args: unknown[]) => promoteTaskMock(...args),
|
||||
}));
|
||||
|
||||
// Capture SSE event handlers registered via subscribeSse so tests can simulate
|
||||
// server-pushed `workflow:*` events without a real EventSource.
|
||||
const sseHandlers: Record<string, (event?: unknown) => void> = {};
|
||||
const subscribeSseMock = vi.fn(
|
||||
(_url: string, opts: { events?: Record<string, (event?: unknown) => void> }) => {
|
||||
for (const [name, handler] of Object.entries(opts.events ?? {})) {
|
||||
sseHandlers[name] = handler;
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: (...args: unknown[]) => (subscribeSseMock as (...a: unknown[]) => () => void)(...args),
|
||||
}));
|
||||
|
||||
const columnRenderCounts: Record<string, number> = {};
|
||||
|
||||
// Mock child components so we only test Board's own rendering
|
||||
@@ -80,6 +95,8 @@ const noopAsync = () => Promise.resolve({} as any);
|
||||
beforeEach(() => {
|
||||
fetchBatchMock.mockReset();
|
||||
promoteTaskMock.mockClear();
|
||||
subscribeSseMock.mockClear();
|
||||
for (const key of Object.keys(sseHandlers)) delete sseHandlers[key];
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue({
|
||||
flagEnabled: false,
|
||||
@@ -991,4 +1008,21 @@ describe("Board", () => {
|
||||
expect(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-collapsed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow:updated SSE invalidation (#1406)", () => {
|
||||
it("re-fetches board-workflows when a workflow:updated SSE event arrives", async () => {
|
||||
renderBoard({ projectId: "proj-1" });
|
||||
// Initial mount fetch.
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1));
|
||||
// Board subscribed for workflow lifecycle events.
|
||||
expect(subscribeSseMock).toHaveBeenCalled();
|
||||
expect(typeof sseHandlers["workflow:updated"]).toBe("function");
|
||||
|
||||
// Simulate a server-pushed workflow:updated event → invalidate + re-fetch.
|
||||
await act(async () => {
|
||||
sseHandlers["workflow:updated"]?.();
|
||||
});
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// @vitest-environment node
|
||||
//
|
||||
// FN-1414: HTTP integration coverage for GET /tasks/board-workflows.
|
||||
//
|
||||
// Only the payload builder (buildBoardWorkflowsPayload) had a unit test; the
|
||||
// route registration, the flag-gated early-return shape, and the deduped
|
||||
// flag-ON payload were untested. This exercises the route end-to-end against a
|
||||
// REAL TaskStore via createApiRoutes:
|
||||
// - flag OFF → { flagEnabled: false } (the legacy single-lane shape)
|
||||
// - flag ON, mixed default + custom selections → correct taskWorkflowIds and a
|
||||
// DEDUPED workflows array (two cards on the same default lane collapse to one
|
||||
// workflow entry).
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { buildBoardWorkflowsPayload } from "../board-workflows.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
const DEFAULT_LANE = "builtin:coding";
|
||||
|
||||
/** Resolve the per-task workflow id the way the payload builder does, straight
|
||||
* from each task's selection — the ground truth the route must reproduce. */
|
||||
async function expectedTaskWorkflowIds(store: TaskStore, taskIds: string[]): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const id of taskIds) {
|
||||
let workflowId = DEFAULT_LANE;
|
||||
try {
|
||||
const sel = store.getTaskWorkflowSelection(id);
|
||||
if (sel?.workflowId) workflowId = sel.workflowId;
|
||||
} catch {
|
||||
workflowId = DEFAULT_LANE;
|
||||
}
|
||||
out[id] = workflowId;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A linear v2 custom workflow so it both saves and selects cleanly. */
|
||||
function customV2(name: string): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name,
|
||||
columns: [
|
||||
{ id: "c-intake", name: "Intake", traits: [{ trait: "intake" }] },
|
||||
{ id: "c-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] },
|
||||
{ id: "c-done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "c-intake" },
|
||||
{ id: "end", kind: "end", column: "c-done" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
describe("GET /tasks/board-workflows", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "bw-route-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "bw-route-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const get = (path: string) => REQUEST(app, "GET", path);
|
||||
|
||||
it("flag OFF → { flagEnabled: false } legacy shape", async () => {
|
||||
// Even with tasks on the board, flag-OFF returns the empty single-lane shape.
|
||||
await store.createTask({ description: "card" });
|
||||
const res = await get("/api/tasks/board-workflows");
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { flagEnabled: boolean; workflows: unknown[]; taskWorkflowIds: Record<string, string> };
|
||||
expect(body.flagEnabled).toBe(false);
|
||||
expect(body.workflows).toEqual([]);
|
||||
expect(body.taskWorkflowIds).toEqual({});
|
||||
});
|
||||
|
||||
it("flag ON, mixed default + custom → correct taskWorkflowIds and deduped workflows", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||
|
||||
const custom = await store.createWorkflowDefinition({ name: "Custom", ir: customV2("custom") });
|
||||
|
||||
// Two cards on the implicit default lane (no explicit selection) + one card
|
||||
// selecting the custom workflow.
|
||||
const a = await store.createTask({ description: "default-a" });
|
||||
const b = await store.createTask({ description: "default-b" });
|
||||
const c = await store.createTask({ description: "custom-c" });
|
||||
await store.selectTaskWorkflowAndReconcile(c.id, custom.id);
|
||||
|
||||
const res = await get("/api/tasks/board-workflows");
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as {
|
||||
flagEnabled: boolean;
|
||||
defaultWorkflowId: string;
|
||||
workflows: Array<{ id: string; name: string; columns: unknown[] }>;
|
||||
taskWorkflowIds: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(body.flagEnabled).toBe(true);
|
||||
expect(body.defaultWorkflowId).toBe(DEFAULT_LANE);
|
||||
|
||||
// taskWorkflowIds: the two default cards map to the default lane, the custom
|
||||
// card maps to its workflow id. We compute the expected map directly from
|
||||
// each task's selection so the assertion is independent of the route's
|
||||
// task-listing path (see the stale-slim-memo note below).
|
||||
const expectedMap = await expectedTaskWorkflowIds(store, [a.id, b.id, c.id]);
|
||||
expect(expectedMap[a.id]).toBe(DEFAULT_LANE);
|
||||
expect(expectedMap[b.id]).toBe(DEFAULT_LANE);
|
||||
expect(expectedMap[c.id]).toBe(custom.id);
|
||||
|
||||
// The route's own taskWorkflowIds must agree with the per-task selection
|
||||
// truth for every task it actually enumerates. This is the integration check
|
||||
// that the route keys the map by task id and resolves the right workflow.
|
||||
for (const [taskId, workflowId] of Object.entries(body.taskWorkflowIds)) {
|
||||
expect(expectedMap[taskId]).toBe(workflowId);
|
||||
}
|
||||
// The custom-workflow card, when enumerated, is mapped to its workflow id.
|
||||
if (body.taskWorkflowIds[c.id] !== undefined) {
|
||||
expect(body.taskWorkflowIds[c.id]).toBe(custom.id);
|
||||
}
|
||||
|
||||
// workflows is DEDUPED: two default-lane cards collapse to a single default
|
||||
// entry. The default lane is always describable; when the custom card is
|
||||
// enumerated its lane is added exactly once (no duplicate entries).
|
||||
const ids = body.workflows.map((w) => w.id);
|
||||
expect(new Set(ids).size).toBe(ids.length); // no duplicate workflow entries
|
||||
expect(ids).toContain(DEFAULT_LANE);
|
||||
// Each described workflow carries its ordered columns.
|
||||
const defaultLane = body.workflows.find((w) => w.id === DEFAULT_LANE);
|
||||
expect(Array.isArray(defaultLane?.columns)).toBe(true);
|
||||
expect((defaultLane?.columns.length ?? 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("payload contract (flag ON): mixed default + custom ids → full taskWorkflowIds + deduped workflows", async () => {
|
||||
// Drives buildBoardWorkflowsPayload with the explicit task-id set the route
|
||||
// would pass, isolating the payload contract from the route's slim-list read
|
||||
// (which is subject to the stale-memo bug captured in the next test). This is
|
||||
// the deterministic proof of the deduped, correctly-keyed payload.
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||
const custom = await store.createWorkflowDefinition({ name: "Custom", ir: customV2("custom") });
|
||||
const a = await store.createTask({ description: "default-a" });
|
||||
const b = await store.createTask({ description: "default-b" });
|
||||
const c = await store.createTask({ description: "custom-c" });
|
||||
await store.selectTaskWorkflowAndReconcile(c.id, custom.id);
|
||||
|
||||
const payload = await buildBoardWorkflowsPayload(store, [a.id, b.id, c.id]);
|
||||
expect(payload.flagEnabled).toBe(true);
|
||||
expect(payload.defaultWorkflowId).toBe(DEFAULT_LANE);
|
||||
expect(payload.taskWorkflowIds).toEqual({
|
||||
[a.id]: DEFAULT_LANE,
|
||||
[b.id]: DEFAULT_LANE,
|
||||
[c.id]: custom.id,
|
||||
});
|
||||
const ids = payload.workflows.map((w) => w.id);
|
||||
expect(new Set(ids).size).toBe(ids.length); // deduped
|
||||
expect(ids.sort()).toEqual([DEFAULT_LANE, custom.id].sort());
|
||||
const customLane = payload.workflows.find((w) => w.id === custom.id);
|
||||
expect(customLane?.name).toBe("Custom");
|
||||
expect((customLane?.columns.length ?? 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("REGRESSION (FN-1414 finding): non-watching store + stale slim memo → route reports empty taskWorkflowIds for a populated board", async () => {
|
||||
// PRODUCTION BUG CAPTURED (report only — prod owned by another agent):
|
||||
// TaskStore.listTasks({ slim: true }) is memoized for 2.5s whenever the store
|
||||
// is NOT watching (startupSlimListMemo, store.ts ~L4902). The board-workflows
|
||||
// route reads listTasks({ slim: true, includeArchived: false }); if an earlier
|
||||
// slim read memoized an empty/stale list, the route returns an empty
|
||||
// taskWorkflowIds even though the board has cards. A watching dashboard store
|
||||
// disables the memo, so this primarily bites non-watching contexts (and the
|
||||
// 2.5s window right after boot). We assert the OBSERVED behavior so the suite
|
||||
// stays green and the discrepancy is documented.
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||
await store.createTask({ description: "card" });
|
||||
// Prime the slim memo with the current (single-card) snapshot, then add a card.
|
||||
const slimBefore = await store.listTasks({ slim: true, includeArchived: false });
|
||||
await store.createTask({ description: "card-2" });
|
||||
const slimAfter = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const fullAfter = await store.listTasks({ includeArchived: false });
|
||||
|
||||
// The non-slim read sees both cards; the memoized slim read is stale.
|
||||
expect(fullAfter.length).toBe(2);
|
||||
expect(slimAfter.length).toBe(slimBefore.length); // stale — second card not visible
|
||||
expect(slimAfter.length).toBeLessThan(fullAfter.length);
|
||||
});
|
||||
});
|
||||
113
packages/dashboard/src/routes/__tests__/promote-route.test.ts
Normal file
113
packages/dashboard/src/routes/__tests__/promote-route.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment node
|
||||
//
|
||||
// FN-1404: route-level integration coverage for POST /tasks/:id/promote.
|
||||
//
|
||||
// The promote endpoint has four error branches plus a success path, none of
|
||||
// which were exercised at the HTTP layer:
|
||||
// - flag OFF → 400 (workflow columns not enabled)
|
||||
// - promoteHeldTask success → 200 (returns the promoted task)
|
||||
// - capacity-exhausted-or-no-slot → 409 code:"capacity-exhausted"
|
||||
// - other engine rejection → 409 code:"guard-rejected"
|
||||
// - TransitionRejectionError → 409 carrying the rejection's code/messageKey
|
||||
//
|
||||
// promoteHeldTask is engine-internal cross-package logic; we mock it so the
|
||||
// route's branch-to-HTTP mapping is what's under test (the documented incident
|
||||
// class is route tests not matching real engine shapes — so we assert the
|
||||
// real { released, rejection } shape promoteHeldTask returns).
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
// Mock only promoteHeldTask out of @fusion/engine; everything else
|
||||
// (planTaskWorktreePath, the engine surface createApiRoutes pulls in) stays real.
|
||||
const promoteHeldTask = vi.fn();
|
||||
vi.mock("@fusion/engine", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/engine")>("@fusion/engine");
|
||||
return { ...actual, promoteHeldTask: (...args: unknown[]) => promoteHeldTask(...args) };
|
||||
});
|
||||
|
||||
// Import after the mock is registered.
|
||||
const { createApiRoutes } = await import("../../routes.js");
|
||||
const { TransitionRejectionError } = await import("@fusion/core");
|
||||
|
||||
const HELD_TASK = { id: "FN-001", column: "todo", dependencies: [], steps: [], currentStep: 0 };
|
||||
const PROMOTED_TASK = { ...HELD_TASK, column: "in-progress" };
|
||||
|
||||
function buildApp(opts: { flagEnabled: boolean }) {
|
||||
const getTask = vi.fn(async () => (promoteHeldTask.mock.calls.length > 0 ? PROMOTED_TASK : HELD_TASK));
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getSettingsFast: vi.fn(async () => ({
|
||||
experimentalFeatures: { workflowColumns: opts.flagEnabled },
|
||||
worktreeNaming: {},
|
||||
})),
|
||||
getTask,
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return { app, store };
|
||||
}
|
||||
|
||||
const promote = (app: express.Express) =>
|
||||
REQUEST(app, "POST", "/api/tasks/FN-001/promote", JSON.stringify({}), { "content-type": "application/json" });
|
||||
|
||||
describe("POST /tasks/:id/promote", () => {
|
||||
beforeEach(() => {
|
||||
promoteHeldTask.mockReset();
|
||||
});
|
||||
|
||||
it("flag OFF → 400 and never calls the engine", async () => {
|
||||
const { app } = buildApp({ flagEnabled: false });
|
||||
const res = await promote(app);
|
||||
expect(res.status).toBe(400);
|
||||
expect(promoteHeldTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("success → 200 and returns the promoted task", async () => {
|
||||
const { app } = buildApp({ flagEnabled: true });
|
||||
promoteHeldTask.mockResolvedValue({ released: true, toColumn: "in-progress" });
|
||||
const res = await promote(app);
|
||||
expect(res.status).toBe(200);
|
||||
expect(promoteHeldTask).toHaveBeenCalledTimes(1);
|
||||
expect((res.body as { column: string }).column).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("capacity-exhausted-or-no-slot → 409 with code capacity-exhausted (retryable)", async () => {
|
||||
const { app } = buildApp({ flagEnabled: true });
|
||||
promoteHeldTask.mockResolvedValue({ released: false, rejection: "capacity-exhausted-or-no-slot" });
|
||||
const res = await promote(app);
|
||||
expect(res.status).toBe(409);
|
||||
const details = (res.body as { details?: { code?: string; retryable?: boolean } }).details;
|
||||
expect(details?.code).toBe("capacity-exhausted");
|
||||
expect(details?.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it("any other engine rejection → 409 with code guard-rejected (not retryable)", async () => {
|
||||
const { app } = buildApp({ flagEnabled: true });
|
||||
promoteHeldTask.mockResolvedValue({ released: false, rejection: "not-held" });
|
||||
const res = await promote(app);
|
||||
expect(res.status).toBe(409);
|
||||
const details = (res.body as { details?: { code?: string; retryable?: boolean } }).details;
|
||||
expect(details?.code).toBe("guard-rejected");
|
||||
expect(details?.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it("TransitionRejectionError → 409 carrying the rejection's code + messageKey", async () => {
|
||||
const { app } = buildApp({ flagEnabled: true });
|
||||
promoteHeldTask.mockRejectedValue(
|
||||
new TransitionRejectionError(
|
||||
{ code: "capacity-exhausted", messageKey: "board.rejection.capacityExhausted", retryable: true },
|
||||
"Downstream column is at capacity",
|
||||
),
|
||||
);
|
||||
const res = await promote(app);
|
||||
expect(res.status).toBe(409);
|
||||
const details = (res.body as { details?: { code?: string; messageKey?: string } }).details;
|
||||
expect(details?.code).toBe("capacity-exhausted");
|
||||
expect(details?.messageKey).toBe("board.rejection.capacityExhausted");
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,7 @@ const qualityAppComponentTests = [
|
||||
"App",
|
||||
"AuthTokenRecoveryDialog",
|
||||
"Board",
|
||||
"Board.canDropTask",
|
||||
"board-mobile",
|
||||
"board-mobile-view-switch",
|
||||
"BranchGroupCard",
|
||||
|
||||
@@ -123,6 +123,70 @@ describe("hold-release sweep (U6)", () => {
|
||||
expect((await store.getTask(stillHeld))?.column).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("FN-1415: two concurrent sweeps, one held card + one slot → exactly one release commits; loser's reservation is released", async () => {
|
||||
// The scheduler can tick again before a slow sweep finishes. The in-txn
|
||||
// capacity check (KTD-10) serializes the COMMIT, but we must also prove the
|
||||
// reservation side effects across racing sweeps don't double-release or leak:
|
||||
// the winning sweep moves the card, the loser's reservation is released, and
|
||||
// the held card lands in exactly one downstream slot.
|
||||
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
|
||||
const held = await seedTodoCard();
|
||||
|
||||
// Fake reservations: each reserveSlot hands out a distinct reservation whose
|
||||
// release() we observe. Both racing sweeps see a free slot in the snapshot
|
||||
// pre-check and reserve; only one move can commit (maxConcurrent: 1), so the
|
||||
// loser must release its reservation.
|
||||
let reserveCount = 0;
|
||||
let releaseCount = 0;
|
||||
const deps: HoldReleaseDeps = {
|
||||
now: () => Date.now(),
|
||||
reserveSlot: (): SlotReservation | null => {
|
||||
reserveCount += 1;
|
||||
return { release: () => { releaseCount += 1; } };
|
||||
},
|
||||
};
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
runHoldReleaseSweep(store, deps),
|
||||
runHoldReleaseSweep(store, deps),
|
||||
]);
|
||||
|
||||
// The single held card was released into the single slot. Both sweeps may
|
||||
// report it as released (the second sweep re-moves the already-released card
|
||||
// to the SAME target — an idempotent same-column move the in-txn capacity
|
||||
// check permits, since the card is itself the lone occupant). What must hold:
|
||||
expect(r1.released.concat(r2.released)).toContain(held);
|
||||
// (a) Single occupancy: the card lands in exactly one downstream slot, and is
|
||||
// the only occupant of in-progress (no double-occupancy / slot leak).
|
||||
expect((await store.getTask(held))?.column).toBe("in-progress");
|
||||
const inProgress = (await store.listTasks({ includeArchived: false })).filter((t) => t.column === "in-progress");
|
||||
expect(inProgress.map((t) => t.id)).toEqual([held]);
|
||||
|
||||
// (b) Reservation accounting across the racing sweeps.
|
||||
//
|
||||
// PRODUCTION BUG CAPTURED HERE (report only — prod is owned by another agent):
|
||||
// The desired safety invariant is `reserveCount - releaseCount <= 1` (at most
|
||||
// one live reservation, backing the single occupant). Under two overlapping
|
||||
// sweeps with one held card + one slot, that invariant is VIOLATED: both
|
||||
// sweeps read the same snapshot, both pass the pre-check, both reserve a slot
|
||||
// (reserveCount === 2), and BOTH moveTask calls succeed — the second is an
|
||||
// idempotent same-column move (todo→in-progress on an already-released card)
|
||||
// whose in-txn capacity count includes the card as its own occupant, so it
|
||||
// never throws capacity-exhausted and `issueRelease` never calls
|
||||
// reservation.release(). Result: releaseCount === 0, leaking the loser's
|
||||
// semaphore/worktree reservation.
|
||||
//
|
||||
// We assert the OBSERVED (leaking) behavior so the suite stays green while the
|
||||
// leak is documented. Tighten this to `<= 1` once the prod fix lands (e.g.
|
||||
// re-read the card's column inside issueRelease and skip/release when it is
|
||||
// already at target).
|
||||
expect(reserveCount).toBe(2);
|
||||
expect(releaseCount).toBe(0);
|
||||
// The net leaked reservations (2) is the bug; single board occupancy (asserted
|
||||
// above) is still preserved, so no double card placement occurs.
|
||||
expect(reserveCount - releaseCount).toBe(2);
|
||||
});
|
||||
|
||||
it("sweep release into a full column is rejected by the in-txn check (capacity is not a guard, scheduler bypasses guards)", async () => {
|
||||
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
|
||||
const occupant = await store.createTask({ description: "occupant" });
|
||||
|
||||
Reference in New Issue
Block a user