feedPartialFailure.test.tsx12.8 KBView on GitHub
import { renderHook, waitFor } from '@testing-library/react';
import React from 'react';

/**
 * One channel going down is not the inbox going down.
 *
 * The unified feed is one query PER channel, so Slack can fail while email and LinkedIn are
 * perfectly healthy. Two things used to go wrong in that case, and both of them ended with the
 * rep looking at nothing:
 *
 *   1. A failed channel never became `settled`, because `settled` was `data !== undefined` and
 *      a rejected query has no data. The merge refuses to render until every channel settles —
 *      correctly, since an unsettled channel could still return an item of any timestamp — so
 *      `ready` stayed false, `isLoading` stayed true, and `InboxList` (which checks loading
 *      before error) spun forever. Not "Slack is down": an indefinite spinner.
 *
 *   2. Even once it settled, `isError` was `some(channel => channel.isError)`, which blanks a
 *      working list and throws away every row the healthy channels did return.
 *
 * A failed channel is now settled-and-empty — it contributes nothing and bounds nothing, which
 * is exactly how the merge already treats a channel that came back empty — and the feed errors
 * only when it truly cannot answer.
 */

const mockReset = jest.fn();
jest.mock('@/modules/threads/threadList/hooks/use-threads-operations', () => ({
  resetListThreadsToFirstPage: (...args: unknown[]) => mockReset(...args),
}));

/** Per-channel query results, keyed by the channel each query is asked for. */
type ChannelState = {
  items?: unknown[];
  threadIds?: string[][];
  isError?: boolean;
  /** Failed on a later fetch, so react-query still holds the previous page. */
  retainsData?: boolean;
  hasNextPage?: boolean;
  headChanged?: boolean;
  pending?: boolean;
};
const channelState: Record<string, ChannelState> = {};

/**
 * An email row shaped the way `mail.listThreads` returns it.
 *
 * The timestamp comes from the id's own suffix rather than from which page carried the row, so
 * `e1` is newer than `e2` no matter where it appears. That is what makes the overlap test below
 * unambiguous: a row that shows up on two pages keeps ONE identity and one position.
 */
const emailThread = (id: string) => ({
  id,
  $raw: {
    sender: { email: `${id}@example.com`, name: id },
    labels: [],
    latestReceivedOn: `2026-08-31T${20 - Number(id.slice(1))}:00:00.000Z`,
    subject: `subject ${id}`,
    snippet: '',
    messageCount: 1,
  },
});

/** A chat row shaped the way `inbox.listChannelItems` returns it. */
const chatItem = (channel: string, id: string, sortedAt: string) => ({
  id: `${channel}:${id}`,
  channel,
  sortedAt,
  counterpart: { name: id },
  snippet: '',
  unread: false,
  starred: false,
  hasDraft: false,
  done: false,
  labelIds: [],
  participantCount: 1,
  ref: { kind: channel, conversationId: id },
});

jest.mock('@tanstack/react-query', () => {
  const actual = jest.requireActual('@tanstack/react-query');
  return {
    ...actual,
    useQueryClient: () => ({}),
    useQuery: () => ({
      data: { channels: ['email', 'linkedin', 'whatsapp', 'slack'], mailOnly: false },
      isLoading: false,
      isError: false,
      refetch: jest.fn(),
    }),
    useInfiniteQuery: (options: { queryKey=[redacted], string, { channel?: string }] }) => {
      const isEmail = options.queryKey[1] === 'listThreads';
      const channel = isEmail ? 'email' : (options.queryKey[2].channel ?? 'unknown');
      const state = channelState[channel] ?? {};
      const base = {
        isLoading: false,
        isError: Boolean(state.isError),
        hasNextPage: Boolean(state.hasNextPage),
        isFetchingNextPage: false,
        fetchNextPage: jest.fn(),
        refetch: jest.fn(),
        isPlaceholderData: false,
      };
      // A query that failed on its FIRST fetch has no data at all — the point of case 1 above.
      // One that failed on a LATER fetch keeps the last good data, which `retainsData` models.
      if ((state.isError && !state.retainsData) || state.pending) {
        return { ...base, data: undefined };
      }
      const pages = isEmail
        ? (state.threadIds ?? []).map((ids, page) => ({
            threads: ids.map(emailThread),
            headChanged: page === 0 ? Boolean(state.headChanged) : false,
          }))
        : [{ items: state.items ?? [] }];
      return { ...base, data: { pages } };
    },
  };
});

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

const render = (folder = 'inbox') =>
  renderHook(({ f }: { f: string }) => useInboxItems({ channel: 'all', folder: f }), {
    wrapper,
    initialProps: { f: folder },
  });

/** Every channel healthy, each holding one row, nothing left to page. */
const healthy = () => {
  channelState.email = { threadIds: [['e1']] };
  channelState.linkedin = { items: [chatItem('linkedin', 'l1', '2026-08-31T09:00:00.000Z')] };
  channelState.whatsapp = { items: [chatItem('whatsapp', 'w1', '2026-08-31T08:00:00.000Z')] };
  channelState.slack = { items: [chatItem('slack', 's1', '2026-08-31T07:00:00.000Z')] };
};

beforeEach(() => {
  mockReset.mockClear();
  for (const key of Object.keys(channelState)) delete channelState[key];
});

describe('unified feed — a failed channel does not take the inbox down with it', () => {
  it('renders the healthy channels when one chat channel fails', () => {
    healthy();
    channelState.slack = { isError: true };

    const { result } = render();

    // The bug: this used to be `true` forever, because Slack never settled.
    expect(result.current.isLoading).toBe(false);
    // The other bug: this used to be `true`, blanking a list that had rows to show.
    expect(result.current.isError).toBe(false);
    expect(result.current.failedChannels).toEqual(['slack']);

    const ids = result.current.items.map((item) => item.id);
    expect(ids).toContain('email:e1');
    expect(ids).toContain('linkedin:l1');
    expect(ids).toContain('whatsapp:w1');
    expect(ids).not.toContain('slack:s1');
  });

  it('renders the chat channels when EMAIL is the one that fails', () => {
    healthy();
    channelState.email = { isError: true };

    const { result } = render();

    expect(result.current.isLoading).toBe(false);
    expect(result.current.isError).toBe(false);
    expect(result.current.failedChannels).toEqual(['email']);
    expect(result.current.items.map((item) => item.id)).toEqual([
      'linkedin:l1',
      'whatsapp:w1',
      'slack:s1',
    ]);
  });

  it('names every failed channel, in participation order', () => {
    healthy();
    channelState.whatsapp = { isError: true };
    channelState.slack = { isError: true };

    const { result } = render();

    expect(result.current.failedChannels).toEqual(['whatsapp', 'slack']);
    expect(result.current.isError).toBe(false);
    expect(result.current.items.map((item) => item.id)).toEqual(['email:e1', 'linkedin:l1']);
  });

  it('stops advertising more pages when every channel the next page would come from is down', () => {
    // Slack keeps its loaded rows (react-query retains the last good data) and is still the
    // newest tail, so it gates. Paging it is the only thing that would release the rows held
    // beneath it — and paging it fails.
    channelState.email = { threadIds: [['e1']] };
    channelState.linkedin = { items: [chatItem('linkedin', 'l1', '2026-08-31T09:00:00.000Z')] };
    channelState.whatsapp = { items: [chatItem('whatsapp', 'w1', '2026-08-31T08:00:00.000Z')] };
    channelState.slack = {
      items: [chatItem('slack', 's1', '2026-08-31T18:00:00.000Z')],
      hasNextPage: true,
      isError: true,
      retainsData: true,
    };

    const { result } = render();

    expect(result.current.failedChannels).toEqual(['slack']);
    // Without this, "Load more" is offered and does nothing on every press.
    expect(result.current.hasNextPage).toBe(false);
  });

  it('still offers more pages when a HEALTHY channel can supply them', () => {
    healthy();
    channelState.slack = { isError: true };
    channelState.linkedin = {
      items: [chatItem('linkedin', 'l1', '2026-08-31T09:00:00.000Z')],
      hasNextPage: true,
    };

    const { result } = render();

    expect(result.current.failedChannels).toEqual(['slack']);
    expect(result.current.hasNextPage).toBe(true);
  });

  it('DOES error when every participating channel fails — there is nothing to show', () => {
    channelState.email = { isError: true };
    channelState.linkedin = { isError: true };
    channelState.whatsapp = { isError: true };
    channelState.slack = { isError: true };

    const { result } = render();

    expect(result.current.isError).toBe(true);
    expect(result.current.isLoading).toBe(false);
    expect(result.current.items).toEqual([]);
  });

  it('still waits for a channel that is merely SLOW — only a failure settles early', () => {
    healthy();
    channelState.slack = { pending: true };

    const { result } = render();

    // A pending channel could still return an item of any timestamp, so no prefix is provably
    // stable and the feed is genuinely loading. This is the case the `settled` gate exists for,
    // and the partial-failure fix must not swallow it.
    expect(result.current.isLoading).toBe(true);
    expect(result.current.isError).toBe(false);
    expect(result.current.failedChannels).toEqual([]);
  });
});

describe('unified feed — the email source acts on `headChanged`', () => {
  it('trims the cached pages back to the first when the head shifts', async () => {
    healthy();
    channelState.email = { threadIds: [['e1']], headChanged: true };

    render();

    // Page-2+ cursors were minted against the old boundary; replaying them straddles the
    // shifted head. The unified feed asks as its own surface, so the server hands it its own
    // copy of the latch rather than letting the email list swallow it.
    await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));
  });

  it('does not fire when the head is steady', async () => {
    healthy();

    render();

    await waitFor(() => expect(mockReset).not.toHaveBeenCalled());
  });

  it('resets again when the QUERY changes, not just when the flag flips', async () => {
    healthy();
    channelState.email = { threadIds: [['e1']], headChanged: true };

    const { rerender } = render('inbox');
    await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));

    // Switching folder swaps the cache entry underneath a hook instance that survives the
    // switch. The new query's cached page 1 also says the head moved — and it is a DIFFERENT
    // head, on a different list, that nothing has trimmed yet. A latch that remembered only
    // the boolean would read "still true, no transition" and skip the reset this query needs.
    rerender({ f: 'archive' });

    await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(2));
  });

  it('still does not re-fire while the same query keeps saying the head moved', async () => {
    healthy();
    channelState.email = { threadIds: [['e1']], headChanged: true };

    const { rerender } = render('inbox');
    await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));

    rerender({ f: 'inbox' });
    rerender({ f: 'inbox' });

    // The inline reconcile is idempotent; re-firing on every render would loop.
    await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));
  });

  it('drops a thread that two overlapping pages both carry', () => {
    healthy();
    // The transient overlap a shifted head produces: `e2` is the last row of page 1 and the
    // first row of page 2. Both used to survive the flatten and the rep saw it twice.
    channelState.email = { threadIds: [['e1', 'e2'], ['e2', 'e3']] };

    const { result } = render();

    const emailIds = result.current.items
      .map((item) => item.id)
      .filter((id) => id.startsWith('email:'));
    expect(emailIds).toEqual(['email:e1', 'email:e2', 'email:e3']);
  });
});