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