overviewEventProjection.test.ts6.3 KBView on GitHub /**
* The list/detail split of a conversation's events, in the conversations slice.
*
* `crm.listConversations` ships the OVERVIEW projection — five scalars per event, no bodies
* and no per-type sub-objects, because a conversation row only draws a dot timeline and three
* "last <type> at" columns. `crm.getConversation` ships the full timeline.
*
* Both write the same `state.conversations[id]` entry, and `setConversations` replaces it
* wholesale, so without a guard a background list refetch lands on top of whatever deal is
* open and empties its timeline. (Before the projections were split this was the same bug in
* a quieter form: the list shipped 15 events, so an open timeline silently SHRANK to 15 —
* apps/mail/docs/client-data-architecture.md §1.2 measured 40 → 15.)
*
* The second thing pinned here is the `threadToConversation` back-fill. It used to read
* `event.emailEvent.threadId`, which the overview projection does not have; the projection
* carries the thread flat instead, and the back-fill has to accept both or a list payload
* stops mapping threads to their conversation.
*/
import { useCedarStore } from '@/modules/store';
import type { HydratedConversation } from '@/modules/crm/types';
const CONVERSATION_ID = 'a3f1c5d2-1111-4000-8000-000000000001';
const THREAD_ID = 'thread-abc';
/** An event as `crm.getConversation` returns it: sub-objects, bodies, the lot. */
function detailEvent(id: string) {
return {
id,
conversationId: CONVERSATION_ID,
eventType: 'email',
title: 'Re: pricing',
direction: 'inbound',
occurredAt: '2026-08-01T10:00:00.000Z',
emailEvent: { threadId: THREAD_ID, subject: 'Re: pricing', snippet: 'a body' },
};
}
/** The same event as `crm.listConversations` returns it. */
function overviewEvent(id: string) {
return {
id,
conversationId: CONVERSATION_ID,
eventType: 'email',
title: 'Re: pricing',
direction: 'inbound',
occurredAt: '2026-08-01T10:00:00.000Z',
threadId: THREAD_ID,
};
}
function hydrated(events: unknown[]): HydratedConversation {
return {
conversation: { id: CONVERSATION_ID, name: 'Acme', events },
userTasks: [],
} as unknown as HydratedConversation;
}
const eventsInStore = () =>
useCedarStore.getState().conversations[CONVERSATION_ID]?.data.conversation.events ?? [];
beforeEach(() => {
useCedarStore.setState({ conversations: {}, threadToConversation: {} });
});
describe('conversations slice — list vs detail event projections', () => {
it('does not let a list payload replace a detail payload’s timeline', () => {
const full = [detailEvent('e1'), detailEvent('e2'), detailEvent('e3')];
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated(full) }, { projection: 'detail' });
// The list refetches behind the open conversation and returns its own, thinner view.
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([overviewEvent('e1')]) }, {
projection: 'list',
});
expect(eventsInStore()).toHaveLength(3);
// Not merely the right count — still the events that carry a body.
expect(eventsInStore()[0]).toHaveProperty('emailEvent.snippet', 'a body');
});
it('accepts a list payload for a conversation the detail has never loaded', () => {
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([overviewEvent('e1')]) }, {
projection: 'list',
});
expect(eventsInStore()).toHaveLength(1);
});
it('lets a detail payload replace overview events', () => {
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([overviewEvent('e1')]) }, {
projection: 'list',
});
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([detailEvent('e1'), detailEvent('e2')]) }, {
projection: 'detail',
});
expect(eventsInStore()).toHaveLength(2);
expect(eventsInStore()[0]).toHaveProperty('emailEvent.snippet', 'a body');
});
it('lets a later list payload refresh a row that only the list has ever loaded', () => {
// The row is born from a list page carrying one event...
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([overviewEvent('e1')]) }, {
projection: 'list',
});
// ...and the deal then gains a meeting, so the next list page carries two.
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([overviewEvent('e1'), overviewEvent('e2')]) }, {
projection: 'list',
});
// Both sides are the overview shape, so there is nothing richer to protect. Guarding on
// "are there events?" alone pinned the first page here and the row's dot timeline never
// grew again until something opened the conversation.
expect(eventsInStore()).toHaveLength(2);
expect(eventsInStore().map((e: { id: string }) => e.id)).toEqual(['e1', 'e2']);
});
it('keeps protecting a detail timeline across repeated list refetches', () => {
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([detailEvent('e1'), detailEvent('e2')]) }, {
projection: 'detail',
});
// Three list refetches in a row. The first must not consume the protection and leave the
// entry marked 'list', which would let the second one through.
for (let i = 0; i < 3; i += 1) {
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([overviewEvent('e1')]) }, {
projection: 'list',
});
}
expect(eventsInStore()).toHaveLength(2);
expect(eventsInStore()[0]).toHaveProperty('emailEvent.snippet', 'a body');
});
it('back-fills threadToConversation from an overview event’s flat threadId', () => {
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([overviewEvent('e1')]) }, {
projection: 'list',
});
expect(useCedarStore.getState().threadToConversation[THREAD_ID]).toBe(CONVERSATION_ID);
});
it('still back-fills threadToConversation from a detail event’s nested threadId', () => {
useCedarStore
.getState()
.setConversations({ [CONVERSATION_ID]: hydrated([detailEvent('e1')]) }, {
projection: 'detail',
});
expect(useCedarStore.getState().threadToConversation[THREAD_ID]).toBe(CONVERSATION_ID);
});
});