resolveTasksOnSend.test.ts8.8 KBView on GitHub /**
* Sending something that satisfies a task must close that task ON THE CLICK, on every surface.
*
* The report these pin: a Cedar-drafted follow-up went out on
* mail.cedarcopilot.com/pipeline?threadOpen=1a0485f61e1e984f, the server closed its task inside
* the send request, and the empty chat's Tasks card kept counting it. That card renders straight
* off the `crm.getConversation` QUERY — so a completion applied only to the Zustand stores never
* reached it. These tests assert all four surfaces move together, and move back together when the
* send is undone.
*
* See apps/mail/modules/userTasks/lib/resolve-tasks-on-send.ts.
*/
import { getPendingTaskResolution } from '@/modules/userTasks/lib/pending-task-resolutions';
import {
resolveTasksForSend,
type SendResolutionHandle,
} from '@/modules/userTasks/lib/resolve-tasks-on-send';
import { setBrowserQueryClient } from '@/lib/browser-query-client';
import type { HydratedConversation } from '@/modules/crm/types';
import type { HydratedUserTask } from '@/modules/userTasks/slice/userTasksSlice';
import { QueryClient } from '@tanstack/react-query';
import { useCedarStore } from '@/modules/store';
import { act } from '@testing-library/react';
const CONVERSATION_ID = 'conv-1';
const THREAD_ID = '1a0485f61e1e984f';
const DRAFT_ID = 'r6491224570646483348';
const TASK_ID = '979d93bb-2ec4-4549-9c21-35ef5e0c073b';
/** Chris's row, as all four surfaces hold it: payload on the legacy axis, kind on the new one. */
function task(id = TASK_ID, overrides: Partial<HydratedUserTask> = {}): HydratedUserTask {
return {
id,
userId: 'u1',
conversationId: CONVERSATION_ID,
taskGroupId: null,
taskChannel: 'email',
taskType: 'post-meeting',
taskCreatedBy: 'agent',
taskOutput: { kind: 'email' },
taskActionData: { channel: 'email', draftId: DRAFT_ID, threadId: THREAD_ID },
agentExecutionEnabled: false,
executionRunId: null,
creationRunId: null,
notes: null,
chatThreadId: null,
description: 'Send post-meeting follow-up',
status: 'todo',
isRead: false,
createdAt: new Date('2026-08-28'),
updatedAt: new Date('2026-08-28'),
completedAt: null,
dueDate: new Date('2026-08-29'),
...overrides,
} as unknown as HydratedUserTask;
}
const CONVERSATION_QUERY_KEY = [
['crm', 'getConversation'],
{ input: { id: CONVERSATION_ID }, type: 'query' },
];
const TASK_LIST_QUERY_KEY = [
['userTasks', 'listUserTasks'],
{ input: { status: 'todo' }, type: 'query' },
];
let queryClient: QueryClient;
function seedAllSurfaces(row: HydratedUserTask = task()) {
act(() => {
useCedarStore.setState((s) => ({ ...s, tasks: { [row.id]: row } }));
useCedarStore.getState().setConversations({
[CONVERSATION_ID]: {
conversation: { id: CONVERSATION_ID, events: [] },
userTasks: [row],
} as unknown as HydratedConversation,
});
useCedarStore.getState().setTaskIdsByDateKey({ today: [row.id] } as never);
});
queryClient.setQueryData(CONVERSATION_QUERY_KEY, {
conversation: { id: CONVERSATION_ID },
userTasks: [row],
});
queryClient.setQueryData(TASK_LIST_QUERY_KEY, { tasks: [row] });
}
/** What each surface currently says the task's status is. */
function statuses() {
const state = useCedarStore.getState();
const cachedConversation = queryClient.getQueryData(CONVERSATION_QUERY_KEY) as {
userTasks: Array<{ id: string; status: string }>;
};
const cachedList = queryClient.getQueryData(TASK_LIST_QUERY_KEY) as {
tasks: Array<{ id: string; status: string }>;
};
return {
slice: state.tasks[TASK_ID]?.status,
conversationStore: state.conversations[CONVERSATION_ID]?.data.userTasks.find(
(t) => t.id === TASK_ID,
)?.status,
conversationQuery: cachedConversation.userTasks.find((t) => t.id === TASK_ID)?.status,
taskListQuery: cachedList.tasks.find((t) => t.id === TASK_ID)?.status,
};
}
beforeEach(() => {
queryClient = new QueryClient();
setBrowserQueryClient(queryClient);
useCedarStore.setState((s) => ({ ...s, tasks: {}, conversations: {} }));
});
afterEach(() => {
setBrowserQueryClient(null);
queryClient.clear();
useCedarStore.setState((s) => ({ ...s, tasks: {}, conversations: {} }));
});
describe('resolveTasksForSend', () => {
it('closes the task on all four surfaces at once', () => {
seedAllSurfaces();
expect(statuses()).toEqual({
slice: 'todo',
conversationStore: 'todo',
conversationQuery: 'todo',
taskListQuery: 'todo',
});
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ draftId: DRAFT_ID, threadId: THREAD_ID });
});
expect(handle.taskIds).toEqual([TASK_ID]);
expect(statuses()).toEqual({
slice: 'done',
conversationStore: 'done',
conversationQuery: 'done',
taskListQuery: 'done',
});
handle.rollback();
});
it('masks the task so a refetch landing before the server write cannot resurrect it', () => {
seedAllSurfaces();
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ threadId: THREAD_ID });
});
expect(getPendingTaskResolution(TASK_ID)).toBe('done');
// The `crm.getConversation` refetch the send's own invalidation kicks off — server still
// reports `todo`, because its write has not landed yet.
act(() => {
useCedarStore.getState().setConversations({
[CONVERSATION_ID]: {
conversation: { id: CONVERSATION_ID, events: [] },
userTasks: [task()],
} as unknown as HydratedConversation,
});
});
expect(statuses().conversationStore).toBe('done');
handle.rollback();
});
it('finds a task the slice never listed, from the conversation query alone', () => {
// The everyday case on /pipeline: a thread is open, /tasks was never visited.
queryClient.setQueryData(CONVERSATION_QUERY_KEY, {
conversation: { id: CONVERSATION_ID },
userTasks: [task()],
});
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ threadId: THREAD_ID });
});
expect(handle.taskIds).toEqual([TASK_ID]);
const cached = queryClient.getQueryData(CONVERSATION_QUERY_KEY) as {
userTasks: Array<{ id: string; status: string }>;
};
expect(cached.userTasks[0].status).toBe('done');
handle.rollback();
});
it('drops the closed task out of the date buckets and puts it back on rollback', () => {
seedAllSurfaces();
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ threadId: THREAD_ID });
});
expect(useCedarStore.getState().taskIdsByDateKey.today).toEqual([]);
act(() => handle.rollback());
expect(useCedarStore.getState().taskIdsByDateKey.today).toEqual([TASK_ID]);
});
it('rollback restores every surface and drops the mask — the send was undone', () => {
seedAllSurfaces();
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ threadId: THREAD_ID });
});
act(() => handle.rollback());
expect(getPendingTaskResolution(TASK_ID)).toBeUndefined();
expect(statuses()).toEqual({
slice: 'todo',
conversationStore: 'todo',
conversationQuery: 'todo',
taskListQuery: 'todo',
});
});
it('is a no-op when the send satisfies nothing — an ordinary compose', () => {
seedAllSurfaces();
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ threadId: 'some-other-thread' });
});
expect(handle.taskIds).toEqual([]);
expect(getPendingTaskResolution(TASK_ID)).toBeUndefined();
expect(statuses().slice).toBe('todo');
});
it('closes an id the caller named even when no store holds the row (a teammate’s task)', () => {
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ taskIds: ['foreign-task'] });
});
expect(handle.taskIds).toEqual(['foreign-task']);
expect(getPendingTaskResolution('foreign-task')).toBe('done');
handle.rollback();
expect(getPendingTaskResolution('foreign-task')).toBeUndefined();
});
it('decrements the CRM list’s open-task counter, and restores it on rollback', () => {
seedAllSurfaces();
const listKey = [['crm', 'listConversations'], { input: {}, type: 'query' }];
queryClient.setQueryData(listKey, {
conversations: [{ id: CONVERSATION_ID, openTaskCount: 3 }],
});
let handle!: SendResolutionHandle;
act(() => {
handle = resolveTasksForSend({ threadId: THREAD_ID });
});
expect(
(queryClient.getQueryData(listKey) as { conversations: Array<{ openTaskCount: number }> })
.conversations[0].openTaskCount,
).toBe(2);
act(() => handle.rollback());
expect(
(queryClient.getQueryData(listKey) as { conversations: Array<{ openTaskCount: number }> })
.conversations[0].openTaskCount,
).toBe(3);
});
});