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