undoSendUnloadGuard.test.ts4.8 KBView on GitHub
/**
 * A held-back send is unsaved work — leaving the page must be guarded for as long as one exists.
 *
 * The guard used to live in the composer's own `useEffect`, but `proceedWithSend` closes the
 * composer the instant the send is deferred, so the cleanup tore the listeners out while the 5s
 * timer was still running. For the whole undo window nothing watched for teardown: closing the tab
 * dropped the send with no request, no error and no telemetry.
 *
 * The guard therefore has to live where `pendingSend` lives — module scope — not in a component
 * whose lifetime is shorter than the thing it protects.
 */

import { act, renderHook } from '@testing-library/react';
import { useUndoSend, performUndo } from '@/modules/drafting/hooks/use-undo-send';

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

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

const SETTINGS = { settings: { undoSendEnabled: true } } as never;

const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });

/** Fire a real cancelable beforeunload and report whether anything guarded it. */
const unloadIsGuarded = (): boolean => {
  const event = new Event('beforeunload', { cancelable: true });
  window.dispatchEvent(event);
  return event.defaultPrevented;
};

describe('undo-send — a pending send guards page teardown', () => {
  beforeEach(() => {
    jest.useFakeTimers();
    jest.clearAllMocks();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('does not guard when nothing is pending', () => {
    expect(unloadIsGuarded()).toBe(false);
  });

  it('guards while a send is held — with no attachments', async () => {
    const { result } = renderHook(() => useUndoSend());

    act(() => {
      result.current.startUndoableSend({
        sendFn: () => Promise.resolve({ messageId: 'm1' }),
        settings: SETTINGS,
        emailData: {
          to: ['<email>'],
          subject: 'Q4 pricing',
          message: '<p>numbers</p>',
          attachments: [],
        },
      });
    });

    expect(unloadIsGuarded()).toBe(true);

    // …and stops guarding once the send is committed.
    act(() => {
      jest.advanceTimersByTime(5_000);
    });
    await flush();

    expect(unloadIsGuarded()).toBe(false);
  });

  it('stops guarding after the send is undone', async () => {
    const { result } = renderHook(() => useUndoSend());

    act(() => {
      result.current.startUndoableSend({
        sendFn: () => Promise.resolve({ messageId: 'm1' }),
        settings: SETTINGS,
      });
    });
    expect(unloadIsGuarded()).toBe(true);

    act(() => {
      performUndo();
    });

    expect(unloadIsGuarded()).toBe(false);
  });

  it('still dispatches the send if the user leaves anyway', async () => {
    const { result } = renderHook(() => useUndoSend());
    const sendFn = jest.fn(() => Promise.resolve({ messageId: 'm1' }));
    const onSendComplete = jest.fn();

    act(() => {
      result.current.startUndoableSend({
        sendFn,
        settings: SETTINGS,
        onSendComplete,
        emailData: {
          to: ['<email>'],
          subject: 'Q4 pricing',
          message: '<p>numbers</p>',
          attachments: [],
        },
      });
    });

    // They confirmed "leave" — the browser then fires pagehide.
    act(() => {
      window.dispatchEvent(new Event('pagehide'));
    });
    await flush();

    expect(sendFn).toHaveBeenCalledTimes(1);
    expect(onSendComplete).toHaveBeenCalledWith({ messageId: 'm1' });
    // Teardown is signalled either way — that is what skips the session gate, and awaiting
    // anything while the page unloads is how a send gets lost. keepalive is the separate,
    // size-limited part, available here because there are no attachments.
    expect(mockSetTeardown).toHaveBeenCalledWith({ keepalive: true });
  });

  it('does not use keepalive when the send carries attachments', async () => {
    const { result } = renderHook(() => useUndoSend());

    act(() => {
      result.current.startUndoableSend({
        sendFn: () => Promise.resolve({ messageId: 'm1' }),
        settings: SETTINGS,
        emailData: {
          to: ['<email>'],
          subject: 'deck',
          message: '<p>attached</p>',
          attachments: [new File(['x'], 'deck.pdf', { type: 'application/pdf' })],
        },
      });
    });

    act(() => {
      window.dispatchEvent(new Event('pagehide'));
    });
    await flush();

    // Still a teardown commit — the session gate must be skipped — but too large for keepalive.
    expect(mockSetTeardown).toHaveBeenCalledWith({ keepalive: false });
  });
});