scheduled-send-layout.test.ts5.1 KBView on GitHub
/**
 * Placement rules for scheduled ("send later") emails inside a thread.
 *
 * The bug these encode: scheduling a reply left NOTHING visible in the conversation. The
 * provider draft stays put (the queue consumes it only when it fires), but the composer
 * was cleared on schedule, so the thread showed a blank editor and the queued email
 * existed only in the Scheduled folder.
 *
 * A scheduled send therefore renders as an ordinary message in the chain until the user
 * presses Edit, which hands the draft back to the composer. Only pending sends reach here
 * — a cancelled one leaves nothing to draw.
 */

import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import {
  asScheduledMessage,
  indexScheduledByDraftId,
  partitionDraftsBySchedule,
  type ScheduledSendEntry,
} from '@/modules/threads/thread/lib/scheduled-send-layout';

const draft = (id: string, draftId?: string): ParsedMessage =>
  ({
    id,
    draftId,
    isDraft: true,
    subject: 'Re: pricing',
    processedHtml: '<p>hi</p>',
    receivedOn: '2026-08-11T10:00:00.000Z',
    sender: { email: '<email>', name: 'Me' },
    tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
    to: [{ email: '<email>' }],
  }) as unknown as ParsedMessage;

const entry = (over: Partial<ScheduledSendEntry> = {}): ScheduledSendEntry => ({
  id: 'sched-1',
  threadId: 'thread-1',
  draftId: 'draft-1',
  sendAt: '2026-08-12T09:00:00.000Z',
  subject: 'Re: pricing',
  snippet: 'hi',
  to: [{ email: '<email>' }],
  ...over,
});

describe('indexScheduledByDraftId', () => {
  it('keeps the freshest schedule when a draft was re-armed', () => {
    const older = entry({ id: 'sched-old', sendAt: '2026-08-12T09:00:00.000Z' });
    const newer = entry({ id: 'sched-new', sendAt: '2026-08-13T09:00:00.000Z' });

    expect(indexScheduledByDraftId([older, newer]).get('draft-1')?.id).toBe('sched-new');
    expect(indexScheduledByDraftId([newer, older]).get('draft-1')?.id).toBe('sched-new');
  });

  it('ignores rows with no draft behind them', () => {
    expect(indexScheduledByDraftId([entry({ draftId: null })]).size).toBe(0);
  });
});

describe('asScheduledMessage', () => {
  const identity = { email: '<email>', name: 'Jesse' };

  it('swaps DRAFT for SENT so the header renders the real recipient, not "You"', () => {
    const message = asScheduledMessage(draft('m1', 'draft-1'), identity);

    expect(message.isDraft).toBe(false);
    expect(message.tags?.map((t) => t.id)).toEqual(['SENT']);
  });

  it('fills the sender from the account when the provider draft has none', () => {
    const senderless = { ...draft('m1', 'draft-1'), sender: { email: '', name: '' } };
    const message = asScheduledMessage(senderless as ParsedMessage, identity);

    expect(message.sender).toEqual({ email: '<email>', name: 'Jesse' });
  });

  it('falls back to the address when nothing else names the sender', () => {
    const senderless = { ...draft('m1', 'draft-1'), sender: { email: '', name: '' } };
    const message = asScheduledMessage(senderless as ParsedMessage, { email: '<email>' });

    expect(message.sender).toEqual({ email: '<email>', name: '<email>' });
  });

  it('leaves the body, subject and recipients alone', () => {
    const source = draft('m1', 'draft-1');
    const message = asScheduledMessage(source, identity);

    expect(message.processedHtml).toBe(source.processedHtml);
    expect(message.subject).toBe(source.subject);
    expect(message.to).toEqual(source.to);
    // The date slot shows the scheduled time, so the draft's own stamp must not be faked.
    expect(message.receivedOn).toBe(source.receivedOn);
  });
});

describe('partitionDraftsBySchedule', () => {
  it('renders a scheduled draft as a message, not a composer', () => {
    const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
      [draft('m1', 'draft-1')],
      indexScheduledByDraftId([entry()]),
      new Set(),
    );

    expect(scheduledDrafts).toHaveLength(1);
    expect(scheduledDrafts[0].entry.id).toBe('sched-1');
    expect(editableDrafts).toHaveLength(0);
  });

  it('hands the draft back to the composer once the user presses Edit', () => {
    const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
      [draft('m1', 'draft-1')],
      indexScheduledByDraftId([entry()]),
      new Set(['sched-1']),
    );

    expect(scheduledDrafts).toHaveLength(0);
    expect(editableDrafts).toHaveLength(1);
  });

  it('leaves ordinary drafts editable', () => {
    const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
      [draft('m1', 'draft-other'), draft('m2')],
      indexScheduledByDraftId([entry()]),
      new Set(),
    );

    expect(scheduledDrafts).toHaveLength(0);
    expect(editableDrafts).toHaveLength(2);
  });

  it('leaves a draft editable once its send is gone — a cancel removes the scheduled view', () => {
    const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
      [draft('m1', 'draft-1')],
      indexScheduledByDraftId([]),
      new Set(),
    );

    expect(scheduledDrafts).toHaveLength(0);
    expect(editableDrafts).toHaveLength(1);
  });
});