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