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);
  });
});