undoSendRestore.test.ts7.0 KBView on GitHub
/**
 * Undo-send restore — the draft comes back AND the surface it was sent from reopens.
 *
 * Covers restoreSendSurface (use-undo-send) over the real store: a compose session, a thread
 * reply, a send fired from an open conversation, and the case where closing the compose
 * already dropped the draft row.
 */

import { act } from '@testing-library/react';
import { DEFAULT_THREAD_ID } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { restoreSendSurface } from '@/modules/drafting/hooks/use-undo-send';
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';

jest.mock('sonner', () => ({
  toast: Object.assign(jest.fn(), { dismiss: jest.fn(), success: jest.fn(), error: jest.fn() }),
}));

jest.mock('@/providers/query-provider', () => ({
  setTeardownNextRequest: jest.fn(),
}));

const EMAIL = {
  to: ['<email>'],
  cc: ['<email>'],
  subject: 'Q4 pricing',
  message: '<p>Here are the numbers.</p>',
  attachments: [] as File[],
};

const draftRow = (overrides: Partial<ParsedMessage> = {}): ParsedMessage =>
  ({
    id: 'temp-msg-1',
    draftSessionId: 'session-1',
    isDraft: true,
    subject: '',
    tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
    sender: { email: '', name: '' },
    to: [],
    cc: null,
    bcc: null,
    tls: true,
    receivedOn: '2026-01-01T00:00:00Z',
    unread: false,
    processedHtml: '',
    blobUrl: '',
    attachments: [],
    ...overrides,
  }) as ParsedMessage;

const seedThread = (key=[redacted], messages: ParsedMessage[]) =>
  useCedarStore.setState((s) => ({
    ...s,
    threadData: {
      ...s.threadData,
      [key]: {
        id: key,
        messages,
        latest: messages[messages.length - 1],
        hasUnread: false,
        totalReplies: messages.length,
        labels: [],
        lastLoadedAt: Date.now(),
      },
    },
  }));

const reset = () =>
  useCedarStore.setState((s) => ({
    ...s,
    threadData: {},
    newEmail: false,
    selectedThreadId: null,
    activeConversationId: null,
    // The display pointer lives on the active chat thread, so the store needs one.
    threadMap: {
      [DEFAULT_THREAD_ID]: {
        id: DEFAULT_THREAD_ID,
        lastLoaded: new Date().toISOString(),
        messages: [],
        chatContext: { items: [] },
      },
    },
    mainThreadId: DEFAULT_THREAD_ID,
    activeThreadId: DEFAULT_THREAD_ID,
  }));

beforeEach(reset);
afterEach(reset);

const openArtifact = () => useCedarStore.getState().getDisplayArtifact();

describe('restoreSendSurface — compose session', () => {
  const sessionKey=[redacted];

  it('reopens the compose session as the displayed thread', () => {
    seedThread(sessionKey, [draftRow({ draftSessionId: sessionKey })]);

    act(() => restoreSendSurface({ draftSessionId: sessionKey }, EMAIL));

    expect(useCedarStore.getState().selectedThreadId).toBe(sessionKey);
    expect(openArtifact()).toEqual({ kind: 'email_thread', id: sessionKey });
    expect(useCedarStore.getState().newEmail).toBe(false);
  });

  it('writes the held-back content back onto the draft row', () => {
    seedThread(sessionKey, [draftRow({ draftSessionId: sessionKey })]);

    act(() => restoreSendSurface({ draftSessionId: sessionKey }, EMAIL));

    const row = useCedarStore.getState().threadData[sessionKey].messages[0];
    expect(row.processedHtml).toBe(EMAIL.message);
    expect(row.subject).toBe('Q4 pricing');
    expect(row.to).toEqual([{ email: '<email>', name: '' }]);
    expect(row.cc).toEqual([{ email: '<email>', name: '' }]);
  });

  it('rebuilds the session when closing the compose already dropped it', () => {
    // No threadData at all — closeNewEmail deletes the session entry on send.
    act(() =>
      restoreSendSurface({ draftSessionId: sessionKey, threadId: undefined, draftId: 'r123' }, EMAIL),
    );

    const thread = useCedarStore.getState().threadData[sessionKey];
    expect(thread.messages[0].isDraft).toBe(true);
    expect(thread.messages[0].processedHtml).toBe(EMAIL.message);
    // Keeps the provider draft identity so autosave targets the same draft, and stays a
    // temp-draft id so a later mail.get sync preserves it.
    expect(thread.messages[0].draftId).toBe('r123');
    expect(thread.messages[0].id.startsWith('temp-draft-')).toBe(true);
    expect(openArtifact()).toEqual({ kind: 'email_thread', id: sessionKey });
  });
});

describe('restoreSendSurface — thread reply', () => {
  it('reopens the provider thread that holds the draft', () => {
    seedThread('thread-9', [
      draftRow({ id: 'msg-1', isDraft: false, draftSessionId: undefined }),
      draftRow({ id: 'temp-draft-2', draftSessionId: 'stable-1' }),
    ]);

    act(() => restoreSendSurface({ draftSessionId: 'stable-1', threadId: 'thread-9' }, EMAIL));

    expect(useCedarStore.getState().selectedThreadId).toBe('thread-9');
    expect(openArtifact()).toEqual({ kind: 'email_thread', id: 'thread-9' });
  });

  it('patches the matching draft row and leaves the other messages alone', () => {
    seedThread('thread-9', [
      draftRow({ id: 'msg-1', isDraft: false, draftSessionId: undefined, subject: 'Original' }),
      draftRow({ id: 'temp-draft-2', draftSessionId: 'stable-1' }),
    ]);

    act(() => restoreSendSurface({ draftSessionId: 'stable-1', threadId: 'thread-9' }, EMAIL));

    const { messages } = useCedarStore.getState().threadData['thread-9'];
    expect(messages).toHaveLength(2);
    expect(messages[0].subject).toBe('Original');
    expect(messages[1].processedHtml).toBe(EMAIL.message);
  });
});

describe('restoreSendSurface — sent from an open conversation', () => {
  it('restores the conversation rather than the thread', () => {
    seedThread('thread-9', [draftRow({ id: 'temp-draft-2', draftSessionId: 'stable-1' })]);

    act(() =>
      restoreSendSurface(
        { draftSessionId: 'stable-1', threadId: 'thread-9', conversationId: 'conv-7' },
        EMAIL,
      ),
    );

    expect(openArtifact()).toEqual({ kind: 'conversation', id: 'conv-7' });
    expect(useCedarStore.getState().activeConversationId).toBe('conv-7');
    // The draft is still restored — it renders inside that conversation's timeline.
    expect(
      useCedarStore.getState().threadData['thread-9'].messages[0].processedHtml,
    ).toBe(EMAIL.message);
  });

  it('leaves an already-open conversation untouched', () => {
    seedThread('thread-9', [draftRow({ id: 'temp-draft-2', draftSessionId: 'stable-1' })]);
    act(() => useCedarStore.getState().openConversation({ conversationId: 'conv-7' }));
    act(() => useCedarStore.getState().setConversationSection('inbox'));

    act(() =>
      restoreSendSurface(
        { draftSessionId: 'stable-1', threadId: 'thread-9', conversationId: 'conv-7' },
        EMAIL,
      ),
    );

    // Reopening would have cleared the section the user was on.
    expect(useCedarStore.getState().conversationSection).toBe('inbox');
    expect(openArtifact()).toEqual({ kind: 'conversation', id: 'conv-7' });
  });
});