Files
kandev/apps/web/hooks/use-task-actions.test.ts
T
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

276 lines
9.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
const archiveTaskMock = vi.fn();
const deleteTaskMock = vi.fn();
const removeTaskFromBoardMock = vi.fn();
const runTaskRemovalMock = vi.fn();
const getStateMock = vi.fn();
const storeMock = { getState: getStateMock };
const replaceTaskUrlMock = vi.fn();
const setActiveSessionMock = vi.fn();
const setActiveTaskMock = vi.fn();
const toastMock = vi.fn();
const dismissToastMock = vi.fn();
const archiveFailureMessage = "archive failed";
const archiveProgressToastId = "archive-progress-toast";
let storeState: {
tasks: { activeTaskId: string | null; activeSessionId: string | null };
setActiveSession: (...args: unknown[]) => void;
setActiveTask: (...args: unknown[]) => void;
};
vi.mock("@/lib/api", () => ({
archiveTask: (...args: unknown[]) => archiveTaskMock(...args),
deleteTask: (...args: unknown[]) => deleteTaskMock(...args),
moveTask: vi.fn(),
updateTask: vi.fn(),
}));
vi.mock("@/lib/links", () => ({
replaceTaskUrl: (...args: unknown[]) => replaceTaskUrlMock(...args),
}));
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => storeMock,
}));
vi.mock("@/hooks/use-task-removal", () => ({
useTaskRemovalSuccessNotifier: () => vi.fn(),
useTaskRemoval: () => ({
removeTaskFromBoard: (...args: unknown[]) => removeTaskFromBoardMock(...args),
runTaskRemoval: (...args: unknown[]) => runTaskRemovalMock(...args),
}),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: toastMock, dismissToast: dismissToastMock }),
}));
import {
useArchiveAndSwitchTask,
useDeleteAndSwitchTask,
useTaskActions,
} from "./use-task-actions";
beforeEach(() => {
vi.clearAllMocks();
toastMock.mockReturnValue(archiveProgressToastId);
storeState = {
tasks: { activeTaskId: "task-A", activeSessionId: "sess-A" },
setActiveSession: (...args: unknown[]) => setActiveSessionMock(...args),
setActiveTask: (...args: unknown[]) => setActiveTaskMock(...args),
};
getStateMock.mockReturnValue(storeState);
removeTaskFromBoardMock.mockResolvedValue({ switchedTaskId: "task-B" });
runTaskRemovalMock.mockImplementation(
async (_action: string, request: { mutate: () => Promise<void> }) => {
await request.mutate();
return {
skipped: false,
operationToken: "removal-1",
switchedTaskId: "task-B",
succeededTaskIds: ["task-A"],
failedTaskIds: [],
};
},
);
});
describe("useArchiveAndSwitchTask", () => {
it("delegates archive mutation and navigation to the shared coordinator", async () => {
const { result } = renderHook(() => useArchiveAndSwitchTask());
await act(() => result.current("task-A"));
expect(runTaskRemovalMock).toHaveBeenCalledWith(
"archive",
{ taskId: "task-A", mutate: expect.any(Function) },
{ cascade: undefined },
);
expect(archiveTaskMock).toHaveBeenCalledWith("task-A", undefined);
});
it("propagates coordinator failures without a second rollback", async () => {
const error = new Error("network error");
runTaskRemovalMock.mockRejectedValueOnce(error);
const { result } = renderHook(() => useArchiveAndSwitchTask());
await expect(result.current("task-A")).rejects.toThrow("network error");
expect(setActiveSessionMock).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
expect(archiveTaskMock).not.toHaveBeenCalled();
});
it("passes the cascade choice to the shared coordinator", async () => {
archiveTaskMock.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useArchiveAndSwitchTask());
await result.current("task-A", { cascade: true });
expect(runTaskRemovalMock).toHaveBeenCalledWith(
"archive",
{ taskId: "task-A", mutate: expect.any(Function) },
{ cascade: true },
);
});
it("does not restore a task when the coordinator rejects before mutation", async () => {
const error = new Error(archiveFailureMessage);
runTaskRemovalMock.mockRejectedValueOnce(error);
const { result } = renderHook(() => useArchiveAndSwitchTask());
await expect(result.current("task-A", { cascade: true })).rejects.toThrow(
archiveFailureMessage,
);
expect(setActiveSessionMock).not.toHaveBeenCalled();
expect(setActiveTaskMock).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
});
});
describe("useDeleteAndSwitchTask", () => {
it("delegates delete mutation and navigation to the shared coordinator", async () => {
const { result } = renderHook(() => useDeleteAndSwitchTask());
await act(() => result.current("task-A"));
expect(runTaskRemovalMock).toHaveBeenCalledWith(
"delete",
{ taskId: "task-A", mutate: expect.any(Function) },
{ cascade: undefined },
);
expect(deleteTaskMock).toHaveBeenCalledWith("task-A", undefined);
});
it("propagates coordinator failures without a second rollback", async () => {
const error = new Error("delete failed");
runTaskRemovalMock.mockRejectedValueOnce(error);
const { result } = renderHook(() => useDeleteAndSwitchTask());
await expect(result.current("task-A")).rejects.toThrow("delete failed");
expect(setActiveSessionMock).not.toHaveBeenCalled();
expect(setActiveTaskMock).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
expect(deleteTaskMock).not.toHaveBeenCalled();
});
});
describe("useTaskActions", () => {
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.1, AC-TASKS-REMOVAL-NAVIGATION-004.2
it("keeps a loading toast visible until an archive request resolves", async () => {
let resolveArchive!: () => void;
archiveTaskMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveArchive = resolve;
}),
);
const { result } = renderHook(() => useTaskActions());
const request = result.current.archiveTaskById("task-1");
expect(toastMock).toHaveBeenCalledWith({
title: "Archiving in progress",
variant: "loading",
});
expect(dismissToastMock).not.toHaveBeenCalled();
resolveArchive();
await request;
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.2
it("dismisses the loading toast when an archive request rejects", async () => {
archiveTaskMock.mockRejectedValueOnce(new Error(archiveFailureMessage));
const { result } = renderHook(() => useTaskActions());
await expect(result.current.archiveTaskById("task-1")).rejects.toThrow(archiveFailureMessage);
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.3
it("coalesces concurrent archive requests into one loading toast", async () => {
let resolveFirst!: () => void;
let resolveSecond!: () => void;
archiveTaskMock
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveFirst = resolve;
}),
)
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSecond = resolve;
}),
);
const { result } = renderHook(() => useTaskActions());
const firstRequest = result.current.archiveTaskById("task-1");
const secondRequest = result.current.archiveTaskById("task-2");
expect(toastMock).toHaveBeenCalledTimes(1);
resolveFirst();
await firstRequest;
expect(dismissToastMock).not.toHaveBeenCalled();
resolveSecond();
await secondRequest;
expect(dismissToastMock).toHaveBeenCalledTimes(1);
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.2, AC-TASKS-REMOVAL-NAVIGATION-004.3
it("keeps the shared toast until a concurrent request resolves after another rejects", async () => {
let rejectFirst!: (error: Error) => void;
let resolveSecond!: () => void;
archiveTaskMock
.mockReturnValueOnce(
new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSecond = resolve;
}),
);
const { result } = renderHook(() => useTaskActions());
const firstRequest = result.current.archiveTaskById("task-1");
const secondRequest = result.current.archiveTaskById("task-2");
rejectFirst(new Error(archiveFailureMessage));
await expect(firstRequest).rejects.toThrow(archiveFailureMessage);
expect(dismissToastMock).not.toHaveBeenCalled();
resolveSecond();
await secondRequest;
expect(dismissToastMock).toHaveBeenCalledTimes(1);
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
it("shows localized retry guidance for a dirty-worktree delete conflict", async () => {
const { ApiError } = await import("@/lib/api/client");
deleteTaskMock.mockRejectedValueOnce(
new ApiError("task worktree contains local changes", 409, {
error_code: "task_delete_dirty_worktree",
}),
);
const { result } = renderHook(() => useTaskActions());
await expect(result.current.deleteTaskById("task-1")).rejects.toMatchObject({ status: 409 });
expect(toastMock).toHaveBeenCalledWith({
title: "Task deletion needs your confirmation",
description:
"The task stays visible. Review the local changes, then select discard and try again.",
variant: "error",
});
});
});