thread-telemetry.test.ts7.5 KBView on GitHub
/**
 * Scoped store telemetry: thread list, thread get, send — and nothing else.
 *
 * The scope is the point. CedarStore is one store holding ~30 slices, message bodies included,
 * so a blanket subscription would be both a privacy leak and a volume problem. These tests pin
 * the two halves of that contract: the three thread operations DO produce an event, an unrelated
 * write produces none, and what does get emitted is counts and ids — never a body, a subject or
 * a recipient, even when the action that fired carried all three.
 */

import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/store';
import {
  peekClientTelemetryBufferForTests,
  resetClientTelemetryForTests,
} from '@/lib/client-telemetry';
import {
  recordThreadStoreTransition,
  resetThreadTelemetryThrottleForTests,
} from '@/modules/store/threadTelemetry';
import type { ParsedMessage, ThreadData } from '@/modules/threads/threadList/store/threadSlice';

const THREAD_ID = 'thread-telemetry-1';
const DRAFT_SESSION_ID = 'draft-session-1';

const SECRET_BODY = '<p>Confidential: our floor is 4.2M ARR</p>';
const SECRET_SUBJECT = 'Re: pricing for Acme';
const SECRET_RECIPIENT = '<email>';

const makeDraft = (): ParsedMessage => ({
  id: 'msg-draft-1',
  threadId: THREAD_ID,
  isDraft: true,
  draftSessionId: DRAFT_SESSION_ID,
  subject: SECRET_SUBJECT,
  sender: { email: '<email>', name: 'Jesse' },
  to: [{ email: SECRET_RECIPIENT, name: 'Buyer' }],
  cc: null,
  bcc: null,
  tls: true,
  receivedOn: '2026-01-01T00:00:00.000Z',
  unread: false,
  processedHtml: SECRET_BODY,
  blobUrl: '',
  tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
  snippet: '',
  attachments: [],
});

const seedThread = () => {
  const draft = makeDraft();
  const thread: ThreadData = {
    id: THREAD_ID,
    messages: [draft],
    latest: draft,
    hasUnread: false,
    totalReplies: 1,
    labels: [],
    lastLoadedAt: Date.now(),
  };
  act(() => {
    // setState directly, NOT through a named thread action — this seeding must not itself
    // be what the assertions below are reading.
    useCedarStore.setState((state) => ({
      ...state,
      threadData: { ...state.threadData, [THREAD_ID]: thread },
    }));
  });
};

const events = () => peekClientTelemetryBufferForTests();

describe('scoped thread store telemetry', () => {
  beforeEach(() => {
    resetThreadTelemetryThrottleForTests();
    resetClientTelemetryForTests();
    // No transport: keep everything in the buffer where the test can read it.
    Object.defineProperty(globalThis, 'fetch', {
      value: undefined,
      configurable: true,
      writable: true,
    });
  });

  afterEach(() => {
    resetClientTelemetryForTests();
    resetThreadTelemetryThrottleForTests();
  });

  it('emits threads.list when the thread list is replaced', () => {
    act(() => {
      useCedarStore.getState().setCurrentThreadList([
        { id: 'a', historyId: null },
        { id: 'b', historyId: null },
      ]);
    });

    const [event] = events();
    expect(event).toMatchObject({ kind: 'store', name: 'threads.list', status: 'applied' });
    expect(event.meta).toMatchObject({ listSize: 2, action: 'thread/setCurrentThreadList' });
  });

  it('emits thread.get when a thread is selected', () => {
    seedThread();

    act(() => {
      useCedarStore.getState().selectThreadId(THREAD_ID);
    });

    const emitted = events().filter((e) => e.name === 'thread.get');
    expect(emitted.length).toBeGreaterThan(0);
    expect(emitted[0].meta).toMatchObject({ threadId: THREAD_ID, messageCount: 1 });
  });

  it('emits thread.send on an optimistic send, and carries no body, subject or recipient', () => {
    seedThread();
    resetClientTelemetryForTests();

    act(() => {
      useCedarStore.getState().optimisticSendDraft({
        threadId: THREAD_ID,
        draftSessionId: DRAFT_SESSION_ID,
        sentMessageData: {
          subject: SECRET_SUBJECT,
          message: SECRET_BODY,
          sender: { email: '<email>', name: 'Jesse' },
          to: [{ email: SECRET_RECIPIENT, name: 'Buyer' }],
        },
      });
    });

    const sendEvents = events().filter((e) => e.name === 'thread.send');
    expect(sendEvents).toHaveLength(1);
    expect(sendEvents[0]).toMatchObject({
      kind: 'store',
      status: 'applied',
      meta: { action: 'thread/optimisticSendDraft' },
    });

    // Everything the store just moved around was content. None of it may be on the wire.
    const wire = JSON.stringify(events());
    expect(wire).not.toContain('Confidential');
    expect(wire).not.toContain(SECRET_RECIPIENT);
    expect(wire).not.toContain('pricing');
    expect(wire).not.toContain('4.2M');
  });

  it('emits NOTHING for unrelated store writes', () => {
    act(() => {
      const store = useCedarStore.getState();
      store.setShowImages(true);
      store.toggleBulkSelection('thread-x');
      store.setUnreadOnly('inbox', true);
      store.openThreadRemindDialog(['thread-x']);
    });

    expect(events()).toHaveLength(0);
  });

  it('throttles list churn to one event per action per second', () => {
    act(() => {
      const setList = useCedarStore.getState().setCurrentThreadList;
      setList([{ id: 'a', historyId: null }]);
      setList([{ id: 'a', historyId: null }, { id: 'b', historyId: null }]);
      setList([
        { id: 'a', historyId: null },
        { id: 'b', historyId: null },
        { id: 'c', historyId: null },
      ]);
    });

    expect(events().filter((e) => e.name === 'threads.list')).toHaveLength(1);
  });
});

describe('recordThreadStoreTransition', () => {
  const STATE = { currentThreadList: [{ id: 'a' }], activeListSource: 'threads' };

  beforeEach(() => {
    resetThreadTelemetryThrottleForTests();
    resetClientTelemetryForTests();
    Object.defineProperty(globalThis, 'fetch', {
      value: undefined,
      configurable: true,
      writable: true,
    });
  });

  afterEach(() => {
    jest.restoreAllMocks();
    resetClientTelemetryForTests();
    resetThreadTelemetryThrottleForTests();
  });

  it('reports how many transitions the throttle stood in for', () => {
    const clock = jest.spyOn(Date, 'now');

    clock.mockReturnValue(1_000_000);
    recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
    // Three more inside the same second: dropped, but counted.
    recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
    recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
    recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);

    clock.mockReturnValue(1_002_000);
    recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);

    const emitted = events();
    expect(emitted).toHaveLength(2);
    expect(emitted[0].meta?.droppedSinceLast).toBeUndefined();
    expect(emitted[1].meta?.droppedSinceLast).toBe(3);
  });

  it('never throttles a send, however fast they arrive', () => {
    jest.spyOn(Date, 'now').mockReturnValue(1_000_000);

    recordThreadStoreTransition('thread/optimisticSendDraft', () => STATE);
    recordThreadStoreTransition('thread/optimisticSendDraft', () => STATE);
    recordThreadStoreTransition('thread/popUndoSend', () => STATE);

    expect(events().filter((e) => e.name === 'thread.send')).toHaveLength(3);
  });

  it('ignores an action name it does not know, and a non-string one', () => {
    recordThreadStoreTransition('crm/setColumns', () => STATE);
    recordThreadStoreTransition(undefined, () => STATE);
    recordThreadStoreTransition(42, () => STATE);

    expect(events()).toHaveLength(0);
  });
});