feat(FN-4023): preserve triage column when SSE updates contain only priorit
Fix FN-4023: preserve the triage column filter when SSE updates only carry priority changes, preventing unintended column resets during selective priority refreshes. Added regression tests across the task store, useTasks hook, and API routes to cover column-stability scenarios. Fusion-Task-Id: FN-4023
This commit is contained in:
@@ -36,6 +36,18 @@ describe("TaskStore", () => {
|
||||
expect(detail.priority).toBe("normal");
|
||||
});
|
||||
|
||||
it("keeps triage tasks in triage when only priority changes", async () => {
|
||||
const task = await harness.store().createTask({
|
||||
description: "Planning task with manual review",
|
||||
column: "triage",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
const updated = await harness.store().updateTask(task.id, { priority: "urgent" });
|
||||
expect(updated.priority).toBe("urgent");
|
||||
expect(updated.column).toBe("triage");
|
||||
});
|
||||
|
||||
it("preserves explicit priority through archive and unarchive", async () => {
|
||||
const task = await harness.store().createTask({
|
||||
description: "Archive priority task",
|
||||
|
||||
@@ -169,6 +169,20 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("updateTask — priority", () => {
|
||||
it("does not move triage tasks when only priority is updated", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Planning task",
|
||||
column: "triage",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
const updated = await store.updateTask(task.id, { priority: "urgent" });
|
||||
expect(updated.column).toBe("triage");
|
||||
expect(updated.priority).toBe("urgent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask — blockedBy", () => {
|
||||
it("sets blockedBy to a string value", async () => {
|
||||
const task = await store.createTask({ title: "Blocked task", description: "A task" });
|
||||
|
||||
@@ -891,17 +891,28 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates priority inline and propagates successful save", async () => {
|
||||
it("updates priority inline and propagates successful save without moving triage tasks", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
const onTaskUpdated = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const updatedTask = makeTask({ id: "FN-001", column: "triage", priority: "urgent" });
|
||||
const updatedTask = makeTask({
|
||||
id: "FN-001",
|
||||
column: "triage",
|
||||
status: "awaiting-approval",
|
||||
priority: "urgent",
|
||||
});
|
||||
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "normal" })}
|
||||
task={makeTask({
|
||||
id: "FN-001",
|
||||
column: "triage",
|
||||
status: "awaiting-approval",
|
||||
description: "Priority metadata",
|
||||
priority: "normal",
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
@@ -919,7 +930,12 @@ describe("TaskDetailModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
|
||||
});
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: "FN-001",
|
||||
column: "triage",
|
||||
status: "awaiting-approval",
|
||||
priority: "urgent",
|
||||
}));
|
||||
expect(addToast).toHaveBeenCalledWith("Priority updated to urgent", "success");
|
||||
});
|
||||
|
||||
|
||||
@@ -637,6 +637,112 @@ describe("useTasks", () => {
|
||||
// Should preserve the done column since we have timestamp and incoming doesn't
|
||||
expect(result.current.tasks[0].column).toBe("done");
|
||||
});
|
||||
|
||||
it("keeps triage tasks in triage when task:updated only changes priority", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "triage" as Column,
|
||||
status: "awaiting-approval",
|
||||
priority: "normal",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("triage");
|
||||
});
|
||||
|
||||
const priorityOnlyUpdate = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "triage" as Column,
|
||||
status: "awaiting-approval",
|
||||
priority: "urgent",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-03T00:00:00Z",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:updated", priorityOnlyUpdate);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("triage");
|
||||
expect(result.current.tasks[0].status).toBe("awaiting-approval");
|
||||
expect(result.current.tasks[0].priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("keeps triage column when priority-only task:updated payload has mismatched stale column", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "triage" as Column,
|
||||
status: "awaiting-approval",
|
||||
priority: "normal",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("triage");
|
||||
});
|
||||
|
||||
const mismatchedPriorityUpdate = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "todo" as Column,
|
||||
status: "awaiting-approval",
|
||||
priority: "urgent",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-03T00:00:00Z",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:updated", mismatchedPriorityUpdate);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("triage");
|
||||
expect(result.current.tasks[0].priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("allows explicit approve-plan move events to transition triage tasks to todo", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "triage" as Column,
|
||||
status: "awaiting-approval",
|
||||
priority: "urgent",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("triage");
|
||||
});
|
||||
|
||||
const approvePlanMove = {
|
||||
task: createMockTask({
|
||||
id: "FN-001",
|
||||
column: "triage" as Column,
|
||||
status: "awaiting-approval",
|
||||
priority: "urgent",
|
||||
columnMovedAt: "2026-01-03T00:00:00Z",
|
||||
updatedAt: "2026-01-03T00:00:00Z",
|
||||
}),
|
||||
from: "triage" as Column,
|
||||
to: "todo" as Column,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:moved", approvePlanMove);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("todo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE event: task:deleted", () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ function mergeIncomingTask(current: Task, incoming: Task): Task {
|
||||
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
|
||||
}
|
||||
|
||||
if (columnTimestampCompare > 0) {
|
||||
if (columnTimestampCompare >= 0) {
|
||||
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
|
||||
}
|
||||
|
||||
|
||||
@@ -1484,8 +1484,9 @@ describe("PATCH /tasks/:id", () => {
|
||||
expect(res.body.dependencies).toEqual(["FN-002"]);
|
||||
});
|
||||
|
||||
it("forwards priority to store.updateTask", async () => {
|
||||
const updatedTask = { ...FAKE_TASK_DETAIL, priority: "high" as const };
|
||||
it("forwards priority to store.updateTask without changing task column", async () => {
|
||||
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "awaiting-approval" as const };
|
||||
const updatedTask = { ...triageTask, priority: "high" as const };
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ priority: "high" }), {
|
||||
@@ -1495,6 +1496,8 @@ describe("PATCH /tasks/:id", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { priority: "high" });
|
||||
expect(res.body.priority).toBe("high");
|
||||
expect(res.body.column).toBe("triage");
|
||||
expect(res.body.status).toBe("awaiting-approval");
|
||||
});
|
||||
|
||||
it("forwards priority=null to store.updateTask (resets to default)", async () => {
|
||||
|
||||
Reference in New Issue
Block a user