aopChangeConsistency.test.tsx6.2 KBView on GitHub
/**
 * Reassigning a conversation's AOP, end to end through the optimistic action.
 *
 * Two things made this the least consistent write in the app:
 *
 *   1. `aopId` is a `listConversations` FILTER (`aopIds`) and the conversation inbox's
 *      GROUPING key, yet it was the one such field that never invalidated the list. Every
 *      cached page kept the old AOP, and the four hooks that push those pages into
 *      `state.conversations` wrote it back over the change.
 *   2. Nothing outranked a payload fetched before the click. `setQueryData` patches one
 *      cache; a response already in flight overwrites it, and a `listConversations` page
 *      is never patched at all.
 *
 * See apps/mail/modules/crm/lib/pending-conversation-field-writes.ts.
 */
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, render, waitFor } from '@testing-library/react';
import type { HydratedConversation } from '@/modules/crm/types';

const mockConversationId = 'dfd4c541-76a7-4ebb-8311-18715b3eea34';
const OLD_AOP = '70bb43c4-c1ae-4266-9429-04711c0dee8d';
const NEW_AOP = '9f1c2b7e-0000-4000-8000-abcdefabcdef';

const mockListKey = [['crm', 'listConversations']] as const;
const mockConversationKey = [['crm', 'getConversation'], { input: { id: mockConversationId } }] as const;
const mockFieldDefsKey = [['crm', 'getConversationFieldDefinitions']] as const;

const mockUpdateConversation = jest.fn();

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    crm: {
      getConversation: { queryKey: () => mockConversationKey },
      getConversationFieldDefinitions: { queryKey: () => mockFieldDefsKey },
      listConversations: { queryKey: () => mockListKey },
      updateConversation: { mutationOptions: () => ({ mutationFn: mockUpdateConversation }) },
      deleteConversation: { mutationOptions: () => ({ mutationFn: jest.fn() }) },
      upsertWorkingMemory: { mutationOptions: () => ({ mutationFn: jest.fn() }) },
    },
  }),
}));

jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } }));
jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } }));
jest.mock('@/modules/conversations/utils/triggerExecuteFromClientSend', () => ({
  triggerExecuteFromClientSend: jest.fn().mockResolvedValue(undefined),
}));

// Imported after the mocks so the hook picks them up.
import { useOptimisticConversationActions } from '@/modules/crm/hooks/use-optimistic-conversation-actions';
import { resetPendingConversationFields } from '@/modules/crm/lib/pending-conversation-field-writes';
import { useCedarStore } from '@/modules/store';

function hydrated(aopId: string): HydratedConversation {
  return {
    conversation: { id: mockConversationId, name: 'pds GmbH', aopId, events: [] },
    userTasks: [],
  } as unknown as HydratedConversation;
}

/** Renders the hook and hands its actions back through a ref. */
function mountActions(queryClient: QueryClient) {
  const actions: { current: ReturnType<typeof useOptimisticConversationActions> | null } = {
    current: null,
  };
  function Probe() {
    actions.current = useOptimisticConversationActions();
    return null;
  }
  render(
    <QueryClientProvider client={queryClient}>
      <Probe />
    </QueryClientProvider>,
  );
  return actions;
}

const storedAopId = () =>
  useCedarStore.getState().conversations[mockConversationId]?.data.conversation.aopId;

describe('changing a conversation AOP', () => {
  let queryClient: QueryClient;

  beforeEach(() => {
    jest.clearAllMocks();
    resetPendingConversationFields();
    queryClient = new QueryClient({
      defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
    });
    useCedarStore.setState({ conversations: {} });
    act(() => {
      useCedarStore.getState().setConversations({ [mockConversationId]: hydrated(OLD_AOP) });
    });
  });

  it('re-runs the conversation list — aopId decides membership and grouping', async () => {
    mockUpdateConversation.mockResolvedValue({ aopRefresh: { runId: 'run-1' } });
    const invalidate = jest.spyOn(queryClient, 'invalidateQueries');
    const actions = mountActions(queryClient);

    await act(async () => {
      await actions.current!.optimisticUpdateConversation(mockConversationId, { aopId: NEW_AOP });
    });

    const invalidatedKeys = invalidate.mock.calls.map(([args]) => JSON.stringify(args?.queryKey));
    expect(invalidatedKeys).toContain(JSON.stringify(mockListKey));
    expect(invalidatedKeys).toContain(JSON.stringify(mockConversationKey));
    expect(invalidatedKeys).toContain(JSON.stringify(mockFieldDefsKey));
  });

  it('survives a list page that was already in flight when the user clicked', async () => {
    mockUpdateConversation.mockResolvedValue({ aopRefresh: { runId: 'run-1' } });
    const actions = mountActions(queryClient);

    await act(async () => {
      await actions.current!.optimisticUpdateConversation(mockConversationId, { aopId: NEW_AOP });
    });
    expect(storedAopId()).toBe(NEW_AOP);

    // useCRMConversations flushing a page fetched before the write committed.
    act(() => {
      useCedarStore.getState().setConversations({ [mockConversationId]: hydrated(OLD_AOP) });
    });

    expect(storedAopId()).toBe(NEW_AOP);
  });

  it('hands the background refresh run to the store so the indicator can show', async () => {
    mockUpdateConversation.mockResolvedValue({ aopRefresh: { runId: 'run-1' } });
    const actions = mountActions(queryClient);

    await act(async () => {
      await actions.current!.optimisticUpdateConversation(mockConversationId, { aopId: NEW_AOP });
    });

    await waitFor(() =>
      expect(useCedarStore.getState().conversations[mockConversationId].aopRefreshRunId).toBe('run-1'),
    );
  });

  it('lets server truth back in when the write failed — the mask is not a lie', async () => {
    mockUpdateConversation.mockRejectedValue(new Error('nope'));
    const actions = mountActions(queryClient);

    await act(async () => {
      await actions.current!.optimisticUpdateConversation(mockConversationId, { aopId: NEW_AOP });
    });

    // The refetch the error path triggers.
    act(() => {
      useCedarStore.getState().setConversations({ [mockConversationId]: hydrated(OLD_AOP) });
    });

    expect(storedAopId()).toBe(OLD_AOP);
  });
});