test(FN-5152): complete Step 4 — add near-duplicate route regressions
Fusion-Task-Id: FN-5152 Fusion-Task-Lineage: 954570d8-82e1-4519-a739-c52ee790d2db
This commit is contained in:
committed by
gsxdsm
parent
97035f2d87
commit
6431c972e8
@@ -256,7 +256,6 @@ describe("task deterministic dedup", () => {
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeLogger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeLogger.warn).toHaveBeenCalledWith(
|
||||
"Deterministic duplicate pre-check failed; proceeding",
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -259,6 +259,31 @@ describe("task duplicate detection routes", () => {
|
||||
expect(body.details.matches[0]).toMatchObject({ id: "FN-32", deterministic: true });
|
||||
});
|
||||
|
||||
it("near-duplicate matches are suppressed when candidate is acknowledged", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({
|
||||
id: "FN-40",
|
||||
title: "Fix missing Create PR API routes",
|
||||
description: "Missing /pr/options /pr/preflight /pr/generate-metadata in packages/dashboard/src/routes/register-git-github.ts",
|
||||
column: "todo",
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
title: "Create PR modal 404s",
|
||||
description: "PrCreateModal hits /api/tasks/:id/pr/options /api/tasks/:id/pr/preflight /api/tasks/:id/pr/generate-metadata",
|
||||
acknowledgedDuplicates: ["FN-40"],
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("done tasks do not trigger conflict", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({ id: "FN-15", title: "Duplicate warning", description: "Warn before task creation", column: "done" }),
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import * as core from "@fusion/core";
|
||||
import type { Column, Task, TaskStore } from "@fusion/core";
|
||||
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
import { ApiError, sendErrorResponse } from "../api-error.js";
|
||||
|
||||
function mkTask(overrides: Partial<Task> & { id: string; description: string; column: Column }): Task {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
description: overrides.description,
|
||||
column: overrides.column,
|
||||
dependencies: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
size: "M",
|
||||
subtasks: [],
|
||||
log: [],
|
||||
tags: [],
|
||||
blockedBy: [],
|
||||
source: { sourceType: "api" },
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function buildApp(seed: Task[]) {
|
||||
const tasks = [...seed];
|
||||
const runtimeLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
||||
const store: Partial<TaskStore> = {
|
||||
searchTasks: vi.fn().mockResolvedValue(tasks),
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fingerprint: string) =>
|
||||
tasks.filter((task) => task.source?.sourceMetadata?.contentFingerprint === fingerprint),
|
||||
),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Task["source"] }) => {
|
||||
const created = mkTask({ id: `FN-${tasks.length + 100}`, title: input.title, description: input.description, column: "todo", source: input.source ?? { sourceType: "api" } });
|
||||
tasks.push(created);
|
||||
return created;
|
||||
}),
|
||||
recordActivity: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
registerTaskWorkflowRoutes({
|
||||
router,
|
||||
store: store as TaskStore,
|
||||
options: {},
|
||||
runtimeLogger: runtimeLogger as never,
|
||||
planningLogger: runtimeLogger as never,
|
||||
chatLogger: runtimeLogger as never,
|
||||
getProjectIdFromRequest: () => undefined,
|
||||
getScopedStore: async () => store as TaskStore,
|
||||
getProjectContext: async () => ({ store: store as TaskStore, engine: undefined, projectId: "p-1" }),
|
||||
prioritizeProjectsForCurrentDirectory: (projects) => projects,
|
||||
emitRemoteRouteDiagnostic: () => {},
|
||||
emitAuthSyncAuditLog: () => {},
|
||||
parseScopeParam: () => undefined,
|
||||
resolveAutomationStore: () => ({}) as never,
|
||||
resolveRoutineStore: () => ({}) as never,
|
||||
resolveRoutineRunner: () => ({}) as never,
|
||||
registerDispose: () => {},
|
||||
dispose: () => {},
|
||||
rethrowAsApiError: (error: unknown): never => {
|
||||
if (error instanceof ApiError) throw error;
|
||||
throw new ApiError(500, error instanceof Error ? error.message : "Internal server error");
|
||||
},
|
||||
}, {
|
||||
runtimeLogger: { error: vi.fn(), warn: runtimeLogger.warn },
|
||||
upload: { single: () => (_req: unknown, _res: unknown, next: () => void) => next() },
|
||||
taskDetailActivityLogLimit: 100,
|
||||
validateOptionalModelField: (value) => (typeof value === "string" ? value : undefined),
|
||||
normalizeModelSelectionPair: (provider, modelId) => ({ provider: provider ?? null, modelId: modelId ?? null }),
|
||||
runGitCommand: async () => "",
|
||||
trimTaskDetailActivityLog: (task) => task,
|
||||
triggerCommentWakeForAssignedAgent: async () => {},
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", router);
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (error instanceof ApiError) {
|
||||
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
|
||||
return;
|
||||
}
|
||||
sendErrorResponse(res, 500, error instanceof Error ? error.message : "Internal server error");
|
||||
});
|
||||
|
||||
return { app, tasks, runtimeLogger };
|
||||
}
|
||||
|
||||
describe("routes /api/tasks near duplicate", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const routeSeed = mkTask({
|
||||
id: "FN-5144",
|
||||
title: "Create PR routes missing handlers",
|
||||
description: "Telemetry stream from staging parser indicates transport omissions. Missing paths: /api/tasks/:id/pr/options, /api/tasks/:id/pr/preflight, /api/tasks/:id/pr/generate-metadata in register-git-github.ts. Extra context: websocket jitter artifact, markdown sanitizer mismatch, scheduler heartbeat skew.",
|
||||
column: "todo",
|
||||
});
|
||||
|
||||
const routeIncoming = {
|
||||
title: "Missing handlers for create PR routes",
|
||||
description: "Customer bug report with shell reproducer: GET /api/tasks/:id/pr/options, GET /api/tasks/:id/pr/preflight, POST /api/tasks/:id/pr/generate-metadata all return 404. Additional notes reference auth nonce parity, cache priming drift, and upstream webhook envelope differences.",
|
||||
};
|
||||
|
||||
const reviewSeed = mkTask({
|
||||
id: "FN-5145",
|
||||
title: "TaskReviewTab create PR button no-op",
|
||||
description: "Review pipeline regression: TaskReviewTab.tsx action fires analytics but PrCreateModal never mounts. Includes unrelated notes on markdown hydration, audit panel clipping, and timeline compaction.",
|
||||
column: "todo",
|
||||
});
|
||||
|
||||
const reviewIncoming = {
|
||||
title: "Create PR button no-op in TaskReviewTab",
|
||||
description: "Observed in QA replay: click handler executes in TaskReviewTab.tsx yet PrCreateModal is absent. Report also mentions artifact retention toggles, branch badge repaint, and virtualized list reflow.",
|
||||
};
|
||||
|
||||
it("reproduces FN-5144 -> FN-5149 with near-duplicate reason and shared tokens", async () => {
|
||||
vi.spyOn(core, "findDuplicateMatches").mockReturnValue([]);
|
||||
const { app, tasks } = buildApp([routeSeed]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify(routeIncoming), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
const match = (res.body as { details: { matches: Array<{ id: string; reason: string; sharedTokens: string[] }> } }).details.matches[0];
|
||||
expect(match.id).toBe("FN-5144");
|
||||
expect(match.reason).toBe("near-duplicate-intent");
|
||||
expect(match.sharedTokens.filter((token) => token.includes("/pr/")).length).toBeGreaterThanOrEqual(2);
|
||||
expect(tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reproduces FN-5145 -> FN-5150 near-duplicate block", async () => {
|
||||
vi.spyOn(core, "findDuplicateMatches").mockReturnValue([]);
|
||||
const { app, tasks } = buildApp([reviewSeed]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify(reviewIncoming), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
const match = (res.body as { details: { matches: Array<{ id: string; reason: string }> } }).details.matches[0];
|
||||
expect(match).toMatchObject({ id: "FN-5145", reason: "near-duplicate-intent" });
|
||||
expect(tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("acknowledgedDuplicates bypasses", async () => {
|
||||
const { app, tasks } = buildApp([routeSeed]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ ...routeIncoming, acknowledgedDuplicates: ["FN-5144"] }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect((res.body as Task).source?.sourceMetadata?.acknowledgedDuplicateIds).toEqual(["FN-5144"]);
|
||||
});
|
||||
|
||||
it("bypassDuplicateCheck bypasses", async () => {
|
||||
const { app, tasks } = buildApp([routeSeed]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ ...routeIncoming, bypassDuplicateCheck: true }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("generic-large-file overlap alone does not fire", async () => {
|
||||
const { app, tasks } = buildApp([
|
||||
mkTask({
|
||||
id: "FN-7001",
|
||||
title: "Fix PR comments pagination bug",
|
||||
description: "Update register-git-github.ts to page PR comments with cursor bounds only.",
|
||||
column: "todo",
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({
|
||||
title: "Add PR merge auto-rebase option",
|
||||
description: "Implement merge auto-rebase handling in register-git-github.ts without changing comment pagination.",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("outside 7-day window does not fire", async () => {
|
||||
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const { app, tasks } = buildApp([
|
||||
mkTask({
|
||||
id: "FN-9001",
|
||||
title: "Provision transport wiring for legacy routes",
|
||||
description: "Missing only /api/tasks/:id/pr/options, /api/tasks/:id/pr/preflight, /api/tasks/:id/pr/generate-metadata.",
|
||||
column: "todo",
|
||||
createdAt: old,
|
||||
updatedAt: old,
|
||||
}),
|
||||
]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({
|
||||
title: "Re-enable review initiation workflow",
|
||||
description: "Calls depend on /api/tasks/:id/pr/options, /api/tasks/:id/pr/preflight, and /api/tasks/:id/pr/generate-metadata route handlers.",
|
||||
}), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("deterministic exact match wins over near-duplicate", async () => {
|
||||
const title = routeIncoming.title;
|
||||
const description = routeIncoming.description;
|
||||
const fingerprint = core.computeContentFingerprint({ title, description }) as string;
|
||||
const { app } = buildApp([
|
||||
mkTask({
|
||||
id: "FN-8001",
|
||||
title,
|
||||
description,
|
||||
column: "todo",
|
||||
source: { sourceType: "api", sourceMetadata: { contentFingerprint: fingerprint } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title, description }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
const match = (res.body as { details: { matches: Array<{ id: string; deterministic?: boolean; reason?: string }> } }).details.matches[0];
|
||||
expect(match).toMatchObject({ id: "FN-8001", deterministic: true });
|
||||
expect(match.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails open when intent extraction throws", async () => {
|
||||
const extractorSpy = vi.spyOn(core, "extractIntentSignature").mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const { app, tasks, runtimeLogger } = buildApp([]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify(routeIncoming), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(extractorSpy).toHaveBeenCalled();
|
||||
expect(runtimeLogger.warn).toHaveBeenCalledWith(
|
||||
"Near-duplicate intent guard failed; proceeding",
|
||||
expect.objectContaining({ error: "boom" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user