From 4e6043cf56140b14d0b6aeec44ed18695dfa1f2f Mon Sep 17 00:00:00 2001 From: dealerweb Date: Mon, 10 Aug 2026 13:40:07 +0200 Subject: [PATCH] test: cover the failed-save rescue dropping the close follow-up When the server refuses the draft save, the composer resets its explicit- close flag so the unmount stash hands the text to the continue-draft slot (#702). The close follow-up must be dropped in that case - running it would open the new session on top of the rescue. This was the one branch of the close guard without a test. --- .../__tests__/composer-close-guard.test.tsx | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 84bdc1f37..aaa115753 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-librar import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; // ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── @@ -139,6 +140,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ getRecipientSuggestions: { call: async () => [] }, onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, onComposeSend: { intercept: async () => true }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, @@ -197,7 +199,7 @@ function renderWithCloseRef(props: Record = {}) { const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>; const afterClose = vi.fn(); const onClose = vi.fn(); - render( + const view = render( = {}) { fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' }, }); - return { requestCloseRef, afterClose, onClose }; + return { requestCloseRef, afterClose, onClose, unmount: view.unmount }; } describe('composer close guard with a follow-up', () => { @@ -274,4 +276,39 @@ describe('composer close guard with a follow-up', () => { await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); }); + + it('drops the follow-up when the save fails and the rescue takes over', async () => { + // When the server refuses the draft, the composer text survives only via + // the unmount stash into the continue-draft slot (#702). That rescue + // outranks the request that triggered the close, so the follow-up must + // never fire - otherwise the new session would bury the stashed text. + const createDraft = vi.fn().mockRejectedValue(new Error('server refused')); + useAuthStore.setState({ + client: { createDraft, hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const onSaveState = vi.fn(); + const { requestCloseRef, afterClose, onClose, unmount } = renderWithCloseRef({ onSaveState }); + + act(() => requestCloseRef.current!(afterClose)); + const dialog = await screen.findByRole('alertdialog'); + fireEvent.click(within(dialog).getByText('save')); + + // The host still gets its close - the composer must not stay stuck - + // but the follow-up is dropped because the text was never persisted. + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(createDraft).toHaveBeenCalled(); + expect(afterClose).not.toHaveBeenCalled(); + + // The rescue itself: unmounting stashes the live text on the host. + unmount(); + expect(onSaveState).toHaveBeenCalledWith( + expect.objectContaining({ subject: 'Half-written more' }), + ); + } finally { + useAuthStore.setState({ client: null }); + consoleError.mockRestore(); + } + }); });