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