pendingTaskResolutions.test.ts8.1 KBView on GitHub /**
* Completing or deleting a task defers the server write ~5s so Undo is a true undo. For those 5s
* the server still reports the task `todo`, so any refetch landing in the window used to re-hydrate
* the row and the user watched their completed task pop back in.
*
* The commonest trigger is self-inflicted: ticking a second task within 5s of the first means the
* first task's own post-write `invalidateQueries` refetches the conversation while the second is
* still only optimistic. These tests pin the mask that survives such a refetch.
*
* See apps/mail/modules/userTasks/lib/pending-task-resolutions.ts.
*/
import {
applyPendingTaskResolutions,
clearTaskResolutionPending,
getPendingTaskResolution,
markTaskResolutionPending,
} from '@/modules/userTasks/lib/pending-task-resolutions';
import type { HydratedConversation } from '@/modules/crm/types';
import type { HydratedUserTask } from '@/modules/userTasks/slice/userTasksSlice';
import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/store';
function task(id: string, status: HydratedUserTask['status'] = 'todo'): HydratedUserTask {
return {
id,
userId: 'u1',
conversationId: 'conv-1',
taskGroupId: null,
taskChannel: 'email',
taskType: 'response',
taskCreatedBy: 'agent',
taskActionData: null,
agentExecutionEnabled: false,
executionRunId: null,
creationRunId: null,
notes: null,
chatThreadId: null,
description: `task ${id}`,
status,
isRead: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
completedAt: null,
dueDate: new Date('2026-01-02'),
taskOutput: null,
sourceThreadId: null,
sourceDocumentId: null,
sourceMarkerId: null,
tags: [],
sortOrder: 0,
sortOrderPinned: false,
};
}
/** What `crm.getConversation` hands back — only the fields the reducer walks matter here. */
function serverConversation(tasks: HydratedUserTask[]): HydratedConversation {
return {
conversation: { id: 'conv-1', events: [] },
userTasks: tasks,
} as unknown as HydratedConversation;
}
const CONVERSATION_ID = 'conv-1';
const reset = () => {
clearTaskResolutionPending('a');
clearTaskResolutionPending('b');
useCedarStore.setState((s) => ({ ...s, tasks: {}, conversations: {} }));
};
beforeEach(reset);
afterEach(reset);
// ---------------------------------------------------------------------------
// The overlay itself
// ---------------------------------------------------------------------------
describe('applyPendingTaskResolutions', () => {
it('returns the input untouched when nothing is pending', () => {
const tasks = [task('a'), task('b')];
expect(applyPendingTaskResolutions(tasks)).toBe(tasks);
});
it('forces a pending completion to done rather than dropping it (the animation renders it)', () => {
markTaskResolutionPending('a', 'done');
const masked = applyPendingTaskResolutions([task('a'), task('b')]);
expect(masked.map((t) => [t.id, t.status])).toEqual([
['a', 'done'],
['b', 'todo'],
]);
});
it('drops a pending deletion outright', () => {
markTaskResolutionPending('a', 'deleted');
expect(applyPendingTaskResolutions([task('a'), task('b')]).map((t) => t.id)).toEqual(['b']);
});
it('stops masking once the resolution is cleared', () => {
markTaskResolutionPending('a', 'done');
clearTaskResolutionPending('a');
expect(getPendingTaskResolution('a')).toBeUndefined();
const tasks = [task('a')];
expect(applyPendingTaskResolutions(tasks)).toBe(tasks);
});
});
// ---------------------------------------------------------------------------
// setConversations — the conversation checklist (ConversationView)
// ---------------------------------------------------------------------------
describe('setConversations during the undo window', () => {
it('does not resurrect a task whose completion has not reached the server yet', () => {
// Sibling completion invalidates crm.getConversation; the refetch still says `todo`.
markTaskResolutionPending('a', 'done');
act(() =>
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: serverConversation([task('a'), task('b')]) }),
);
const stored = useCedarStore.getState().conversations[CONVERSATION_ID].data.userTasks;
// The checklist filters to `todo` — 'a' must not be in that set.
expect(stored.filter((t) => t.status === 'todo').map((t) => t.id)).toEqual(['b']);
expect(stored.find((t) => t.id === 'a')?.status).toBe('done');
});
it('hides a task whose deletion has not reached the server yet', () => {
markTaskResolutionPending('a', 'deleted');
act(() =>
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: serverConversation([task('a'), task('b')]) }),
);
expect(
useCedarStore
.getState()
.conversations[CONVERSATION_ID].data.userTasks.map((t) => t.id),
).toEqual(['b']);
});
it('lets the task back in once the resolution is cleared (undo, or a failed write)', () => {
markTaskResolutionPending('a', 'done');
act(() =>
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: serverConversation([task('a')]) }),
);
clearTaskResolutionPending('a');
act(() =>
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: serverConversation([task('a')]) }),
);
expect(
useCedarStore.getState().conversations[CONVERSATION_ID].data.userTasks[0].status,
).toBe('todo');
});
});
// ---------------------------------------------------------------------------
// hydrateTodoTasks — the /tasks board and kanban
// ---------------------------------------------------------------------------
describe('hydrateTodoTasks during the undo window', () => {
it('does not put a pending-complete task back on the board as todo', () => {
markTaskResolutionPending('a', 'done');
act(() => useCedarStore.getState().hydrateTodoTasks([task('a'), task('b')]));
const { tasks } = useCedarStore.getState();
expect(tasks.a.status).toBe('done');
expect(tasks.b.status).toBe('todo');
});
it('does not put a pending-deleted task back on the board at all', () => {
markTaskResolutionPending('a', 'deleted');
act(() => useCedarStore.getState().hydrateTodoTasks([task('a'), task('b')]));
expect(Object.keys(useCedarStore.getState().tasks)).toEqual(['b']);
});
});
// ---------------------------------------------------------------------------
// upsertServerTasks — the agenda's own day-ranged query
// ---------------------------------------------------------------------------
//
// The agenda does not go through `useHydrateTasksSlice`; it runs its own `listUserTasks` for the
// visible day and mirrors the result into the store. That mirror used raw `setTasks` and so was
// the one unmasked door for server data, which is what put a just-ticked task back on screen.
// Reported Aug 28: "I see a task that I marked complete but it reappears."
describe('upsertServerTasks during the undo window', () => {
it('does not put a pending-complete task back as todo', () => {
markTaskResolutionPending('a', 'done');
act(() => useCedarStore.getState().upsertServerTasks([task('a'), task('b')]));
const { tasks } = useCedarStore.getState();
expect(tasks.a.status).toBe('done');
expect(tasks.b.status).toBe('todo');
});
it('does not re-add a pending-deleted task', () => {
markTaskResolutionPending('a', 'deleted');
act(() => useCedarStore.getState().upsertServerTasks([task('a'), task('b')]));
expect(Object.keys(useCedarStore.getState().tasks)).toEqual(['b']);
});
// The distinction from hydrateTodoTasks. The agenda's list covers one day, so a task absent
// from it is out of range, not gone — reconciling removals here would empty the board.
it('leaves tasks outside the incoming partial list untouched', () => {
act(() => useCedarStore.getState().hydrateTodoTasks([task('a'), task('b')]));
act(() => useCedarStore.getState().upsertServerTasks([task('a')]));
expect(Object.keys(useCedarStore.getState().tasks).sort()).toEqual(['a', 'b']);
});
});