mark-done-flow.test.tsx11.3 KBView on GitHub
/**
 * Mark-as-done: the archived threads must not come back.
 *
 * Reproduces the production bug traced in
 * apps/server/src/docs/bug-mark-done-threads-reappear.md.
 *
 * The mirror and `mail.listThreads` are provably correct — the archive leaves
 * `crm_thread_labels` ~230-380 ms after the click and never returns. The window
 * that breaks is the one INSIDE that round-trip: a `listThreads` fetch issued
 * after `cancelQueries()` has already run but before the server committed the
 * archive gets a page that legitimately still contains the thread. When it
 * lands, React Query replaces the cache with it — and `optimisticMarkDone`
 * never re-asserts the removal, so the thread is back in the inbox until the
 * next page-1 refresh (the 5-minute interval in use-threads.ts).
 *
 * `optimisticMoveThreadsTo` and `optimisticDeleteThreads` already close this
 * window by calling `removeThreadsFromQueryCache` a second time after awaiting
 * the mutation; §2 below pins that behaviour so the three actions can't drift
 * apart again.
 */

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

// ── Module mocks (must precede the imports they affect) ─────────────────────

jest.mock('@/providers/query-provider', () => {
  // 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() },
}));

jest.mock('@/modules/drafting/hooks/use-undo-send', () => ({
  hasPendingSend: () => false,
  performUndo: jest.fn(),
}));

jest.mock('@/modules/threads/thread/utils/thread-actions', () => ({
  moveThreadsTo: jest.fn(async () => ({ ok: true })),
}));

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

import {
  HarnessProvider,
  beginHarness,
  endHarness,
  flushMicrotasks,
  seedThread,
} from '../../lib/labelFlowHarness';
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 LIST_KEY = ['mail', 'listThreads', { inboxName: 'Inbox' }] as const;

function inboxThread(id: string, receivedOn: string): ServerThread {
  return {
    id,
    labels: [
      { id: 'INBOX', name: 'INBOX', type: 'system' },
      { id: 'UNREAD', name: 'UNREAD', type: 'system' },
    ],
    subject: `subject ${id}`,
    sender: { name: `sender ${id}`, email: `${id}@example.com` },
    snippet: `snippet ${id}`,
    receivedOn,
    messageCount: 1,
  };
}

type InfiniteListData = {
  pages: Array<{ threads: Array<{ id: string }>; nextPageToken=[redacted] | null }>;
  pageParams: unknown[];
};

/** The ids the inbox list would render right now, flattened across pages. */
function listedIds(queryClient: QueryClient): string[] {
  const data = queryClient.getQueryData<InfiniteListData>(LIST_KEY);
  return (data?.pages ?? []).flatMap((page) => page.threads.map((t) => t.id));
}

/** Seed the cache the way a completed page-1 load would have. */
function seedListCache(queryClient: QueryClient, sim: GmailSimulator): void {
  queryClient.setQueryData<InfiniteListData>(LIST_KEY, {
    pages: [{ threads: sim.listThreadsResponse().threads, nextPageToken=[redacted] }],
    pageParams: [''],
  });
}

/**
 * A `listThreads` response landing in the cache. Its contents come from the
 * simulator's CURRENT server state — so calling this while a mark-done is still
 * pending reproduces exactly the response a request issued mid-round-trip would
 * have received: the archive has not committed, so the threads are still there.
 */
function landListThreadsResponse(queryClient: QueryClient, sim: GmailSimulator): void {
  queryClient.setQueryData<InfiniteListData>(LIST_KEY, {
    pages: [{ threads: sim.listThreadsResponse().threads, nextPageToken=[redacted] }],
    pageParams: [''],
  });
}

// ── Suite ───────────────────────────────────────────────────────────────────

describe('mark as done — archived threads must not reappear', () => {
  let sim: GmailSimulator;
  let queryClient: QueryClient;

  beforeEach(() => {
    ({ sim, queryClient } = beginHarness());
    seedThread(inboxThread('t1', '2026-08-16T10:00:00.000Z'));
    seedThread(inboxThread('t2', '2026-08-16T09:00:00.000Z'));
    seedThread(inboxThread('t3', '2026-08-16T08:00:00.000Z'));
    seedListCache(queryClient, sim);
  });

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

  // §1 — the regression itself
  it('does not resurrect threads when a pre-archive listThreads response lands mid-mutation', async () => {
    const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });

    expect(listedIds(queryClient)).toEqual(['t1', 't2', 't3']);

    // Fire and DON'T await — the simulator holds both markDone calls pending,
    // which is the ~230-380 ms the real server spends in threads.modify.
    let settled: Promise<void>;
    await act(async () => {
      settled = result.current.optimisticMarkDone(['t1', 't2']);
      await flushMicrotasks();
    });

    // Optimistic removal landed; the server has not committed yet.
    expect(listedIds(queryClient)).toEqual(['t3']);
    expect(sim.pendingCount()).toBe(2);
    expect(sim.getLabelNames('t1')).toContain('INBOX');

    // A listThreads fetch issued inside the round-trip resolves. It is a
    // perfectly valid server response — it just predates the archive. Round 1
    // let it land and repaired the flicker afterwards; the guard now refuses
    // the write outright, so there is no window in which the user sees them.
    await act(async () => {
      landListThreadsResponse(queryClient, sim);
      await flushMicrotasks();
    });
    expect(listedIds(queryClient)).toEqual(['t3']);

    // Server commits the archive and the mutation settles.
    await act(async () => {
      sim.flushAll();
      await settled;
      await flushMicrotasks();
    });

    expect(sim.getLabelNames('t1')).not.toContain('INBOX');
    expect(sim.getLabelNames('t2')).not.toContain('INBOX');

    // The action has finished and the server agrees the threads are archived,
    // so the rendered list must not still be showing them.
    expect(listedIds(queryClient)).toEqual(['t3']);
  });

  // §1b — the Round 2 regression: prod's three recorded resurrections all landed
  // 15.7-18.2 s after the click, i.e. AFTER the mutation settled and after the
  // one-shot repair had already run. Nothing opposes that write today.
  // See bug-mark-done-threads-reappear.md Round 2.
  it('does not resurrect when a pre-archive listThreads response lands AFTER the mutation settles', async () => {
    const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });

    expect(listedIds(queryClient)).toEqual(['t1', 't2', 't3']);

    // A slow archive: staging measured mail.markDone at up to 7381 ms. A page-1
    // refetch issued inside that window is answered from pre-archive state.
    let settled: Promise<void>;
    await act(async () => {
      settled = result.current.optimisticMarkDone(['t1', 't2']);
      await flushMicrotasks();
    });
    const staleResponse = sim.listThreadsResponse().threads;
    expect(staleResponse.map((t) => t.id)).toEqual(['t1', 't2', 't3']);

    // The mutation settles and the repair pass runs.
    await act(async () => {
      sim.flushAll();
      await settled;
      await flushMicrotasks();
    });
    expect(sim.getLabelNames('t1')).not.toContain('INBOX');
    expect(listedIds(queryClient)).toEqual(['t3']);

    // ...and only THEN does the in-flight response resolve. The repair has
    // already fired; there is nothing left to undo this write.
    await act(async () => {
      queryClient.setQueryData(LIST_KEY, {
        pages: [{ threads: staleResponse, nextPageToken=[redacted] }],
        pageParams: [''],
      });
      await flushMicrotasks();
    });

    expect(listedIds(queryClient)).toEqual(['t3']);
  });

  it('keeps a late stale response from resurrecting a bulk archive', async () => {
    const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });

    let settled: Promise<void>;
    await act(async () => {
      settled = result.current.optimisticMarkDone(['t1', 't2', 't3']);
      await flushMicrotasks();
    });
    const staleResponse = sim.listThreadsResponse().threads;

    await act(async () => {
      sim.flushAll();
      await settled;
      await flushMicrotasks();
    });
    expect(listedIds(queryClient)).toEqual([]);

    await act(async () => {
      queryClient.setQueryData(LIST_KEY, {
        pages: [{ threads: staleResponse, nextPageToken=[redacted] }],
        pageParams: [''],
      });
      await flushMicrotasks();
    });

    expect(listedIds(queryClient)).toEqual([]);
  });

  it('keeps them out when no stale response ever lands (baseline)', async () => {
    const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });

    await act(async () => {
      const settled = result.current.optimisticMarkDone(['t1', 't2']);
      await flushMicrotasks();
      sim.flushAll();
      await settled;
      await flushMicrotasks();
    });

    expect(listedIds(queryClient)).toEqual(['t3']);
  });

  it('restores the threads when the server rejects the archive', async () => {
    const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });

    await act(async () => {
      const settled = result.current.optimisticMarkDone(['t1']);
      await flushMicrotasks();
      sim.failNext(new Error('Gmail API error'));
      sim.flushAll();
      await settled;
      await flushMicrotasks();
    });

    // Rollback puts it back — the repair pass must not defeat the error path.
    expect(listedIds(queryClient)).toContain('t1');
    expect(sim.getLabelNames('t1')).toContain('INBOX');
  });

  // §2 — the two sibling actions already close this window; pin it.
  it('optimisticDeleteThreads also survives a stale response landing mid-mutation', async () => {
    const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });

    let settled: Promise<void>;
    await act(async () => {
      settled = result.current.optimisticDeleteThreads(['t1']);
      await flushMicrotasks();
    });
    expect(sim.pendingCount()).toBe(1);

    await act(async () => {
      landListThreadsResponse(queryClient, sim);
      await flushMicrotasks();
    });
    expect(listedIds(queryClient)).toContain('t1');

    await act(async () => {
      sim.flushAll();
      await settled;
      await flushMicrotasks();
    });

    expect(listedIds(queryClient)).not.toContain('t1');
  });
});