updatingTasksBridge.test.tsx5.4 KBView on GitHub
/**
 * The "updating…" affordance, bridged from the Next Steps card to the task surface.
 *
 * One agent pass rewrites a deal's `nextSteps` prose AND its task rows. Until now only the
 * conversation's Next Steps card knew that was happening: the flag lived on the hydrated
 * conversation record, and it had exactly one reader. Two consequences, both pinned here:
 *
 *   - The flag has to be settable for a deal the conversation store has never loaded. The task
 *     list shows tasks across the whole book; almost none of those deals are hydrated, so a
 *     flag stored on the hydrated record was a silent no-op on the surface that most needed it.
 *   - A task row reads that same flag and runs the same completion sweep on the same
 *     `true → false` edge, so the card and the rows land together rather than a beat apart on
 *     whichever query refetches first.
 *
 * See NEXT_STEPS_LIFECYCLE_DESIGN.md §2.6.
 */
import { act, render, screen } from '@testing-library/react';

// ── Mocks ─────────────────────────────────────────────────────────────────────

const mockRunCompletionShimmer = jest.fn();
jest.mock('@/lib/completion-shimmer', () => ({
  runCompletionShimmer: (...args: unknown[]) => mockRunCompletionShimmer(...args),
}));

jest.mock('@tanstack/react-query', () => ({
  useQuery: () => ({ data: undefined }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    crm: { getConversation: { queryOptions: () => ({ queryKey: ['getConversation'] }) } },
  }),
}));

// The row's /inbox-style thread preload is its own concern (see taskThreadPrefetchHooks.test.tsx);
// stubbed here so this suite doesn't have to stand up a query client to render a row.
jest.mock('@/modules/userTasks/hooks/use-prefetch-task-threads', () => ({
  usePrefetchTaskThreads: () => () => {},
  usePreloadTaskThreads: () => {},
}));

jest.mock('@/modules/userTasks/hooks/use-task-is-executing', () => ({
  useTaskIsExecuting: () => false,
}));

jest.mock('@/modules/conversationsPage/components/ConversationCompanyAvatar', () => ({
  ConversationCompanyAvatar: () => null,
}));

import { TaskListRow } from '@/modules/userTasks/components/TaskListRow';
import type { TaskCardTask } from '@/modules/userTasks/components/TaskKanbanCard';
import { useCedarStore } from '@/modules/store';

const CONVERSATION_ID = 'conv-1';

const TASK: TaskCardTask = {
  id: 'task-1',
  description: 'Send pricing recap',
  conversationId: CONVERSATION_ID,
  status: 'todo',
  dueDate: null,
  conversation: { name: 'Acme', companyName: 'Acme', logoUrl: null, lastContactedAt: null, nextStepDate: null, nextSteps: null },
};

const setUpdating = (updating: boolean) =>
  act(() => {
    useCedarStore.getState().setUpdatingTasks(CONVERSATION_ID, updating);
  });

const reset = () =>
  act(() => {
    useCedarStore.setState((s) => ({ ...s, conversations: {}, updatingTasksByConversation: {} }));
  });

beforeEach(() => {
  mockRunCompletionShimmer.mockClear();
  reset();
});
afterEach(reset);

describe('setUpdatingTasks', () => {
  it('sets the flag for a conversation the store has never hydrated', () => {
    setUpdating(true);
    expect(useCedarStore.getState().updatingTasksByConversation[CONVERSATION_ID]).toBe(true);
  });

  it('drops the entry rather than storing false, so the map is the in-flight set', () => {
    setUpdating(true);
    setUpdating(false);
    expect(CONVERSATION_ID in useCedarStore.getState().updatingTasksByConversation).toBe(false);
  });

  it('survives a conversation being (re)hydrated underneath it', () => {
    setUpdating(true);
    act(() => {
      useCedarStore.getState().setConversations({
        [CONVERSATION_ID]: {
          conversation: { id: CONVERSATION_ID, events: [] },
          userTasks: [],
        } as never,
      });
    });
    expect(useCedarStore.getState().updatingTasksByConversation[CONVERSATION_ID]).toBe(true);
  });
});

// Queried by LABEL, not by text: ShimmerText animates per character and so renders one
// <span> per letter — no text node ever reads "updating". Its wrapper carries
// aria-label={text}. A getByText here fails, and a queryByText passes vacuously.
describe('TaskListRow — the shared affordance', () => {
  it('shows the same "updating" cue the Next Steps card shows', () => {
    setUpdating(true);
    render(<TaskListRow task={TASK} />);
    expect(screen.getByLabelText('updating')).toBeInTheDocument();
  });

  it('shows nothing when no agent pass is running on the deal', () => {
    render(<TaskListRow task={TASK} />);
    expect(screen.queryByLabelText('updating')).not.toBeInTheDocument();
  });

  it('sweeps the row on the true → false edge, and only then', () => {
    setUpdating(true);
    render(<TaskListRow task={TASK} />);
    expect(mockRunCompletionShimmer).not.toHaveBeenCalled();

    setUpdating(false);
    expect(mockRunCompletionShimmer).toHaveBeenCalledTimes(1);
  });

  it('ignores a pass on a different deal', () => {
    render(<TaskListRow task={TASK} />);
    act(() => {
      useCedarStore.getState().setUpdatingTasks('conv-other', true);
    });
    expect(screen.queryByLabelText('updating')).not.toBeInTheDocument();
    act(() => {
      useCedarStore.getState().setUpdatingTasks('conv-other', false);
    });
    expect(mockRunCompletionShimmer).not.toHaveBeenCalled();
  });
});