Files
kandev/apps/web/lib/ws/handlers/tasks.test.ts
T
José Almeida c64e835612 feat: allow moving tasks across workflows (#822)
* feat: allow moving tasks across workflows

* fix: address PR review feedback

* fix: address remaining PR review feedback

* fix: remove unreachable move state event

* fix: map task move conflicts correctly

* fix: address PR review nitpicks

* fix: use neutral task move error message
2026-05-05 14:13:43 +01:00

253 lines
8.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import { removeRecentTask } from "@/lib/recent-tasks";
import type { AppState } from "@/lib/state/store";
import { registerTasksHandlers } from "./tasks";
vi.mock("@/lib/recent-tasks", () => ({
removeRecentTask: vi.fn(),
}));
type Listener = (state: AppState) => void;
/**
* Minimal in-memory store for the tasks WS handler tests.
* The handler reads kanban tasks, kanbanMulti snapshots, and tasks.activeTaskId/activeSessionId,
* and calls setActiveSession; everything else can stay default.
*/
function makeStore(initial: Partial<AppState> = {}) {
let state = {
kanban: { workflowId: "wf1", steps: [], tasks: [] },
kanbanMulti: { snapshots: {}, isLoading: false },
tasks: { activeTaskId: null, activeSessionId: null, pinnedSessionId: null },
taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} },
environmentIdBySessionId: {},
setActiveSession: vi.fn((taskId: string, sessionId: string | null) => {
state = {
...state,
tasks: {
activeTaskId: taskId,
activeSessionId: sessionId,
pinnedSessionId: sessionId,
},
};
}),
setActiveSessionAuto: vi.fn((taskId: string, sessionId: string | null) => {
state = {
...state,
tasks: {
...state.tasks,
activeTaskId: taskId,
activeSessionId: sessionId,
},
};
}),
...initial,
} as unknown as AppState;
const listeners = new Set<Listener>();
return {
getState: () => state,
setState: (updater: AppState | ((s: AppState) => AppState)) => {
const next =
typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater;
state = { ...state, ...next };
for (const l of listeners) l(state);
},
subscribe: (l: Listener) => {
listeners.add(l);
return () => listeners.delete(l);
},
destroy: vi.fn(),
getInitialState: vi.fn(),
} as unknown as StoreApi<AppState> & { getState: () => AppState };
}
function makeTask(id: string, primarySessionId: string | null, workflowId = "wf1") {
return {
task_id: id,
workflow_id: workflowId,
workflow_step_id: "step1",
title: "Test",
description: "",
state: "IN_PROGRESS",
primary_session_id: primarySessionId,
is_ephemeral: false,
} as Record<string, unknown>;
}
function makeMessage(payload: Record<string, unknown>) {
return {
id: "msg-1",
type: "notification" as const,
action: "task.updated" as const,
payload,
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["task.updated"]>>[0];
}
function makeDeletedMessage(payload: Record<string, unknown>) {
return {
id: "msg-1",
type: "notification" as const,
action: "task.deleted" as const,
payload,
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["task.deleted"]>>[0];
}
describe("task.updated primary-session focus follow", () => {
let store: ReturnType<typeof makeStore>;
let setActiveSessionAuto: ReturnType<typeof vi.fn>;
beforeEach(() => {
setActiveSessionAuto = vi.fn();
vi.mocked(removeRecentTask).mockClear();
});
it("follows focus to the new primary when the user is on the previous primary", () => {
store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }],
} as unknown as AppState["kanban"],
tasks: { activeTaskId: "t1", activeSessionId: "sess-old", pinnedSessionId: null },
setActiveSessionAuto,
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new")));
expect(setActiveSessionAuto).toHaveBeenCalledTimes(1);
expect(setActiveSessionAuto).toHaveBeenCalledWith("t1", "sess-new");
});
it("does NOT follow focus when the user is on a different session than the previous primary", () => {
// User manually selected sess-other; primary swapping shouldn't yank them away.
store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }],
} as unknown as AppState["kanban"],
tasks: { activeTaskId: "t1", activeSessionId: "sess-other", pinnedSessionId: "sess-other" },
setActiveSessionAuto,
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new")));
expect(setActiveSessionAuto).not.toHaveBeenCalled();
});
it("does NOT follow focus when the user is viewing a different task", () => {
store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }],
} as unknown as AppState["kanban"],
tasks: { activeTaskId: "t2", activeSessionId: "sess-old", pinnedSessionId: null },
setActiveSessionAuto,
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new")));
expect(setActiveSessionAuto).not.toHaveBeenCalled();
});
it("does NOT call setActiveSessionAuto when the primary did not change", () => {
store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }],
} as unknown as AppState["kanban"],
tasks: { activeTaskId: "t1", activeSessionId: "sess-old", pinnedSessionId: null },
setActiveSessionAuto,
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-old")));
expect(setActiveSessionAuto).not.toHaveBeenCalled();
});
// Regression: even when the user happens to be sitting on the previous
// primary, an explicit pin on it must override primary-follow-focus —
// otherwise a workflow profile switch silently yanks them off the session
// they deliberately clicked into.
it("does NOT follow focus when the user has pinned the previous primary", () => {
store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }],
} as unknown as AppState["kanban"],
tasks: { activeTaskId: "t1", activeSessionId: "sess-old", pinnedSessionId: "sess-old" },
setActiveSessionAuto,
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new")));
expect(setActiveSessionAuto).not.toHaveBeenCalled();
});
});
describe("task.updated cross-workflow placement", () => {
it("removes the task from its old workflow snapshot before upserting into the new one", () => {
const task = { id: "t1", title: "Test", workflowId: "wf1", workflowStepId: "step1" };
const store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [task],
} as unknown as AppState["kanban"],
kanbanMulti: {
isLoading: false,
snapshots: {
wf1: { workflow: { id: "wf1" }, steps: [], tasks: [task] },
wf2: { workflow: { id: "wf2" }, steps: [], tasks: [] },
},
} as unknown as AppState["kanbanMulti"],
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(
makeMessage({ ...makeTask("t1", null, "wf2"), old_workflow_id: "wf1" }),
);
const state = store.getState();
expect(state.kanban.tasks).toHaveLength(0);
expect(state.kanbanMulti.snapshots.wf1.tasks).toHaveLength(0);
expect(state.kanbanMulti.snapshots.wf2.tasks).toHaveLength(1);
expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.id).toBe("t1");
expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.workflowStepId).toBe("step1");
});
});
describe("task.deleted cleanup", () => {
it("removes the deleted task from recent task history", () => {
const store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }],
} as unknown as AppState["kanban"],
environmentIdBySessionId: {},
});
const handlers = registerTasksHandlers(store);
handlers["task.deleted"]!(
makeDeletedMessage({
task_id: "t1",
workflow_id: "wf1",
}),
);
expect(removeRecentTask).toHaveBeenCalledTimes(1);
expect(removeRecentTask).toHaveBeenCalledWith("t1");
});
});