pendingConversationFieldWrites.test.ts7.8 KBView on GitHub /**
* Changing a conversation's AOP rendered optimistically and then popped back.
*
* The conversation header, the conversation inbox's AOP groups, the CRM table and the
* kanban all read `state.conversations[id]` — a Zustand mirror written by a dozen
* call sites fed by several different server queries, last-writer-wins. An optimistic edit could only patch one of them
* (`crm.getConversation`); every `crm.listConversations` page still held the pre-edit row,
* and pushed it back into the mirror on its next resolve, remount, or in-flight response.
*
* These tests pin the mask that survives such a write, plus the two things that made the
* AOP the worst case of the class: the list was never invalidated on an `aopId` change
* (unlike `status`/`priority`), and the sidebar list bypasses the mirror entirely.
*
* See apps/mail/modules/crm/lib/pending-conversation-field-writes.ts.
*/
import {
PENDING_CONVERSATION_WRITE_TTL_MS,
applyPendingConversationFields,
clearConversationFieldsPending,
markConversationFieldsPending,
resetPendingConversationFields,
} from '@/modules/crm/lib/pending-conversation-field-writes';
import type { HydratedConversation } from '@/modules/crm/types';
import { useCedarStore } from '@/modules/store';
import { act } from '@testing-library/react';
const CONVERSATION_ID = 'conv-1';
const OLD_AOP = 'aop-old';
const NEW_AOP = 'aop-new';
/** What a `listConversations` page / `getConversation` response hands back. */
function serverConversation(aopId: string, extra: Record<string, unknown> = {}) {
const hydrated = {
conversation: {
id: CONVERSATION_ID,
name: 'Acme',
aopId,
status: 'open',
events: [],
...extra,
},
userTasks: [],
};
return hydrated as unknown as HydratedConversation;
}
const reset = () => {
resetPendingConversationFields();
useCedarStore.setState((s) => ({ ...s, conversations: {} }));
};
beforeEach(reset);
afterEach(reset);
// ---------------------------------------------------------------------------
// The overlay itself
// ---------------------------------------------------------------------------
describe('applyPendingConversationFields', () => {
it('returns the input by identity when nothing is pending — change detection depends on it', () => {
const row = { id: CONVERSATION_ID, aopId: OLD_AOP };
expect(applyPendingConversationFields(CONVERSATION_ID, row)).toBe(row);
});
it('returns the input by identity when the server already agrees', () => {
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP });
const row = { id: CONVERSATION_ID, aopId: NEW_AOP };
expect(applyPendingConversationFields(CONVERSATION_ID, row)).toBe(row);
});
it('holds the edited value over a payload that still carries the old one', () => {
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP });
expect(
applyPendingConversationFields(CONVERSATION_ID, { id: CONVERSATION_ID, aopId: OLD_AOP }),
).toEqual({ id: CONVERSATION_ID, aopId: NEW_AOP });
});
it('masks only the edited fields, and only the edited conversation', () => {
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP });
expect(
applyPendingConversationFields(CONVERSATION_ID, {
id: CONVERSATION_ID,
aopId: OLD_AOP,
status: 'closed_won',
}),
).toEqual({ id: CONVERSATION_ID, aopId: NEW_AOP, status: 'closed_won' });
const other = { id: 'conv-2', aopId: OLD_AOP };
expect(applyPendingConversationFields('conv-2', other)).toBe(other);
});
it('compares dates by instant, not identity — the wire carries a different object', () => {
const chosen = new Date('2026-03-04T00:00:00.000Z');
markConversationFieldsPending(CONVERSATION_ID, { nextStepDate: chosen });
const row = { id: CONVERSATION_ID, nextStepDate: new Date('2026-03-04T00:00:00.000Z') };
expect(applyPendingConversationFields(CONVERSATION_ID, row)).toBe(row);
});
it('holds a null — clearing a field is an edit like any other', () => {
markConversationFieldsPending(CONVERSATION_ID, { aopId: null });
expect(
applyPendingConversationFields(CONVERSATION_ID, { id: CONVERSATION_ID, aopId: OLD_AOP }),
).toEqual({ id: CONVERSATION_ID, aopId: null });
});
it('expires, so a write the server silently dropped becomes visible rather than pinned', () => {
const t0 = 1_000_000;
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP }, t0);
const row = { id: CONVERSATION_ID, aopId: OLD_AOP };
expect(applyPendingConversationFields(CONVERSATION_ID, row, t0 + 1_000)).toEqual({
id: CONVERSATION_ID,
aopId: NEW_AOP,
});
expect(
applyPendingConversationFields(
CONVERSATION_ID,
row,
t0 + PENDING_CONVERSATION_WRITE_TTL_MS + 1,
),
).toBe(row);
});
it('stops masking as soon as the edit is released (a failed write)', () => {
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP, status: 'closed_won' });
clearConversationFieldsPending(CONVERSATION_ID, ['aopId', 'status']);
const row = { id: CONVERSATION_ID, aopId: OLD_AOP, status: 'open' };
expect(applyPendingConversationFields(CONVERSATION_ID, row)).toBe(row);
});
});
// ---------------------------------------------------------------------------
// setConversations — the mirror every conversation surface reads
// ---------------------------------------------------------------------------
describe('setConversations after an AOP change', () => {
const storedAopId = () =>
useCedarStore.getState().conversations[CONVERSATION_ID]?.data.conversation.aopId;
it('does not let a listConversations page fetched before the change revert it', () => {
// The optimistic write.
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP });
act(() =>
useCedarStore.getState().setConversations({ [CONVERSATION_ID]: serverConversation(NEW_AOP) }),
);
expect(storedAopId()).toBe(NEW_AOP);
// useCRMConversations pushing a page that was already in flight when the user clicked.
act(() =>
useCedarStore.getState().setConversations({ [CONVERSATION_ID]: serverConversation(OLD_AOP) }),
);
expect(storedAopId()).toBe(NEW_AOP);
});
it('still takes everything else from that page — only the edited field is held', () => {
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP });
act(() =>
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: serverConversation(OLD_AOP, { name: 'Acme Inc' }) }),
);
const stored = useCedarStore.getState().conversations[CONVERSATION_ID].data.conversation;
expect(stored.aopId).toBe(NEW_AOP);
expect(stored.name).toBe('Acme Inc');
});
it('accepts server truth again once the write is released', () => {
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP });
act(() =>
useCedarStore.getState().setConversations({ [CONVERSATION_ID]: serverConversation(NEW_AOP) }),
);
clearConversationFieldsPending(CONVERSATION_ID, ['aopId']);
act(() =>
useCedarStore.getState().setConversations({ [CONVERSATION_ID]: serverConversation(OLD_AOP) }),
);
expect(storedAopId()).toBe(OLD_AOP);
});
it('keeps the AOP-refresh run id across a masked ingest, so the indicator survives', () => {
act(() =>
useCedarStore.getState().setConversations({ [CONVERSATION_ID]: serverConversation(OLD_AOP) }),
);
act(() => useCedarStore.getState().setAopRefreshRun(CONVERSATION_ID, 'run-1'));
markConversationFieldsPending(CONVERSATION_ID, { aopId: NEW_AOP });
act(() =>
useCedarStore.getState().setConversations({ [CONVERSATION_ID]: serverConversation(OLD_AOP) }),
);
expect(useCedarStore.getState().conversations[CONVERSATION_ID].aopRefreshRunId).toBe('run-1');
expect(storedAopId()).toBe(NEW_AOP);
});
});