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