feedScopeGate.test.tsx8.4 KBView on GitHub import { renderHook, waitFor } from '@testing-library/react';
import React from 'react';
/**
* The channel badge and the inbox tab are ORTHOGONAL filters over one feed: "all channels"
* ∩ "Important" has to mean both, not either.
*
* <email>, 2026-08-26, on `/inbox/important?channel=all`: 32 archived Rippling
* commuter-benefit receipts (Gmail-IMPORTANT, no INBOX label) showed up under Important,
* while the email-only list — which always sends the tab's real compiled query — showed
* none of them. The feed had been asked without the inbox half: until settings/inboxes
* resolve, `inboxLayout` reads as the default `inbox`, no system stub owns the `important`
* slug, `findInboxBySlug` misses, and `compiledQuery` goes out undefined. The server then
* falls back to a folder mapping that cannot express the tab.
*
* A feed asked without its scope is not a partial answer, it is a different question — so
* the queries are held rather than sent wrong. Now that the feed is composed on the client
* (inbox-triage.md Phase 8) the scope decides one thing more: WHICH channels are asked at
* all. A channel that cannot evaluate the tab's rule is not queried and answered with an
* empty page — it is never queried.
*/
const mockInfiniteQuery = jest.fn();
const mockQuery = jest.fn();
const mockScope: { data: { channels: string[]; mailOnly: boolean } | undefined } = {
data: undefined,
};
jest.mock('@tanstack/react-query', () => {
const actual = jest.requireActual('@tanstack/react-query');
return {
...actual,
// The email source reads the client to trim its cached pages when the head shifts. This
// suite renders no provider, so hand it an inert one — nothing here exercises that path.
useQueryClient: () => ({}),
useQuery: (...args: unknown[]) => {
mockQuery(...args);
return { data: mockScope.data, isLoading: false, isError: false, refetch: jest.fn() };
},
useInfiniteQuery: (...args: unknown[]) => {
mockInfiniteQuery(...args);
return {
data: undefined,
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
refetch: jest.fn(),
isPlaceholderData: false,
};
},
};
});
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
inbox: {
getFeedScope: {
queryOptions: (input: unknown, opts: Record<string, unknown>) => ({
queryKey: ['inbox', 'getFeedScope', input],
...opts,
}),
},
listChannelItems: {
infiniteQueryOptions: (input: unknown, opts: Record<string, unknown>) => ({
queryKey: ['inbox', 'listChannelItems', input],
...opts,
}),
},
},
mail: {
listThreads: {
infiniteQueryOptions: (input: unknown, opts: Record<string, unknown>) => ({
queryKey: ['mail', 'listThreads', input],
...opts,
}),
},
},
}),
}));
jest.mock('@/hooks/use-connections', () => ({
useActiveConnection: () => ({ data: { id: 'conn-1' } }),
}));
jest.mock('@/modules/store', () => ({
useCedarStore: (selector: (s: unknown) => unknown) =>
selector({
channelFeeds: {},
setChannelFeeds: jest.fn(),
batchPopulateThreadMetadata: jest.fn(),
}),
}));
import { useInboxItems } from '@/modules/inbox/hooks/use-inbox-items';
const wrapper = ({ children }: { children: React.ReactNode }) => <>{children}</>;
type Options = Record<string, unknown> & { queryKey=[redacted], string, Record<string, unknown>] };
const callsMatching = (path: string, channel?: string): Options[] =>
(mockInfiniteQuery.mock.calls.map((call) => call[0]) as Options[]).filter(
(opts) =>
opts.queryKey[1] === path && (channel === undefined || opts.queryKey[2].channel === channel),
);
/** The last options each channel's query was rendered with — `undefined` if never rendered. */
const lastFor = (path: string, channel?: string): Options | undefined => {
const matches = callsMatching(path, channel);
return matches[matches.length - 1];
};
const scopeOptions = () =>
mockQuery.mock.calls[mockQuery.mock.calls.length - 1]?.[0] as Record<string, unknown> | undefined;
/**
* What `inbox.getFeedScope` returns for the plain unibox. The route runs the shared
* `participatingChannels` rule server-side and hands back the resolved set, so a client
* fixture of `[]` means "nothing participates" rather than "no split is active".
*/
const ALL: string[] = ['email', 'linkedin', 'whatsapp', 'slack'];
describe('unified feed — the inbox half of the scope gates the request', () => {
beforeEach(() => {
mockInfiniteQuery.mockClear();
mockQuery.mockClear();
mockScope.data = undefined;
});
it('holds every query while the tab is still resolving', async () => {
renderHook(
() =>
useInboxItems({
channel: 'all',
folder: 'important',
// What the call site passes before settings/inboxes land.
compiledQuery: undefined,
enabled: false,
}),
{ wrapper },
);
await waitFor(() => expect(mockQuery).toHaveBeenCalled());
expect(scopeOptions()?.enabled).toBe(false);
// Not one channel query goes out — no scope, no participation.
for (const opts of mockInfiniteQuery.mock.calls.map((call) => call[0] as Options)) {
expect(opts.enabled).toBe(false);
}
});
it('sends them once the tab resolves, carrying BOTH the channel and the inbox query', async () => {
const IMPORTANT = 'label:INBOX ((label:IMPORTANT)) -({subject:"invitation:"})';
mockScope.data = { channels: ['email', 'slack'], mailOnly: false };
renderHook(
() =>
useInboxItems({
channel: 'all',
folder: 'important',
compiledQuery: IMPORTANT,
queryHash: 'sha256:abc',
inboxId: 'important',
enabled: true,
}),
{ wrapper },
);
await waitFor(() => expect(mockInfiniteQuery).toHaveBeenCalled());
// The scope is asked for the tab — and is handed only the id, never the rule.
expect(scopeOptions()?.enabled).toBe(true);
expect(mockQuery.mock.calls[0]?.[0]?.queryKey[2]).toEqual({ inboxId: 'important' });
// Orthogonal: each participating channel's own query carries the inbox's rule.
const slack = lastFor('listChannelItems', 'slack');
expect(slack?.enabled).toBe(true);
expect(slack?.queryKey[2].compiledQuery).toBe(IMPORTANT);
const email = lastFor('listThreads');
expect(email?.enabled).toBe(true);
expect(email?.queryKey[2].compiledQuery).toBe(IMPORTANT);
});
it('never asks a channel that cannot evaluate a Gmail-only rule', async () => {
// `mailOnly` with no opt-in: the chat sources have no way to answer a Gmail query, and
// answering "everything" instead is what put 32 LinkedIn chats on a Calendar tab.
mockScope.data = { channels: ['email'], mailOnly: true };
renderHook(
() => useInboxItems({ channel: 'all', folder: 'calendar', inboxId: 'calendar' }),
{ wrapper },
);
await waitFor(() => expect(mockInfiniteQuery).toHaveBeenCalled());
expect(lastFor('listThreads')?.enabled).toBe(true);
for (const channel of ['linkedin', 'whatsapp', 'slack']) {
expect(lastFor('listChannelItems', channel)?.enabled).toBe(false);
}
});
it('asks one channel only when the badge names one', async () => {
// `channels` is the RESOLVED participation set the server computed, not a split's opt-in:
// the plain unibox comes back with all four. The badge narrows it here.
mockScope.data = { channels: ALL, mailOnly: false };
renderHook(() => useInboxItems({ channel: 'linkedin', folder: 'inbox' }), { wrapper });
await waitFor(() => expect(mockInfiniteQuery).toHaveBeenCalled());
expect(lastFor('listChannelItems', 'linkedin')?.enabled).toBe(true);
expect(lastFor('listThreads')?.enabled).toBe(false);
expect(lastFor('listChannelItems', 'slack')?.enabled).toBe(false);
});
it('defaults to enabled so every other caller is unchanged', async () => {
mockScope.data = { channels: ALL, mailOnly: false };
renderHook(() => useInboxItems({ channel: 'all', folder: 'inbox' }), { wrapper });
await waitFor(() => expect(mockQuery).toHaveBeenCalled());
expect(scopeOptions()?.enabled).toBe(true);
expect(lastFor('listThreads')?.enabled).toBe(true);
expect(lastFor('listChannelItems', 'whatsapp')?.enabled).toBe(true);
});
});