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