split-inbox-templates.test.tsx12.4 KBView on GitHub
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import {
  SplitInboxTabs,
  SUPERHUMAN_TEMPLATES,
} from '@/modules/threads/components/SplitInboxTabs';

const mockCreateAiLabelMutateAsync = jest.fn();
const mockBackfillAiLabelsMutateAsync = jest.fn();
const mockAddInbox = jest.fn();

let mockAllUserLabels: Array<{ id: string; name: string }> = [];

jest.mock('@/components/ui/dropdown-menu', () => ({
  DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
  DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  DropdownMenuItem: ({
    children,
    onSelect,
  }: {
    children: React.ReactNode;
    onSelect?: () => void;
  }) => <button onClick={onSelect}>{children}</button>,
  DropdownMenuSeparator: () => null,
}));

jest.mock('@/components/ui/dialog', () => ({
  Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
    open ? <div>{children}</div> : null,
  DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

jest.mock('@/components/ui/button', () => ({
  Button: ({
    children,
    onClick,
    disabled,
  }: {
    children: React.ReactNode;
    onClick?: () => void;
    disabled?: boolean;
  }) => (
    <button onClick={onClick} disabled={disabled}>
      {children}
    </button>
  ),
}));

jest.mock('@/components/ui/input', () => ({
  Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));

jest.mock('@/components/ui/switch', () => ({
  Switch: ({
    checked,
    onCheckedChange,
  }: {
    checked?: boolean;
    onCheckedChange?: (checked: boolean) => void;
  }) => (
    <input
      type="checkbox"
      checked={checked}
      onChange={(event) => onCheckedChange?.(event.target.checked)}
    />
  ),
}));

jest.mock('@/components/ui/checkbox', () => ({
  Checkbox: ({
    checked,
    onCheckedChange,
  }: {
    checked?: boolean;
    onCheckedChange?: (checked: boolean) => void;
  }) => (
    <input
      type="checkbox"
      checked={checked}
      onChange={(event) => onCheckedChange?.(event.target.checked)}
    />
  ),
}));

jest.mock('@tanstack/react-query', () => ({
  useMutation: (options: { mutationKey?: string[] }) => {
    const key=[redacted];
    if (key === 'createAiLabel') {
      return { mutateAsync: mockCreateAiLabelMutateAsync };
    }
    if (key === 'backfillAiLabels') {
      return { mutateAsync: mockBackfillAiLabelsMutateAsync };
    }
    return { mutateAsync: jest.fn() };
  },
  useQueryClient: () => ({ invalidateQueries: jest.fn() }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    labels: {
      list: {
        queryKey: () => ['labels.list'],
      },
      create: {
        mutationOptions: (options: Record<string, unknown> = {}) => ({
          mutationKey: ['createUserLabel'],
          ...options,
        }),
      },
      delete: {
        mutationOptions: (options: Record<string, unknown> = {}) => ({
          mutationKey: ['deleteUserLabel'],
          ...options,
        }),
      },
    },
    mail: {
      createAiLabel: {
        mutationOptions: (options: Record<string, unknown> = {}) => ({
          mutationKey: ['createAiLabel'],
          ...options,
        }),
      },
      backfillAiLabels: {
        mutationOptions: (options: Record<string, unknown> = {}) => ({
          mutationKey: ['backfillAiLabels'],
          ...options,
        }),
      },
      deleteAiLabel: {
        mutationOptions: (options: Record<string, unknown> = {}) => ({
          mutationKey: ['deleteAiLabel'],
          ...options,
        }),
      },
    },
  }),
}));

jest.mock('@/modules/threads/hooks/use-inboxes', () => ({
  getInboxSlug: (inbox: { id: string; name: string }) => {
    if (inbox.id === 'default') return 'inbox';
    if (inbox.id === 'important') return 'important';
    if (inbox.id === 'other') return 'other';
    return inbox.name.toLowerCase().replace(/\s+/g, '-');
  },
  findInboxBySlug: (inboxes: Array<{ id: string }>, slug: string) =>
    inboxes.find((inbox) => (slug === 'inbox' ? inbox.id === 'default' : inbox.id === slug)),
  getRowInboxOrder: (inboxes: Array<{ id: string }>) => inboxes,
  useInboxes: () => ({
    inboxes: [{ id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } }],
    activeInbox: { id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } },
    activeInboxId: 'default',
    inboxLayout: 'inbox',
    importantSignal: 'category_personal',
    setActiveInboxId: jest.fn(),
    setInboxLayout: jest.fn(),
    setImportantSignal: jest.fn(),
    addInbox: mockAddInbox,
    updateInbox: jest.fn(),
    renameInbox: jest.fn(),
    removeInbox: jest.fn(),
    reorderInboxes: jest.fn(),
    isLoading: false,
    inboxesLoading: false,
  }),
}));

jest.mock('@/modules/labels/hooks/use-labels-search', () => ({
  __esModule: true,
  default: () => ({ setLabels: jest.fn(), labels: [] }),
}));

jest.mock('@/hooks/use-inbox-counts', () => ({
  useInboxCounts: () => ({
    byId: { default: { count: 0, isExact: true } },
    done: { count: 0, isExact: true },
  }),
}));

jest.mock('@/modules/aop/hooks/use-aops', () => ({
  useAOPs: () => ({ data: { aops: [] } }),
}));

jest.mock('@/modules/labels/hooks/use-labels', () => ({
  useLabels: () => ({ allUserLabels: mockAllUserLabels, systemLabels: [] }),
}));

jest.mock('@/modules/store', () => ({
  useCedarStore: (selector: (state: Record<string, unknown>) => unknown) =>
    selector({
      isThreadOpen: false,
      isConversationOpen: false,
      // The tab strip reads which inboxes the ⇧U filter is narrowing, and calls setUnreadOnly
      // to clear one from its badge.
      unreadOnlyByFolder: {},
      setUnreadOnly: () => {},
    }),
}));

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

describe('split inbox template coverage and behavior', () => {
  beforeEach(() => {
    mockAllUserLabels = [];
    mockCreateAiLabelMutateAsync.mockReset().mockResolvedValue({});
    mockBackfillAiLabelsMutateAsync
      .mockReset()
      .mockResolvedValue({ attempted: 50, succeeded: 50, failed: 0 });
    mockAddInbox.mockReset().mockResolvedValue('inbox-marketing');
  });

  /**
   * The Calendar split shipped matching only two sender addresses plus
   * `filename:ics`, and both halves leaked:
   *  - `filename:ics` needs the invite's text/calendar part to be a NAMED
   *    attachment, which Google Calendar invites often are not;
   *  - the sender terms miss RSVP notices and the human/auto replies on invite
   *    threads, which come from the ATTENDEE's own address.
   * Real reported case: a rep still saw `Invitation:` and `Re: Declined:` mail in
   * Important after enabling the split. These assertions pin the subject terms
   * that fix it, and — just as importantly — that they stay narrow enough to
   * leave product mail in the inbox.
   */
  describe('Calendar template', () => {
    const calendar = SUPERHUMAN_TEMPLATES.find((t) => t.name === 'Calendar');
    /** The mirror interprets `subject:"x"` as a case-insensitive substring (ILIKE %x%). */
    const subjectTerms = (query: string) =>
      Array.from(query.matchAll(/subject:"([^"]+)"/g)).map((m) => m[1]!.toLowerCase());
    const matches = (subject: string) =>
      subjectTerms(calendar!.query!).some((term) => subject.toLowerCase().includes(term));

    it('carves calendar mail out of Important rather than mirroring into it', () => {
      // Without this the split is a copy, not a partition, and the clutter stays.
      expect(calendar!.alsoShowInImportantOrOther ?? false).toBe(false);
    });

    it('still matches genuinely attached .ics files', () => {
      expect(calendar!.query).toContain('filename:ics');
    });

    it.each([
      'Invitation: Debrief - Nicolas Baranowski @ Thu Aug 27, 2026 11:45am - 12pm (EDT)',
      'Re: Updated invitation: LivTech / Ode Sync @ Wed Aug 12, 2026 12pm - 12:30pm (EDT)',
      'Automatic reply: Updated invitation with note: Fractional <> Waystar DS Knowledge Transfer',
      'Out of office until 7/31 Re: Invitation: Project Review: LivTech AI Roadmapping',
      'Re: Declined: LivTech / Ode Weekly Sync',
      'Accepted: Varun / Abrey @ Thu Mar 26, 2026 9am - 10am (EDT)',
      'Re: Canceled event: Salma / Varun @ Weekly from 9:30am to 10am on Wednesday (PDT)',
      'Team Happy Hour @ Tue, Jul 21, 2026 5:30pm \u2013 7:30pm (GMT-04)',
    ])('catches calendar mail: %s', (subject) => {
      expect(matches(subject)).toBe(true);
    });

    it.each([
      'Isabelle invited you to work together in Slack',
      'Your invitation to the Claude Console',
      'Reminder: gracie has invited you to join the project LivTech',
      'You\u2019re invited to Vornado',
    ])('leaves product mail in the inbox: %s', (subject) => {
      // This is why every term keeps its trailing colon — a bare `invitation`
      // would pull all of these out of the user's inbox.
      expect(matches(subject)).toBe(false);
    });
  });

  it('includes every documented Superhuman template', () => {
    const templateNames = new Set(SUPERHUMAN_TEMPLATES.map((template) => template.name));
    const expectedNames = [
      'Important + Other',
      'Calendar',
      'VIP',
      'Starred',
      'Unread',
      'Reminders',
      'Shared',
      'Team',
      'Notes',
      'Travel',
      'Purchases',
      'Finance',
      'Notifications',
      'News',
      'Social',
      'Marketing',
      'Pitches',
      'Documents',
      'Google',
      'Office',
      'Notion',
      'Coda',
      'Confluence',
      'Loom',
      'Figma',
      'GitHub',
      'Linear',
      'Jira',
      'Aha!',
      'Asana',
      'Trello',
      'ClickUp',
      'Monday',
      'Signature',
      'DocuSign',
      'SignNow',
      'Dropbox Sign',
      'Signeasy',
      'PandaDoc',
      'Salesforce',
      'Zoho',
      'HubSpot',
      'Pipedrive',
      'Gong',
      'Chorus',
      'DocSend',
      'Workday Recruiting',
      'Greenhouse',
      'Lever',
      'Ashby',
      'SmartRecruiters',
      'Workable',
      'Breezy HR',
      'Zoom',
      'Meet',
      'Otter',
      'Fireflies.ai',
    ];

    for (const expectedName of expectedNames) {
      expect(templateNames.has(expectedName)).toBe(true);
    }
  });

  it('configures Cedar AI label metadata for all AI-based templates', () => {
    const aiTemplates = SUPERHUMAN_TEMPLATES.filter((template) =>
      ['Travel', 'Purchases', 'Finance', 'Notifications', 'News', 'Social', 'Marketing', 'Pitches'].includes(template.name),
    );

    expect(aiTemplates.length).toBe(8);
    for (const template of aiTemplates) {
      expect(template.aiLabels && template.aiLabels.length > 0).toBe(true);
      for (const aiLabel of template.aiLabels ?? []) {
        expect(aiLabel.slug.length).toBeGreaterThan(0);
        expect(aiLabel.displayName.length).toBeGreaterThan(0);
        expect(aiLabel.description.length).toBeGreaterThan(0);
      }
    }
  });

  it('creates missing AI labels and triggers 50-thread backfill when applying AI templates', async () => {
    render(
      <MemoryRouter initialEntries={['/mail/inbox']}>
        <SplitInboxTabs />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByText('Edit inboxes'));
    fireEvent.click(screen.getByText('Marketing').closest('button')!);

    await waitFor(() => expect(mockCreateAiLabelMutateAsync).toHaveBeenCalledTimes(1));
    expect(mockCreateAiLabelMutateAsync).toHaveBeenCalledWith(
      expect.objectContaining({
        displayName: 'Marketing',
      }),
    );

    await waitFor(() => expect(mockAddInbox).toHaveBeenCalledTimes(1));
    const addInboxArgs = mockAddInbox.mock.calls[0]?.[0] as { query: string };
    expect(addInboxArgs.query).toContain('label:[superhuman]/ai/marketing');
    expect(addInboxArgs.query).toContain('label:"Cedar/AI/marketing"');

    await waitFor(() => expect(mockBackfillAiLabelsMutateAsync).toHaveBeenCalledTimes(1));
    expect(mockBackfillAiLabelsMutateAsync).toHaveBeenCalledWith({
      maxThreads: 50,
      folder: 'INBOX',
      reexecute: true,
      bypassAgentExecutionEnabledCheck: true,
    });
  });
});