test(FN-5137): complete Step 4-6 — soft-delete regression coverage
Fusion-Task-Id: FN-5137 Fusion-Task-Lineage: bb2e2e56-d64c-46ca-a6c8-abda13029fc2
This commit is contained in:
committed by
gsxdsm
parent
9b6513af71
commit
f74c0d5105
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, type Task } from "@fusion/core";
|
||||
import { AutoClaimSnapshotManager } from "../auto-claim-snapshot.js";
|
||||
|
||||
describe("AutoClaimSnapshotManager soft-delete guards", () => {
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "fn-5137-engine-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "fn-5137-engine-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.stopWatching();
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns only live todo tasks after soft delete", async () => {
|
||||
const live = await store.createTask({ title: "Live", description: "live" });
|
||||
const deleted = await store.createTask({ title: "Deleted", description: "deleted" });
|
||||
await store.moveTask(live.id, "todo");
|
||||
await store.moveTask(deleted.id, "todo");
|
||||
await store.deleteTask(deleted.id);
|
||||
|
||||
const manager = new AutoClaimSnapshotManager({ taskStore: store });
|
||||
const snapshot = await manager.getSnapshot();
|
||||
|
||||
expect(snapshot.tasks.map((task) => task.id)).toEqual([live.id]);
|
||||
});
|
||||
|
||||
it("drops deleted ids after cache invalidation and rebuild", async () => {
|
||||
let includeDeletedCandidate = true;
|
||||
const listTasks = vi.fn(async () => ([
|
||||
{
|
||||
id: "FN-001",
|
||||
title: "First",
|
||||
description: "first",
|
||||
status: "open",
|
||||
column: "todo",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
dependencies: [],
|
||||
comments: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
deletedAt: null,
|
||||
},
|
||||
...(includeDeletedCandidate
|
||||
? [{
|
||||
id: "FN-002",
|
||||
title: "Second",
|
||||
description: "second",
|
||||
status: "open",
|
||||
column: "todo",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
dependencies: [],
|
||||
comments: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
deletedAt: null,
|
||||
}]
|
||||
: []),
|
||||
] as unknown as Task[]));
|
||||
|
||||
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks } });
|
||||
const beforeDelete = await manager.getSnapshot();
|
||||
expect(beforeDelete.tasks.map((task) => task.id)).toEqual(["FN-001", "FN-002"]);
|
||||
|
||||
includeDeletedCandidate = false;
|
||||
manager.invalidate("task:deleted");
|
||||
|
||||
const afterDelete = await manager.getSnapshot();
|
||||
expect(afterDelete.tasks.map((task) => task.id)).toEqual(["FN-001"]);
|
||||
});
|
||||
|
||||
it("defense-in-depth filters deleted candidates from synthetic listTasks results", async () => {
|
||||
const listTasks = vi.fn(async () => ([
|
||||
{
|
||||
id: "FN-live",
|
||||
title: "Live",
|
||||
description: "live",
|
||||
status: "open",
|
||||
column: "todo",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
dependencies: [],
|
||||
comments: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
id: "FN-deleted",
|
||||
title: "Deleted",
|
||||
description: "deleted",
|
||||
status: "open",
|
||||
column: "todo",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
dependencies: [],
|
||||
comments: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
deletedAt: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
] as unknown as Task[]));
|
||||
|
||||
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks } });
|
||||
const snapshot = await manager.getSnapshot();
|
||||
|
||||
expect(snapshot.tasks.map((task) => task.id)).toEqual(["FN-live"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import "./executor-test-helpers.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { executingTaskLock } from "../active-session-registry.js";
|
||||
import { executorLog } from "../logger.js";
|
||||
import * as childProcess from "node:child_process";
|
||||
|
||||
function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.title ?? null,
|
||||
description: overrides.description ?? "desc",
|
||||
status: overrides.status ?? "open",
|
||||
column: overrides.column ?? "in-progress",
|
||||
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
comments: overrides.comments ?? [],
|
||||
steps: overrides.steps ?? [],
|
||||
currentStep: overrides.currentStep ?? 0,
|
||||
log: overrides.log ?? [],
|
||||
assignedAgentId: overrides.assignedAgentId,
|
||||
paused: overrides.paused,
|
||||
deletedAt: overrides.deletedAt,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
function createStore(overrides?: { tasks?: Task[] }) {
|
||||
const listeners = new Map<string, ((payload: unknown) => void)[]>();
|
||||
const store = {
|
||||
on: vi.fn((event: string, listener: (payload: unknown) => void) => {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
listeners.set(event, existing);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
||||
listTasks: vi.fn().mockResolvedValue(overrides?.tasks ?? []),
|
||||
} as any;
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("TaskExecutor soft-delete guards", () => {
|
||||
it("refuses to execute soft-deleted tasks and releases execution lock", async () => {
|
||||
const store = createStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const warnSpy = vi.spyOn(executorLog, "warn");
|
||||
const execSyncSpy = vi.spyOn(childProcess, "execSync");
|
||||
|
||||
const task = makeTask({ id: "FN-5137", deletedAt: "2026-01-02T00:00:00.000Z" });
|
||||
await executor.execute(task);
|
||||
|
||||
expect(execSyncSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith("FN-5137: refusing execute — task is soft-deleted");
|
||||
expect(executingTaskLock.tryClaim(task.id)).toBe(true);
|
||||
executingTaskLock.release(task.id);
|
||||
});
|
||||
|
||||
it("resumeOrphaned skips in-progress tasks that are soft-deleted", async () => {
|
||||
const deletedTask = makeTask({
|
||||
id: "FN-deleted",
|
||||
column: "in-progress",
|
||||
paused: false,
|
||||
deletedAt: "2026-01-03T00:00:00.000Z",
|
||||
});
|
||||
const store = createStore({ tasks: [deletedTask] });
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const executeSpy = vi.spyOn(executor, "execute");
|
||||
|
||||
await executor.resumeOrphaned();
|
||||
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ function createStore() {
|
||||
on,
|
||||
off: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue("/test/project"),
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const emit = (event: string, payload: unknown) => {
|
||||
@@ -26,16 +28,41 @@ function createStore() {
|
||||
}
|
||||
|
||||
describe("Scheduler auto-claim snapshot invalidation", () => {
|
||||
it("invalidates on task:created and task:updated", () => {
|
||||
it("invalidates on task:created, task:updated, and task:deleted", () => {
|
||||
const invalidate = vi.fn();
|
||||
const { store, emit } = createStore();
|
||||
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||
|
||||
emit("task:created", { task: { id: "FN-1" } });
|
||||
emit("task:updated", { id: "FN-1" });
|
||||
emit("task:deleted", { id: "FN-1" });
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith("task:created");
|
||||
expect(invalidate).toHaveBeenCalledWith("task:updated");
|
||||
expect(invalidate).toHaveBeenCalledWith("task:deleted");
|
||||
});
|
||||
|
||||
it("clears scheduler bookkeeping for deleted tasks", () => {
|
||||
const { store, emit } = createStore();
|
||||
const scheduler = new Scheduler(store, {});
|
||||
const internals = scheduler as unknown as {
|
||||
pausedTaskIds: Set<string>;
|
||||
failedTaskIds: Set<string>;
|
||||
wasNodeDispatchValidationBlocked: Set<string>;
|
||||
wasNodeBlocked: Set<string>;
|
||||
};
|
||||
|
||||
internals.pausedTaskIds.add("FN-1");
|
||||
internals.failedTaskIds.add("FN-1");
|
||||
internals.wasNodeDispatchValidationBlocked.add("FN-1");
|
||||
internals.wasNodeBlocked.add("FN-1");
|
||||
|
||||
emit("task:deleted", { id: "FN-1" });
|
||||
|
||||
expect(internals.pausedTaskIds.has("FN-1")).toBe(false);
|
||||
expect(internals.failedTaskIds.has("FN-1")).toBe(false);
|
||||
expect(internals.wasNodeDispatchValidationBlocked.has("FN-1")).toBe(false);
|
||||
expect(internals.wasNodeBlocked.has("FN-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates task:moved only when todo is source or destination", () => {
|
||||
|
||||
Reference in New Issue
Block a user