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);
});
});