Files
2026-07-10 17:34:53 +02:00

258 lines
7.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const { pollMock } = vi.hoisted(() => ({
pollMock: vi.fn(),
}));
vi.mock("@/actions/task/poll", () => ({
pollTaskUntilSettled: pollMock,
}));
import {
registerTaskKindHandler,
resumePendingTasks,
TASK_WATCHER_STATUS,
trackAndPollTask,
useTaskWatcherStore,
} from "./store";
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("task watcher store", () => {
const onReady = vi.fn();
const onError = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
Object.defineProperty(navigator, "locks", {
configurable: true,
value: undefined,
});
useTaskWatcherStore.setState({ tasks: {} });
registerTaskKindHandler("test-kind", { onReady, onError });
});
it("tracks a task, polls it to completion and fires onReady once", async () => {
pollMock.mockResolvedValue({ ok: true, state: "completed" });
await trackAndPollTask({
taskId: "task-1",
kind: "test-kind",
meta: { complianceId: "csa_ccm_4.0" },
});
const task = useTaskWatcherStore.getState().tasks["task-1"];
expect(task?.status).toBe(TASK_WATCHER_STATUS.READY);
expect(onReady).toHaveBeenCalledTimes(1);
expect(onReady.mock.calls[0][0].meta.complianceId).toBe("csa_ccm_4.0");
expect(onError).not.toHaveBeenCalled();
});
it("replaces settled results of the same kind when tracking new work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"previous-task": {
taskId: "previous-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.READY,
meta: {},
startedAt: Date.now() - 1000,
},
},
});
// When
await trackAndPollTask({
taskId: "replacement-task",
kind: "test-kind",
meta: {},
});
// Then
expect(
useTaskWatcherStore.getState().tasks["previous-task"],
).toBeUndefined();
expect(
useTaskWatcherStore.getState().tasks["replacement-task"]?.status,
).toBe(TASK_WATCHER_STATUS.READY);
});
it("marks the task as error when the backend task fails", async () => {
pollMock.mockResolvedValue({ ok: true, state: "failed" });
await trackAndPollTask({
taskId: "task-2",
kind: "test-kind",
meta: {},
});
expect(useTaskWatcherStore.getState().tasks["task-2"]).toBeUndefined();
expect(onError).toHaveBeenCalledTimes(1);
expect(onReady).not.toHaveBeenCalled();
});
it("settles the task as error when polling itself throws", async () => {
pollMock.mockRejectedValue(new Error("network down"));
await trackAndPollTask({ taskId: "task-7", kind: "test-kind", meta: {} });
expect(useTaskWatcherStore.getState().tasks["task-7"]).toBeUndefined();
expect(onError).toHaveBeenCalledTimes(1);
expect(onReady).not.toHaveBeenCalled();
});
it("keeps polling through transient timeouts until the ceiling", async () => {
pollMock
.mockResolvedValueOnce({ ok: false, error: "Task timeout" })
.mockResolvedValueOnce({ ok: true, state: "completed" });
await trackAndPollTask({ taskId: "task-3", kind: "test-kind", meta: {} });
expect(pollMock).toHaveBeenCalledTimes(2);
expect(useTaskWatcherStore.getState().tasks["task-3"]?.status).toBe(
TASK_WATCHER_STATUS.READY,
);
});
it("does not start a second poll loop for an already-pending task", async () => {
let resolvePoll: (value: unknown) => void = () => {};
pollMock.mockImplementation(
() => new Promise((resolve) => (resolvePoll = resolve)),
);
const first = trackAndPollTask({
taskId: "task-4",
kind: "test-kind",
meta: {},
});
const second = trackAndPollTask({
taskId: "task-4",
kind: "test-kind",
meta: {},
});
await vi.waitFor(() => expect(pollMock).toHaveBeenCalledTimes(1));
resolvePoll({ ok: true, state: "completed" });
await Promise.all([first, second]);
await flush();
expect(pollMock).toHaveBeenCalledTimes(1);
expect(onReady).toHaveBeenCalledTimes(1);
});
it("does not poll or notify after another tab settles the same task", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"shared-task": {
taskId: "shared-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
const requestLock = vi.fn(
async (_name: string, callback: () => Promise<void>) => {
useTaskWatcherStore
.getState()
.resolveTask("shared-task", TASK_WATCHER_STATUS.READY);
await callback();
},
);
Object.defineProperty(navigator, "locks", {
configurable: true,
value: { request: requestLock },
});
// When
await resumePendingTasks();
// Then
expect(requestLock).toHaveBeenCalledTimes(1);
expect(pollMock).not.toHaveBeenCalled();
expect(onReady).not.toHaveBeenCalled();
});
it("resumes persisted pending tasks on watcher mount", async () => {
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"task-5": {
taskId: "task-5",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
await resumePendingTasks();
expect(useTaskWatcherStore.getState().tasks["task-5"]?.status).toBe(
TASK_WATCHER_STATUS.READY,
);
expect(onReady).toHaveBeenCalledTimes(1);
});
it("discards settled tasks before resuming persisted work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"old-ready-task": {
taskId: "old-ready-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.READY,
meta: {},
startedAt: Date.now() - 1000,
},
"pending-task": {
taskId: "pending-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
// When
await resumePendingTasks();
// Then
expect(
useTaskWatcherStore.getState().tasks["old-ready-task"],
).toBeUndefined();
expect(useTaskWatcherStore.getState().tasks["pending-task"]?.status).toBe(
TASK_WATCHER_STATUS.READY,
);
});
it("expires stale persisted tasks instead of polling them", async () => {
useTaskWatcherStore.setState({
tasks: {
"task-6": {
taskId: "task-6",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now() - 60 * 60 * 1000,
},
},
});
await resumePendingTasks();
expect(pollMock).not.toHaveBeenCalled();
expect(useTaskWatcherStore.getState().tasks["task-6"]).toBeUndefined();
expect(onError).toHaveBeenCalledTimes(1);
});
});