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