Files
kandev/apps/web/hooks/use-task-menu-actions.test.ts
João Salavisa 8a6a3c1e61 feat: show progress while archiving tasks (#3775)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-09-22 20:54:00 +01:00

241 lines
9.6 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAppStore } from "@/lib/state/store";
import { useTaskMenuActions } from "./use-task-menu-actions";
const api = vi.hoisted(() => ({
archiveTask: vi.fn(),
deleteTask: vi.fn(),
toast: vi.fn(),
softNavigate: vi.fn(),
}));
let store: ReturnType<typeof createAppStore>;
vi.mock("@/components/state-provider", () => ({ useAppStoreApi: () => store }));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: api.toast }) }));
vi.mock("@/lib/routing/client-router", () => ({ softNavigate: api.softNavigate }));
vi.mock("@/lib/api", () => ({
archiveTask: api.archiveTask,
deleteTask: api.deleteTask,
moveTask: vi.fn(),
updateTask: vi.fn(),
fetchTask: vi.fn(),
listTaskSessions: vi.fn(),
}));
const tasks = ["A", "B"].map((id) => ({
id,
title: id,
workspaceId: "workspace",
workflowId: "workflow",
workflowStepId: "step",
position: 0,
}));
const toastCallsWithVariant = (variant: string) =>
api.toast.mock.calls.filter(([input]) => input.variant === variant);
beforeEach(() => {
vi.clearAllMocks();
store = createAppStore();
store.setState((state) => ({
kanban: { ...state.kanban, workflowId: "workflow", tasks },
kanbanMulti: {
...state.kanbanMulti,
snapshots: {
workflow: { ...state.kanban, workflowId: "workflow", tasks },
},
},
}));
});
describe("concurrent shared task menu removal", () => {
it.each(["runArchive", "runDelete"] as const)(
"%s accepts another task while retaining the first task's duplicate guard",
async (method) => {
let releaseA!: () => void;
let releaseB!: () => void;
const requestA = new Promise<void>((resolve) => {
releaseA = resolve;
});
const requestB = new Promise<void>((resolve) => {
releaseB = resolve;
});
const request = method === "runArchive" ? api.archiveTask : api.deleteTask;
request.mockImplementation((id: string) => (id === "A" ? requestA : requestB));
const { result } = renderHook(() => useTaskMenuActions({ stayOnListing: true }));
let outcomeA!: Promise<boolean>;
let outcomeB!: Promise<boolean>;
await act(async () => {
outcomeA = result.current[method]("A");
outcomeB = result.current[method]("B");
});
expect(request.mock.calls.map(([id]) => id)).toEqual(["A", "B"]);
expect(result.current.pendingTaskId).toBe("A");
await act(async () => {
expect(await result.current[method]("A")).toBe(false);
expect(await result.current[method]("B")).toBe(false);
releaseB();
expect(await outcomeB).toBe(true);
});
expect(result.current.pendingTaskId).toBe("A");
expect(store.getState().kanban.tasks.map(({ id }) => id)).toEqual(["A"]);
await act(async () => {
expect(await result.current[method]("A")).toBe(false);
releaseA();
expect(await outcomeA).toBe(true);
});
expect(request).toHaveBeenCalledTimes(2);
expect(result.current.pendingTaskId).toBeNull();
expect(store.getState().kanban.tasks).toEqual([]);
},
);
it.each(["runArchive", "runDelete"] as const)(
"%s failing A keeps B pending and permits retrying only A",
async (method) => {
let rejectA!: (error: Error) => void;
let releaseB!: () => void;
const requestA = new Promise<void>((_, reject) => {
rejectA = reject;
});
const requestB = new Promise<void>((resolve) => {
releaseB = resolve;
});
const request = method === "runArchive" ? api.archiveTask : api.deleteTask;
request.mockImplementation((id: string) => (id === "A" ? requestA : requestB));
const { result } = renderHook(() => useTaskMenuActions({ stayOnListing: true }));
let outcomeA!: Promise<boolean>;
let outcomeB!: Promise<boolean>;
await act(async () => {
outcomeA = result.current[method]("A");
outcomeB = result.current[method]("B");
});
expect(request.mock.calls.map(([id]) => id)).toEqual(["A", "B"]);
await act(async () => {
rejectA(new Error("offline"));
expect(await outcomeA).toBe(false);
});
expect(result.current.pendingTaskId).toBe("B");
expect(toastCallsWithVariant("error")).toHaveLength(1);
request.mockResolvedValueOnce(undefined);
await act(async () => {
expect(await result.current[method]("B")).toBe(false);
expect(await result.current[method]("A")).toBe(true);
});
expect(result.current.pendingTaskId).toBe("B");
await act(async () => {
releaseB();
expect(await outcomeB).toBe(true);
});
expect(request).toHaveBeenCalledTimes(3);
expect(result.current.pendingTaskId).toBeNull();
},
);
});
describe("shared task menu removal", () => {
// @covers AC-TASKS-THREADS-ACTIONS-002.4, AC-TASKS-REMOVAL-NAVIGATION-002.3
it.each(["runArchive", "runDelete"] as const)(
"%s coordinates a listing removal without departing the remembered task",
async (method) => {
store.getState().setActiveSession("A", "session-A");
const selection = store.getState().tasks;
let release!: () => void;
const request = method === "runArchive" ? api.archiveTask : api.deleteTask;
request.mockReturnValueOnce(new Promise<void>((resolve) => (release = resolve)));
const { result } = renderHook(() => useTaskMenuActions({ stayOnListing: true }));
let outcome!: Promise<boolean>;
act(() => {
outcome = result.current[method]("A");
});
try {
const operations = Object.values(store.getState().taskRemoval.operationsByToken);
expect(operations).toHaveLength(1);
expect(operations[0]).toMatchObject({ taskIds: ["A"], departure: null });
expect(store.getState().kanban.tasks).toEqual(tasks);
expect(toastCallsWithVariant("loading")).toHaveLength(Number(method === "runArchive"));
} finally {
await act(async () => {
release();
expect(await outcome).toBe(true);
});
}
expect(store.getState().tasks).toEqual(selection);
expect(store.getState().kanban.tasks.map(({ id }) => id)).toEqual(["B"]);
expect(store.getState().taskRemoval.operationsByToken).toEqual({});
expect(api.softNavigate).not.toHaveBeenCalled();
expect(api.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "success" }));
},
);
// @covers AC-TASKS-THREADS-ACTIONS-002.5, AC-TASKS-THREADS-ACTIONS-003.4
it.each(["runArchive", "runDelete"] as const)(
"%s failure on a listing never navigates back to the remembered task",
async (method) => {
store.getState().setActiveSession("A", "session-A");
const selection = store.getState().tasks;
const request = method === "runArchive" ? api.archiveTask : api.deleteTask;
request.mockRejectedValueOnce(new Error("offline"));
const { result } = renderHook(() => useTaskMenuActions({ stayOnListing: true }));
await act(async () => {
expect(await result.current[method]("A")).toBe(false);
});
expect(store.getState().tasks).toEqual(selection);
expect(store.getState().kanban.tasks).toEqual(tasks);
expect(store.getState().taskRemoval.operationsByToken).toEqual({});
expect(api.softNavigate).not.toHaveBeenCalled();
expect(toastCallsWithVariant("error")).toHaveLength(1);
expect(api.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
},
);
// @covers AC-TASKS-THREADS-ACTIONS-002.2, AC-TASKS-THREADS-ACTIONS-002.4, AC-TASKS-THREADS-ACTIONS-002.6
it.each(["runArchive", "runDelete"] as const)(
"%s retains A while selection changes to B and rejects duplicate submits",
async (method) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const request = method === "runArchive" ? api.archiveTask : api.deleteTask;
request.mockReturnValueOnce(pending);
const { result } = renderHook(() => useTaskMenuActions({ stayOnListing: true }));
let outcome!: Promise<boolean>;
act(() => {
outcome = result.current[method]("A", { cascade: true });
});
expect(result.current.pendingTaskId).toBe("A");
store.getState().setActiveTask("B");
await act(async () => {
expect(await result.current[method]("A")).toBe(false);
release();
expect(await outcome).toBe(true);
});
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("A", { cascade: true });
expect(store.getState().kanban.tasks.map((task) => task.id)).toEqual(["B"]);
expect(store.getState().tasks.activeTaskId).toBe("B");
expect(result.current.pendingTaskId).toBeNull();
},
);
// @covers AC-TASKS-THREADS-ACTIONS-002.5
it.each(["runArchive", "runDelete"] as const)(
"%s failure retains tasks, reports once and permits retry",
async (method) => {
const request = method === "runArchive" ? api.archiveTask : api.deleteTask;
request.mockRejectedValueOnce(new Error("offline"));
const { result } = renderHook(() => useTaskMenuActions({ stayOnListing: true }));
await act(async () => {
expect(await result.current[method]("A")).toBe(false);
});
expect(store.getState().kanban.tasks).toEqual(tasks);
expect(toastCallsWithVariant("error")).toHaveLength(1);
expect(result.current.pendingTaskId).toBeNull();
request.mockResolvedValueOnce(undefined);
await act(async () => {
expect(await result.current[method]("A")).toBe(true);
});
},
);
});