delete-draft-flow.test.tsx16.7 KBView on GitHub
/**
 * Regression test for the "delete-draft-comes-back" bug.
 *
 * Repro path:
 *   1. A thread contains a draft (userEdited=true) — e.g. a reply draft or a
 *      synthetic one whose row id equals its draftSessionId.
 *   2. User clicks delete on the draft row in the thread list.
 *   3. `optimisticDeleteDraft` fires. Before the fix it only called
 *      `removeFromList` (which touches the thread list, not threadData), so
 *      the draft stayed inside `threadData[threadId].messages` and any
 *      subsequent `mail.get` merge re-adopted it because `userEdited: true`
 *      tells `mergeDraftsByUserEdited` to preserve unmatched local drafts.
 *
 * The fix layers three things onto the delete:
 *   - `removeDraftFromThread`  → physically drop the message from threadData
 *   - `addDraftTombstones`    → suppress any in-flight `mail.get` that still
 *                               echoes the draft (e.g. Gmail not yet synced)
 *   - mirror the removal into the React Query cache (`mail.get` queryKey)
 *
 * And passes the full identifier triplet `{ draftId, emailHeaderMessageId,
 * threadId }` to the server so the provider lookup can resolve the draft
 * even when the row id isn't itself a Gmail draftId.
 */

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

// ── Module mocks (must precede imports that pull in the mocked modules) ─────

jest.mock('@/providers/query-provider', () => {
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  const { makeTrpcShim } = require('../../lib/labelFlowHarness');
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  const { __getDraftDeleteSpy } = require('./delete-draft-flow.helpers');
  return {
    useTRPC: () => {
      const base = makeTrpcShim();
      return {
        ...base,
        drafts: {
          ...base.drafts,
          delete: {
            mutationOptions: () => ({
              mutationFn: async (
                input: { draftId?: string | null; emailHeaderMessageId?: string | null; threadId?: string },
              ) => {
                __getDraftDeleteSpy().calls.push(input);
                if (__getDraftDeleteSpy().mode === 'reject') {
                  throw __getDraftDeleteSpy().error ?? new Error('mock delete failure');
                }
                return true;
              },
            }),
          },
          list: {
            queryKey: () => ['drafts', 'list'] as readonly unknown[],
          },
        },
      };
    },
  };
});

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

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

import {
  HarnessProvider,
  beginHarness,
  endHarness,
  flushMicrotasks,
} from '../../lib/labelFlowHarness';
import { __getDraftDeleteSpy, __resetDraftDeleteSpy } from './delete-draft-flow.helpers';
import { useCedarStore } from '@/modules/store';
import { useOptimisticActions } from '@/modules/threads/rendering/use-optimistic-actions';
import type { ParsedMessage, ThreadData } from '@/modules/threads/threadList/store/threadSlice';
import type { QueryClient } from '@tanstack/react-query';

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

const THREAD_ID = 'thread-delete-draft';

const makeReply = (overrides: Partial<ParsedMessage> = {}): ParsedMessage => ({
  id: 'msg-real',
  threadId: THREAD_ID,
  isDraft: false,
  subject: 'Re: Meeting Follow-Up',
  sender: { email: '<email>', name: 'Nicolas Gutierrez' },
  to: [{ email: '<email>', name: 'Jesse' }],
  cc: null,
  bcc: null,
  tls: false,
  receivedOn: '2026-05-22T00:39:04.000Z',
  unread: false,
  processedHtml: '<p>Sounds good</p>',
  blobUrl: '',
  tags: [
    { id: 'INBOX', name: 'INBOX', type: 'system' },
  ],
  snippet: 'Sounds good',
  attachments: [],
  ...overrides,
});

const makeSyntheticDraft = (overrides: Partial<ParsedMessage> = {}): ParsedMessage => ({
  id: 'b8c9d44f-7ae5-4149-a9b5-e4f2a6889ab2',
  draftId: 'r-6871551176658842359',
  draftSessionId: 'b8c9d44f-7ae5-4149-a9b5-e4f2a6889ab2',
  emailHeaderMessageId: 'b8c9d44f-7ae5-4149-a9b5-e4f2a6889ab2',
  userEdited: true,
  threadId: THREAD_ID,
  isDraft: true,
  subject: 'Re: Meeting Follow-Up',
  sender: { email: '<email>', name: 'jesse' },
  to: [{ email: '<email>', name: 'nico' }],
  cc: null,
  bcc: null,
  tls: true,
  receivedOn: '2026-05-22T06:15:11.320Z',
  unread: false,
  processedHtml: '<div>Yo - was dope seeing you in person.</div>',
  blobUrl: '',
  tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
  snippet: '',
  attachments: [],
  ...overrides,
});

const seed = (messages: ParsedMessage[]): void => {
  useCedarStore.getState().setThreadData(THREAD_ID, {
    messages,
    latest: messages[messages.length - 1],
    hasUnread: false,
    totalReplies: messages.length,
    labels: [{ id: 'INBOX', name: 'INBOX' }],
  } as Omit<ThreadData, 'id' | 'lastLoadedAt'>);
};

const draftsInThread = (): ParsedMessage[] =>
  useCedarStore.getState().threadData[THREAD_ID]?.messages.filter((m) => m.isDraft) ?? [];

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

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

let queryClient: QueryClient;

beforeEach(() => {
  ({ queryClient } = beginHarness());
  // beginHarness resets threadData but not the tombstone map — without this
  // clear, a tombstone added by a prior test causes the next seed() to filter
  // the draft out (applyDraftTombstones runs inside setThreadData), and the
  // hook can no longer find the draft to derive identityCandidates / cache
  // updates from.
  useCedarStore.setState((s) => ({ ...s, pendingDraftDeletions: {} }));
  __resetDraftDeleteSpy();
});

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

// =============================================================================

describe('optimisticDeleteDraft', () => {
  it('removes the draft from threadData and tombstones it so a stale mail.get cannot re-add it', async () => {
    const real = makeReply();
    const draft = makeSyntheticDraft();
    seed([real, draft]);

    expect(draftsInThread()).toHaveLength(1);

    const { result } = renderActions();

    await act(async () => {
      await result.current.optimisticDeleteDraft(draft.id);
      await flushMicrotasks();
    });

    // Optimistic removal landed in threadData.
    expect(draftsInThread()).toHaveLength(0);

    // A tombstone for the draft's identities is in place.
    const tombstones =
      useCedarStore.getState().pendingDraftDeletions?.[THREAD_ID] ?? {};
    expect(tombstones[draft.draftId!]).toBeDefined();
    expect(tombstones[draft.draftSessionId!]).toBeDefined();
    expect(tombstones[draft.id]).toBeDefined();

    // Server received the full identifier triplet (not just the row id).
    const spy = __getDraftDeleteSpy();
    expect(spy.calls).toHaveLength(1);
    expect(spy.calls[0]).toEqual({
      draftId: draft.draftId,
      emailHeaderMessageId: draft.emailHeaderMessageId,
      threadId: THREAD_ID,
    });

    // Simulate the regression: a stale `mail.get` lands and still echoes the
    // draft (Gmail not yet synced, or canvas pendingDraft not yet cleared).
    act(() => {
      useCedarStore.getState().setThreadData(THREAD_ID, {
        messages: [real, draft],
        latest: real,
        hasUnread: false,
        totalReplies: 2,
        labels: [{ id: 'INBOX', name: 'INBOX' }],
      } as Omit<ThreadData, 'id' | 'lastLoadedAt'>);
    });

    // Tombstone suppressed it — the row stays gone.
    expect(draftsInThread()).toHaveLength(0);
  });

  it('mirrors the removal into the React Query mail.get cache so an in-flight refetch cannot snap the draft back', async () => {
    const real = makeReply();
    const draft = makeSyntheticDraft();
    seed([real, draft]);

    // Pre-populate the React Query cache as if `mail.get` had already returned.
    queryClient.setQueryData(['mail', 'get', { id: THREAD_ID }], {
      messages: [real, draft],
      latest: real,
      totalReplies: 2,
    });

    const { result } = renderActions();

    await act(async () => {
      await result.current.optimisticDeleteDraft(draft.id);
      await flushMicrotasks();
    });

    const cached = queryClient.getQueryData(['mail', 'get', { id: THREAD_ID }]) as {
      messages: ParsedMessage[];
      totalReplies: number;
    };
    expect(cached.messages.some((m) => m.isDraft)).toBe(false);
    expect(cached.totalReplies).toBe(1);
  });

  it('rolls back threadData and the cache when the server delete rejects', async () => {
    const real = makeReply();
    const draft = makeSyntheticDraft();
    seed([real, draft]);

    queryClient.setQueryData(['mail', 'get', { id: THREAD_ID }], {
      messages: [real, draft],
      latest: real,
      totalReplies: 2,
    });

    const spy = __getDraftDeleteSpy();
    spy.mode = 'reject';
    spy.error = new Error('Gmail 500');

    // Silence the expected `console.error('Error deleting draft:', …)` the hook
    // logs before rolling back.
    const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

    const { result } = renderActions();

    await act(async () => {
      await result.current.optimisticDeleteDraft(draft.id);
      await flushMicrotasks();
    });

    errorSpy.mockRestore();

    // threadData restored to its pre-delete shape.
    expect(draftsInThread()).toHaveLength(1);
    // Tombstone cleared so the restored draft isn't filtered out by a future merge.
    const tombstones =
      useCedarStore.getState().pendingDraftDeletions?.[THREAD_ID] ?? {};
    expect(tombstones[draft.draftId!]).toBeUndefined();

    // Cache restored too.
    const cached = queryClient.getQueryData(['mail', 'get', { id: THREAD_ID }]) as {
      messages: ParsedMessage[];
    };
    expect(cached.messages.some((m) => m.isDraft)).toBe(true);
  });

  it('REGRESSION: thread-list draft row passes the THREAD id; we still resolve the inner draft and call drafts.delete with the Gmail draftId', async () => {
    // This is the user's actual repro shape. The mail.get response includes a
    // single Gmail draft whose `id` and `draftId` are completely different from
    // the threadId. The Draft thread-list component calls
    // `optimisticDeleteDraft(message.id)` where `message.id` is the THREAD id —
    // before the fix that produced no `draftMessage`, identityCandidates was
    // empty, and the server delete was silently skipped (= the bug).
    const REAL_THREAD_ID = '19e41d0eef80bbed';
    const real = makeReply({ id: REAL_THREAD_ID, threadId: REAL_THREAD_ID });
    const gmailDraft = makeReply({
      id: '19e4d71f2ffffc91',
      threadId: REAL_THREAD_ID,
      isDraft: true,
      tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
      sender: { email: '<email>', name: 'Jesse Li' },
      to: [{ email: '<email>', name: '' }],
      emailHeaderMessageId: '<<email>>',
      processedHtml: '<div>Best,<br>Jesse</div>',
      snippet: 'Best, Jesse - 770 309 0537',
      receivedOn: '2026-05-22T02:09:33.000Z',
    });
    // Gmail draft id lives on its own property — not on `id`.
    (gmailDraft as ParsedMessage & { draftId?: string }).draftId = 'r6490562241473124820';

    useCedarStore.getState().setThreadData(REAL_THREAD_ID, {
      messages: [real, gmailDraft],
      latest: gmailDraft,
      hasUnread: false,
      totalReplies: 2,
      labels: [{ id: 'INBOX', name: 'INBOX' }],
    } as Omit<ThreadData, 'id' | 'lastLoadedAt'>);

    const { result } = renderActions();

    await act(async () => {
      // Caller passes the THREAD id (what Draft thread-list row passes today).
      await result.current.optimisticDeleteDraft(REAL_THREAD_ID);
      await flushMicrotasks();
    });

    const spy = __getDraftDeleteSpy();
    expect(spy.calls).toHaveLength(1);
    // Must have resolved the inner draft and sent its Gmail draftId.
    expect(spy.calls[0]).toEqual({
      draftId: 'r6490562241473124820',
      emailHeaderMessageId:
        '<<email>>',
      threadId: REAL_THREAD_ID,
    });

    // And the draft is gone from threadData.
    expect(
      useCedarStore.getState().threadData[REAL_THREAD_ID]!.messages.some((m) => m.isDraft),
    ).toBe(false);
  });

  it('REGRESSION: a draft row backed only by list-preview placeholders still calls drafts.delete', async () => {
    // The row the user actually clicks in the inbox is often one `mail.get` has
    // never loaded: `batchPopulateThreadMetadata` fills `messages` with
    // placeholders (`id: ''`, `isDraft: false`) built from the listThreads
    // preview. Neither lookup in optimisticDeleteDraft can find a draft in that,
    // so before the fix `parentThreadId` stayed undefined, the server guard was
    // all-falsy, and `drafts.delete` was never called — the row disappeared
    // locally and came straight back on the next listThreads refetch, opening to
    // a blank page with only the subject (the placeholders' empty bodies).
    const PREVIEW_THREAD_ID = '1a03966786acd842';
    const placeholder = makeReply({
      id: '',
      threadId: PREVIEW_THREAD_ID,
      isDraft: false,
      processedHtml: '',
      snippet: '',
    });
    useCedarStore.getState().setThreadData(PREVIEW_THREAD_ID, {
      messages: [placeholder],
      latest: placeholder,
      hasUnread: false,
      totalReplies: 1,
      hasDraft: true,
      labels: [{ id: 'INBOX', name: 'INBOX' }],
    } as Omit<ThreadData, 'id' | 'lastLoadedAt'>);

    const { result } = renderActions();

    await act(async () => {
      await result.current.optimisticDeleteDraft(PREVIEW_THREAD_ID);
      await flushMicrotasks();
    });

    const spy = __getDraftDeleteSpy();
    expect(spy.calls).toHaveLength(1);
    // No draft identifiers are resolvable client-side, so the server is handed the
    // thread id and resolves the draft from the provider / stored snapshot itself.
    expect(spy.calls[0]).toEqual({
      draftId: null,
      emailHeaderMessageId: null,
      threadId: PREVIEW_THREAD_ID,
    });

    // And the deletion is tombstoned, so a mail.get still echoing the draft can't
    // re-add it while Gmail catches up.
    expect(
      useCedarStore.getState().pendingDraftDeletions[PREVIEW_THREAD_ID],
    ).toBeDefined();
  });

  it('leaves a client-only compose session local — no provider draft to delete', async () => {
    // `draftSessionId-…` keys have no provider thread behind them; firing
    // drafts.delete for one would 400. The fallback must not widen to these.
    const { result } = renderActions();

    await act(async () => {
      await result.current.optimisticDeleteDraft('draftSessionId-abc123');
      await flushMicrotasks();
    });

    expect(__getDraftDeleteSpy().calls).toHaveLength(0);
  });

  it('handles a row id that is the draftSessionId (not a Gmail draftId) and still resolves identities', async () => {
    // Synthetic draft whose row id equals the draftSessionId — this is the
    // shape `Draft` thread-list rows pass to optimisticDeleteDraft when the
    // thread itself is just the draft session.
    const real = makeReply();
    const draft = makeSyntheticDraft({ draftId: undefined, emailHeaderMessageId: undefined });
    seed([real, draft]);

    const { result } = renderActions();

    await act(async () => {
      // Caller passes the draftSessionId / row id rather than a real Gmail draftId.
      await result.current.optimisticDeleteDraft(draft.draftSessionId!);
      await flushMicrotasks();
    });

    expect(draftsInThread()).toHaveLength(0);

    const spy = __getDraftDeleteSpy();
    expect(spy.calls).toHaveLength(1);
    // No real Gmail draftId — server still gets threadId so it can resolve the
    // latest draft via the provider lookup.
    expect(spy.calls[0]).toEqual({
      draftId: null,
      emailHeaderMessageId: null,
      threadId: THREAD_ID,
    });
  });
});