routeInboxParity.test.tsx9.6 KBView on GitHub
/**
 * The email list and the unified feed must answer a route the same way.
 *
 * They did not. `useThreads` resolved the active inbox with a final fallback to the PERSISTED
 * `settings.activeInboxId`, while the feed read the URL slug (`findInboxBySlug` in mail.tsx).
 * On `/mail/inbox` with any other tab active the two asked different questions before the
 * server was even reached — one list scoped to Important under a tab claiming to be the whole
 * Inbox — and on `/mail/sent` the email list sent the folder template's query while the feed
 * sent nothing and let the server fall back to a folder mapping.
 *
 * Both now resolve through `useRouteInbox`, which is URL-only: the URL is what the user
 * navigated to and what is shareable, and the folder route syncs `activeInboxId` FROM the slug
 * rather than the reverse. See apps/mail/docs/inbox-triage.md Phase 0.
 *
 * The feed side is asserted through `useRouteInbox` directly, which is exactly what mail.tsx
 * hands `InboxList` as `compiledQuery` / `queryHash` / `inboxId`; that those three reach
 * `mail.listThreads` unchanged is covered by tests/modules/inbox/feedScopeGate.test.tsx.
 */

import React from 'react';
import { renderHook } from '@testing-library/react';

import type { InboxConfig } from '@/modules/threads/hooks/use-inboxes';
import { resolveRouteInbox } from '@/modules/threads/lib/route-inbox';

// ─── The route the hooks read, and the settings behind them ──────────────────

const route = { folder: 'inbox' };
const settings: { inboxes: InboxConfig[]; activeInboxId: string } = {
  inboxes: [],
  activeInboxId: 'default',
};

jest.mock('react-router', () => ({
  useParams: () => ({ folder: route.folder }),
  useLocation: () => ({ pathname: `/mail/${route.folder}` }),
}));

jest.mock('@tanstack/react-query', () => ({
  useInfiniteQuery: (options: unknown) => {
    listThreadsOptions.push(options as ListThreadsOptions);
    return {
      data: undefined,
      isLoading: false,
      isFetching: false,
      hasNextPage: false,
      isFetchingNextPage: false,
      fetchNextPage: jest.fn(),
    };
  },
  useQueryClient: () => ({ invalidateQueries: jest.fn(), prefetchInfiniteQuery: jest.fn() }),
}));

jest.mock('@/modules/threads/hooks/use-inboxes', () => {
  const actual = jest.requireActual('@/modules/threads/hooks/use-inboxes');
  return {
    ...actual,
    useInboxes: () => ({
      inboxes: settings.inboxes,
      activeInboxId: settings.activeInboxId,
      activeInbox:
        settings.inboxes.find((inbox) => inbox.id === settings.activeInboxId) ??
        settings.inboxes[0],
      inboxesLoading: false,
      importantSignal: 'gmail_important',
    }),
  };
});

jest.mock('@/modules/labels/hooks/use-labels', () => ({ useLabels: () => ({ systemLabels: [] }) }));
jest.mock('@/modules/labels/hooks/use-labels-search', () => ({
  __esModule: true,
  default: () => ({ labels: [] }),
}));
jest.mock('@/hooks/use-connections', () => ({
  useActiveConnection: () => ({ data: { id: 'conn-1', email: '<email>', name: 'Me' } }),
}));
jest.mock('@/modules/threads/hooks/use-search-value', () => ({
  useSearchValue: () => [{ value: '' }, jest.fn()],
}));
jest.mock('@/modules/threads/hooks/use-prefetch-thread', () => ({ shouldPrefetch: () => false }));
jest.mock('@/modules/threads/threadList/hooks/use-threads-operations', () => ({
  resetListThreadsToFirstPage: jest.fn(),
  useThreadsOperations: () => ({ isFetching: false, refetch: jest.fn() }),
}));

const storeState = {
  batchPopulateThreadMetadata: () => [],
  setCurrentThreadList: () => {},
  batchSetThreadData: () => {},
  setConversations: () => {},
  unreadOnlyByFolder: {} as Record<string, boolean>,
  hasConversation: () => true,
  isStale: () => false,
};
jest.mock('@/modules/store', () => ({
  useCedarStore: Object.assign(
    (selector: (state: unknown) => unknown) => selector(storeState),
    { getState: () => storeState },
  ),
}));

import { useThreads } from '@/modules/threads/threadList/hooks/use-threads';
import { useRouteInbox } from '@/modules/threads/hooks/use-route-inbox';

type ListThreadsOptions = { queryKey=[redacted], Record<string, unknown>, unknown] };

const listThreadsOptions: ListThreadsOptions[] = [];

/** What the email list actually asked `mail.listThreads` for, on its last render. */
function emailListInput(): Record<string, unknown> {
  const last = listThreadsOptions[listThreadsOptions.length - 1];
  return last.queryKey[1];
}

const wrapper = ({ children }: { children: React.ReactNode }) => <>{children}</>;

/** Render both surfaces' resolvers under one route and return what each derived. */
function askBothSurfaces() {
  listThreadsOptions.length = 0;
  const { result } = renderHook(
    () => {
      useThreads();
      return useRouteInbox({ folder: route.folder });
    },
    { wrapper },
  );

  const emailList = emailListInput();
  return {
    emailList: {
      compiledQuery: emailList.compiledQuery,
      queryHash: emailList.queryHash,
      inboxId: emailList.inboxId,
    },
    unifiedFeed: {
      compiledQuery: result.current.compiled?.compiledQuery,
      queryHash: result.current.compiled?.queryHash,
      inboxId: result.current.record?.id,
    },
  };
}

// ─── Fixtures ────────────────────────────────────────────────────────────────

const DEFAULT_TAB: InboxConfig = {
  id: 'default',
  name: 'Inbox',
  position: 0,
  system: true,
  rule: { kind: 'all' },
};
const IMPORTANT_TAB: InboxConfig = {
  id: 'important',
  name: 'Important',
  position: 0,
  system: true,
  rule: { kind: 'all' },
};
const OTHER_TAB: InboxConfig = {
  id: 'other',
  name: 'Other',
  position: 1,
  system: true,
  rule: { kind: 'all' },
};
const GITHUB_INBOX: InboxConfig = {
  id: 'inbox-github',
  name: 'GitHub',
  position: 2,
  rule: { kind: 'all' },
  enabled: true,
  compiledQuery: 'label:INBOX from:github.com',
  queryHash: 'sha256:github',
};

beforeEach(() => {
  route.folder = 'inbox';
  settings.inboxes = [DEFAULT_TAB, GITHUB_INBOX];
  settings.activeInboxId = 'default';
});

describe('one route, one compiled query, both lists', () => {
  it('agrees on the plain inbox', () => {
    const { emailList, unifiedFeed } = askBothSurfaces();

    expect(emailList.compiledQuery).toContain('label:INBOX');
    expect(emailList).toEqual(unifiedFeed);
  });

  it('agrees on a custom inbox slug, rule and hash included', () => {
    route.folder = 'github';

    const { emailList, unifiedFeed } = askBothSurfaces();

    expect(emailList).toEqual({
      compiledQuery: 'label:INBOX from:github.com',
      queryHash: 'sha256:github',
      inboxId: 'inbox-github',
    });
    expect(unifiedFeed).toEqual(emailList);
  });

  it('agrees on a system folder, which the feed used to answer with nothing', () => {
    route.folder = 'sent';

    const { emailList, unifiedFeed } = askBothSurfaces();

    expect(emailList).toEqual({
      compiledQuery: 'in:sent',
      queryHash: 'system-folder:sent:in:sent',
      // A folder template carries no CRM rule, so neither surface names an inbox record.
      inboxId: undefined,
    });
    expect(unifiedFeed).toEqual(emailList);
  });

  it('ignores the persisted active tab — the URL is the question being asked', () => {
    // The case that diverged: under the important/other layout nothing owns the `inbox`
    // slug, so the email list fell through to `settings.activeInboxId` and scoped the list
    // to Important while the tab said Inbox. The folder route is already redirecting to the
    // first tab; until it lands, `/mail/inbox` means the whole inbox on both surfaces.
    settings.inboxes = [IMPORTANT_TAB, OTHER_TAB];
    settings.activeInboxId = 'important';

    const { emailList, unifiedFeed } = askBothSurfaces();

    expect(emailList).toEqual(unifiedFeed);
    expect(emailList.compiledQuery).not.toContain('IMPORTANT');
  });

  it('agrees on a system tab that IS the route', () => {
    settings.inboxes = [IMPORTANT_TAB, OTHER_TAB];
    settings.activeInboxId = 'other';
    route.folder = 'important';

    const { emailList, unifiedFeed } = askBothSurfaces();

    // Resolved from the slug, not from the tab the settings remember.
    expect(emailList.inboxId).toBe('important');
    expect(emailList.compiledQuery).toContain('IMPORTANT');
    expect(unifiedFeed).toEqual(emailList);
  });
});

describe('resolveRouteInbox — the ladder both surfaces walk', () => {
  it('prefers an explicit inbox, for the stacked sections that render several at once', () => {
    const resolved = resolveRouteInbox({
      inboxes: [DEFAULT_TAB, GITHUB_INBOX],
      folder: 'inbox',
      explicitInbox: GITHUB_INBOX,
    });

    expect(resolved.inbox).toBe(GITHUB_INBOX);
    expect(resolved.record?.id).toBe('inbox-github');
  });

  it('falls back to a custom inbox reached by raw id', () => {
    const resolved = resolveRouteInbox({ inboxes: [DEFAULT_TAB, GITHUB_INBOX], folder: 'inbox-github' });

    expect(resolved.inbox?.id).toBe('inbox-github');
    expect(resolved.matchedSlug).toBe(true);
  });

  it('resolves a system folder to a template carrying no record', () => {
    const resolved = resolveRouteInbox({ inboxes: [DEFAULT_TAB], folder: 'archive' });

    expect(resolved.systemFolderSplit?.compiledQuery).toBe('in:archive');
    expect(resolved.record).toBeUndefined();
  });

  it('resolves a Gmail label route to no inbox at all', () => {
    // `/mail/some-label` is a label read, not an inbox — the caller sends `q:` instead.
    const resolved = resolveRouteInbox({ inboxes: [DEFAULT_TAB], folder: 'some-label' });

    expect(resolved.inbox).toBeUndefined();
    expect(resolved.matchedSlug).toBe(false);
  });
});