label-change-flow.test.tsx20.4 KBView on GitHub
/**
 * Deep integration tests for the mail label-change flow.
 *
 * These exercise the FULL optimistic pipeline used by the UI:
 *
 *   useOptimisticActions
 *     → zustand mutation (markAsRead / toggleStar / toggleImportant / toggleLabel)
 *     → queryClient.cancelQueries(listThreads)
 *     → server mutateAsync()  ← GmailSimulator (deferred, controllable)
 *     → queryClient.invalidateQueries(mail.get)
 *     → on error: revert zustand
 *
 * The GmailSimulator keeps mutations *pending* by default so each test can
 * step through the race window:
 *
 *   1. fire the optimistic action
 *   2. inspect the in-flight state
 *   3. inject events (stale list refetch, second mutation, server failure)
 *   4. flush the simulator and inspect the settled state
 *
 * The bug under investigation: "mark as read instantly reverts." The suspect
 * is `batchPopulateThreadMetadata` (use-threads.ts:290) overwriting the
 * optimistic `hasUnread` when a stale `listThreads` response lands during the
 * mutation gap. Tests in §2 reproduce that scenario directly.
 */

import { act, renderHook } from '@testing-library/react';

// ── Module mocks ─────────────────────────────────────────────────────────────
// Must come before any imports that pull in the modules being mocked.

jest.mock('@/providers/query-provider', () => {
  // Lazy import — the harness file is allowed to import @/modules/store but
  // the factory itself must not capture module-scope locals from the test.
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  const { makeTrpcShim } = require('../../lib/labelFlowHarness');
  return { useTRPC: () => makeTrpcShim() };
});

jest.mock('@/hooks/use-predictive-prefetch', () => ({
  usePredictivePrefetch: () => ({ afterArchiveOrDelete: jest.fn() }),
}));

jest.mock('posthog-js', () => ({
  __esModule: true,
  default: { capture: jest.fn() },
  capture: jest.fn(),
}));

jest.mock('sonner', () => ({
  toast: {
    success: jest.fn(),
    error: jest.fn(),
    info: jest.fn(),
    warning: jest.fn(),
  },
}));

// performUndo and hasPendingSend are pulled in by useOptimisticActions for the
// undo flow — stub them out so we don't drag the drafting module into the test.
jest.mock('@/modules/drafting/hooks/use-undo-send', () => ({
  hasPendingSend: () => false,
  performUndo: jest.fn(),
}));

// moveThreadsTo lives in thread-actions and pulls in router/url machinery we
// don't need for the label flow.
jest.mock('@/modules/threads/thread/utils/thread-actions', () => ({
  moveThreadsTo: jest.fn(),
}));

// ── Imports (after mocks) ────────────────────────────────────────────────────

import {
  HarnessProvider,
  beginHarness,
  endHarness,
  flushMicrotasks,
  seedThread,
} from '../../lib/labelFlowHarness';
import { useCedarStore } from '@/modules/store';
import { useOptimisticActions } from '@/modules/threads/rendering/use-optimistic-actions';
import type { GmailSimulator, ServerThread } from '../../lib/gmailSimulator';
import type { QueryClient } from '@tanstack/react-query';

// ── Fixtures ────────────────────────────────────────────────────────────────

const RECEIVED_AT = '2026-05-21T10:00:00.000Z';

function unreadInboxThread(id: string, extra: string[] = []): ServerThread {
  return {
    id,
    labels: [
      { id: 'INBOX', name: 'INBOX', type: 'system' },
      { id: 'UNREAD', name: 'UNREAD', type: 'system' },
      ...extra.map((name) => ({ id: name, name, type: 'system' })),
    ],
    subject: `Subject ${id}`,
    sender: { email: '<email>', name: 'Alice' },
    snippet: `Snippet for ${id}`,
    receivedOn: RECEIVED_AT,
    messageCount: 1,
  };
}

function readInboxThread(id: string, extra: string[] = []): ServerThread {
  return {
    id,
    labels: [
      { id: 'INBOX', name: 'INBOX', type: 'system' },
      ...extra.map((name) => ({ id: name, name, type: 'system' })),
    ],
    subject: `Subject ${id}`,
    sender: { email: '<email>', name: 'Bob' },
    snippet: `Snippet for ${id}`,
    receivedOn: RECEIVED_AT,
    messageCount: 1,
  };
}

// ── Test helpers ────────────────────────────────────────────────────────────

const storeLabelsOf = (id: string) =>
  useCedarStore.getState().threadData[id]?.labels.map((l) => l.name) ?? [];

const storeHasUnread = (id: string) => useCedarStore.getState().threadData[id]?.hasUnread;

const storeLatestTags = (id: string) =>
  useCedarStore.getState().threadData[id]?.latest?.tags?.map((t) => t.name) ?? [];

function renderActions() {
  return renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
}

/**
 * Let the optimistic action's `await cancelQueries()` resolve so that the
 * subsequent `mutateAsync()` is dispatched to the simulator. Without this
 * drain, the async body is still suspended at the cancel-await when assertions
 * run, and `sim.pendingCount()` reports 0 — even though the mutation is
 * queued to fire imminently.
 *
 * Use after firing an optimistic action but BEFORE asserting on
 * `sim.pendingCount()` or flushing the simulator.
 */
async function settleInFlight(): Promise<void> {
  await act(async () => {
    await flushMicrotasks();
  });
}

// ── Lifecycle ───────────────────────────────────────────────────────────────

let sim: GmailSimulator;
let queryClient: QueryClient;

beforeEach(() => {
  ({ sim, queryClient } = beginHarness());
});

afterEach(() => {
  endHarness();
});

// =============================================================================
// §1 — Mark as read: happy paths and basic semantics
// =============================================================================

describe('optimisticMarkAsRead — basic semantics', () => {
  it('flips hasUnread and the latest tag immediately, before the server resolves', async () => {
    seedThread(unreadInboxThread('t1'));
    const { result } = renderActions();

    expect(storeHasUnread('t1')).toBe(true);
    expect(storeLatestTags('t1')).toContain('UNREAD');

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticMarkAsRead(['t1']);
    });

    // Optimistic state has landed; the mutation is still in flight on the server.
    expect(storeHasUnread('t1')).toBe(false);
    expect(storeLatestTags('t1')).not.toContain('UNREAD');
    await settleInFlight();
    expect(sim.pendingCount()).toBe(1);

    // Resolve the server side.
    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    expect(storeHasUnread('t1')).toBe(false);
    expect(sim.getLabelNames('t1')).not.toContain('UNREAD');
  });

  it('reverts hasUnread when the server rejects the mutation', async () => {
    seedThread(unreadInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticMarkAsRead(['t1']);
    });
    expect(storeHasUnread('t1')).toBe(false); // optimistic
    await settleInFlight();

    await act(async () => {
      sim.failNext(new Error('Gmail 500'));
      await pending;
      await flushMicrotasks();
    });

    expect(storeHasUnread('t1')).toBe(true);
    expect(storeLatestTags('t1')).toContain('UNREAD');
    // Server state was never mutated.
    expect(sim.getLabelNames('t1')).toContain('UNREAD');
  });

  it('marks multiple threads in a single bulk call', async () => {
    seedThread(unreadInboxThread('a'));
    seedThread(unreadInboxThread('b'));
    seedThread(unreadInboxThread('c'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticMarkAsRead(['a', 'b', 'c']);
    });

    expect(storeHasUnread('a')).toBe(false);
    expect(storeHasUnread('b')).toBe(false);
    expect(storeHasUnread('c')).toBe(false);
    await settleInFlight();
    expect(sim.pendingCount()).toBe(1);

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    for (const id of ['a', 'b', 'c']) {
      expect(sim.getLabelNames(id)).not.toContain('UNREAD');
    }
  });

  it('is a no-op for an empty thread-id list', async () => {
    const { result } = renderActions();
    await act(async () => {
      await result.current.optimisticMarkAsRead([]);
    });
    expect(sim.pendingCount()).toBe(0);
  });

  it('pushes an undo entry so the user can reverse the mark-as-read', async () => {
    seedThread(unreadInboxThread('t1'));
    const { result } = renderActions();

    act(() => {
      void result.current.optimisticMarkAsRead(['t1']);
    });

    const undoStack = useCedarStore.getState().undoStack;
    expect(undoStack.at(-1)?.action).toEqual({ type: 'markAsRead', threadIds: ['t1'] });

    await settleInFlight();
    await act(async () => {
      sim.flushNext();
      await flushMicrotasks();
    });
  });

  it('does NOT push an undo entry when called with silent=true', async () => {
    seedThread(unreadInboxThread('t1'));
    const { result } = renderActions();
    const before = useCedarStore.getState().undoStack.length;

    act(() => {
      void result.current.optimisticMarkAsRead(['t1'], /* silent */ true);
    });

    expect(useCedarStore.getState().undoStack.length).toBe(before);

    await settleInFlight();
    await act(async () => {
      sim.flushNext();
      await flushMicrotasks();
    });
  });
});

// =============================================================================
// §2 — cancelListThreadsQueries
// =============================================================================

describe('optimisticMarkAsRead — listThreads cancellation', () => {
  it('cancelListThreadsQueries actually cancels an in-flight listThreads fetch', async () => {
    seedThread(unreadInboxThread('t1'));
    const { result } = renderActions();

    // Kick off a slow listThreads fetch directly via the QueryClient so we can
    // observe whether cancelQueries reaches it.
    let resolveListFetch!: (value: unknown) => void;
    const slowFetch = new Promise((resolve) => {
      resolveListFetch = resolve;
    });

    const inFlight = queryClient.fetchInfiniteQuery({
      queryKey: ['mail', 'listThreads'],
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      queryFn: () => slowFetch as any,
      initialPageParam: '',
    });

    // Fire the optimistic mutation — it calls cancelQueries on this key.
    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticMarkAsRead(['t1']);
    });

    // Let the cancel propagate.
    await flushMicrotasks();

    // The slow fetch should now be cancelled — settle it and confirm.
    resolveListFetch(sim.listThreadsResponse());
    await expect(inFlight).rejects.toBeDefined();

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });
  });
});

// =============================================================================
// §3 — Mark as unread (inverse path)
// =============================================================================

describe('optimisticMarkAsUnread', () => {
  it('flips hasUnread to true immediately and confirms on server', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    expect(storeHasUnread('t1')).toBe(false);

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticMarkAsUnread(['t1']);
    });
    expect(storeHasUnread('t1')).toBe(true);
    expect(storeLatestTags('t1')).toContain('UNREAD');
    await settleInFlight();

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).toContain('UNREAD');
  });

  it('reverts when the server fails', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticMarkAsUnread(['t1']);
    });
    expect(storeHasUnread('t1')).toBe(true);
    await settleInFlight();

    await act(async () => {
      sim.failNext();
      await pending;
      await flushMicrotasks();
    });

    expect(storeHasUnread('t1')).toBe(false);
  });
});

// =============================================================================
// §4 — Star
// =============================================================================

describe('optimisticToggleStar', () => {
  it('adds STARRED optimistically and persists on flush', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleStar(['t1'], /* starred */ true);
    });
    expect(storeLabelsOf('t1')).toContain('STARRED');
    await settleInFlight();

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).toContain('STARRED');
  });

  it('removes STARRED optimistically and persists on flush', async () => {
    seedThread(readInboxThread('t1', ['STARRED']));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleStar(['t1'], /* starred */ false);
    });
    expect(storeLabelsOf('t1')).not.toContain('STARRED');
    await settleInFlight();

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).not.toContain('STARRED');
  });

  it('reverts on server failure', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleStar(['t1'], true);
    });
    expect(storeLabelsOf('t1')).toContain('STARRED');
    await settleInFlight();

    await act(async () => {
      sim.failNext();
      await pending;
      await flushMicrotasks();
    });

    expect(storeLabelsOf('t1')).not.toContain('STARRED');
  });

});

// =============================================================================
// §5 — Important
// =============================================================================

describe('optimisticToggleImportant', () => {
  it('adds IMPORTANT optimistically and persists', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleImportant(['t1'], true);
    });
    expect(storeLabelsOf('t1')).toContain('IMPORTANT');
    await settleInFlight();

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).toContain('IMPORTANT');
  });

  it('reverts on server failure', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleImportant(['t1'], true);
    });
    await settleInFlight();
    await act(async () => {
      sim.failNext();
      await pending;
      await flushMicrotasks();
    });

    expect(storeLabelsOf('t1')).not.toContain('IMPORTANT');
  });
});

// =============================================================================
// §6 — Custom user labels (modifyLabels)
// =============================================================================

describe('optimisticToggleLabel (custom user labels)', () => {
  const LABEL_ID = 'Label_42';
  const LABEL_NAME = 'Follow up';

  it('adds a custom label optimistically and persists', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleLabel(['t1'], LABEL_ID, LABEL_NAME, true);
    });

    expect(storeLabelsOf('t1')).toContain(LABEL_NAME);
    await settleInFlight();

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).toContain(LABEL_ID);
  });

  it('removes a custom label optimistically and persists', async () => {
    seedThread(readInboxThread('t1', [LABEL_ID]));
    // The user label "Follow up" is added with id=LABEL_ID, name=LABEL_NAME
    // — but `readInboxThread` constructs the label as { id: name, name: name }.
    // Reset to a labelled fixture by hand for this test.
    sim.forceServerLabels('t1', ['INBOX', LABEL_ID]);
    useCedarStore.getState().setThreadData('t1', {
      messages: [],
      latest: undefined,
      hasUnread: false,
      totalReplies: 1,
      labels: [
        { id: 'INBOX', name: 'INBOX' },
        { id: LABEL_ID, name: LABEL_NAME },
      ],
    });

    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleLabel(['t1'], LABEL_ID, LABEL_NAME, false);
    });
    expect(storeLabelsOf('t1')).not.toContain(LABEL_NAME);
    await settleInFlight();

    await act(async () => {
      sim.flushNext();
      await pending;
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).not.toContain(LABEL_ID);
  });

  it('reverts on server failure', async () => {
    seedThread(readInboxThread('t1'));
    const { result } = renderActions();

    let pending!: Promise<unknown>;
    act(() => {
      pending = result.current.optimisticToggleLabel(['t1'], LABEL_ID, LABEL_NAME, true);
    });
    expect(storeLabelsOf('t1')).toContain(LABEL_NAME);
    await settleInFlight();

    await act(async () => {
      sim.failNext();
      await pending;
      await flushMicrotasks();
    });

    expect(storeLabelsOf('t1')).not.toContain(LABEL_NAME);
  });
});

// =============================================================================
// §7 — Concurrent mutations
// =============================================================================

describe('concurrent optimistic mutations on the same thread', () => {
  it('mark-as-read then mark-as-unread in quick succession ends in the user-intended state', async () => {
    seedThread(unreadInboxThread('t1'));
    const { result } = renderActions();

    let firstPending!: Promise<unknown>;
    let secondPending!: Promise<unknown>;

    act(() => {
      firstPending = result.current.optimisticMarkAsRead(['t1']);
    });
    expect(storeHasUnread('t1')).toBe(false);
    await settleInFlight();

    act(() => {
      secondPending = result.current.optimisticMarkAsUnread(['t1']);
    });
    expect(storeHasUnread('t1')).toBe(true);
    await settleInFlight();

    expect(sim.pendingCount()).toBe(2);

    // Flush both in order; the server ends up unread (the user's last action).
    await act(async () => {
      sim.flushNext(); // markAsRead applies → server says read
      sim.flushNext(); // markAsUnread applies → server back to unread
      await Promise.all([firstPending, secondPending]);
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).toContain('UNREAD');
    expect(storeHasUnread('t1')).toBe(true);
  });

  it('star + mark-as-read on the same thread compose without clobbering each other', async () => {
    seedThread(unreadInboxThread('t1'));
    const { result } = renderActions();

    let p1!: Promise<unknown>;
    let p2!: Promise<unknown>;
    act(() => {
      p1 = result.current.optimisticToggleStar(['t1'], true);
    });
    await settleInFlight();
    act(() => {
      p2 = result.current.optimisticMarkAsRead(['t1']);
    });
    await settleInFlight();

    expect(storeLabelsOf('t1')).toContain('STARRED');
    expect(storeHasUnread('t1')).toBe(false);

    await act(async () => {
      sim.flushAll();
      await Promise.all([p1, p2]);
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).toContain('STARRED');
    expect(sim.getLabelNames('t1')).not.toContain('UNREAD');
    expect(storeLabelsOf('t1')).toContain('STARRED');
    expect(storeHasUnread('t1')).toBe(false);
  });
});