taskThreadPrefetchHooks.test.tsx5.8 KBView on GitHub
/**
 * The two preload entry points, held to the shape of the ones `/inbox` already has.
 *
 * Both failure modes are invisible in the UI — a preload that never fires just leaves the old
 * spinner, and one that fires too widely burns a request per task on a 500-task board — so the
 * behaviour is pinned here rather than left to be noticed.
 */
import React from 'react';
import { render } from '@testing-library/react';
import { usePrefetchTaskThreads, usePreloadTaskThreads } from '@/modules/userTasks/hooks/use-prefetch-task-threads';

const mockPrefetchThreads = jest.fn();
const mockPrefetchQuery = jest.fn();
const mockFetchQuery = jest.fn();
const mockBatchSetThreadData = jest.fn();
const mockShouldPrefetch = jest.fn(() => true);

jest.mock('@/modules/threads/hooks/use-prefetch-thread', () => ({
  usePrefetchThread: () => ({ prefetchThreads: (ids: string[]) => mockPrefetchThreads(ids) }),
  shouldPrefetch: () => mockShouldPrefetch(),
}));

jest.mock('@tanstack/react-query', () => ({
  useQueryClient: () => ({
    prefetchQuery: (...args: unknown[]) => mockPrefetchQuery(...args),
    fetchQuery: (...args: unknown[]) => mockFetchQuery(...args),
  }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    mail: { get: { queryOptions: (input: { id: string }) => ({ queryKey: ['mail.get', input.id] }) } },
    userTasks: {
      getTaskById: {
        queryOptions: (input: { taskId: string }) => ({ queryKey: ['getTaskById', input.taskId] }),
      },
    },
  }),
}));

jest.mock('@/modules/store', () => ({
  useCedarStore: (selector: (s: unknown) => unknown) => selector({ batchSetThreadData: mockBatchSetThreadData }),
}));

/** Mounts a hook so its effects run, with no DOM of its own. */
function Harness({ run }: { run: () => void }) {
  run();
  return null;
}

function emailTask(id: string, threadId: string) {
  return { id, taskOutput: { kind: 'email', threadId } };
}

beforeEach(() => {
  jest.clearAllMocks();
  mockShouldPrefetch.mockReturnValue(true);
  mockFetchQuery.mockResolvedValue(null);
});

describe('usePrefetchTaskThreads — hover', () => {
  function hover(task: Parameters<ReturnType<typeof usePrefetchTaskThreads>>[0]) {
    let prefetch: ReturnType<typeof usePrefetchTaskThreads> = () => {};
    render(<Harness run={() => { prefetch = usePrefetchTaskThreads(); }} />);
    prefetch(task);
  }

  it('pulls down the thread the task would open', () => {
    hover(emailTask('task-1', 'thread-1'));
    expect(mockPrefetchThreads).toHaveBeenCalledWith(['thread-1']);
  });

  it('pulls down the ticket query too — the ticket blocks on it before it renders anything', () => {
    // A task with no thread still opens something: preloading only bodies would move the spinner
    // rather than remove it.
    hover({ id: 'task-2', taskOutput: { kind: 'none' } });
    expect(mockPrefetchThreads).not.toHaveBeenCalled();
    expect(mockPrefetchQuery).toHaveBeenCalledWith(
      expect.objectContaining({ queryKey: ['getTaskById', 'task-2'] }),
    );
  });

  it('sends nothing speculative on a slow or data-saver connection', () => {
    mockShouldPrefetch.mockReturnValue(false);
    hover(emailTask('task-3', 'thread-3'));
    expect(mockPrefetchThreads).not.toHaveBeenCalled();
    expect(mockPrefetchQuery).not.toHaveBeenCalled();
  });
});

describe('usePreloadTaskThreads — the sweep after the list loads', () => {
  function load(tasks: unknown[] | undefined) {
    render(<Harness run={() => usePreloadTaskThreads(tasks as never)} />);
    jest.runOnlyPendingTimers();
  }

  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('fetches the bodies behind the loaded tasks', () => {
    load([emailTask('t1', 'thread-1'), emailTask('t2', 'thread-2')]);
    expect(mockFetchQuery).toHaveBeenCalledTimes(2);
    expect(mockFetchQuery).toHaveBeenCalledWith(
      expect.objectContaining({ queryKey: ['mail.get', 'thread-1'] }),
    );
    expect(mockFetchQuery).toHaveBeenCalledWith(
      expect.objectContaining({ queryKey: ['mail.get', 'thread-2'] }),
    );
  });

  it('stops at the first screenful — a board holds 500 tasks, not 20', () => {
    load(Array.from({ length: 40 }, (_, i) => emailTask(`t${i}`, `thread-${i}`)));
    expect(mockFetchQuery).toHaveBeenCalledTimes(20);
    expect(mockFetchQuery).not.toHaveBeenCalledWith(
      expect.objectContaining({ queryKey: ['mail.get', 'thread-20'] }),
    );
  });

  it('fetches a thread once even when several tasks share it', () => {
    // Several commitments off one email thread is the normal case, not an edge one.
    load([emailTask('t1', 'thread-1'), emailTask('t2', 'thread-1')]);
    expect(mockFetchQuery).toHaveBeenCalledTimes(1);
  });

  it('writes the bodies into the thread slice so the thread opens from the store', async () => {
    mockFetchQuery.mockResolvedValue({
      messages: [{ id: 'm1' }],
      latest: { id: 'm1' },
      hasUnread: false,
      totalReplies: 1,
      labels: [],
      conversationId: 'conv-1',
      trackingData: null,
    });
    load([emailTask('t1', 'thread-1')]);
    // The sweep hands each body through fetch → catch → Promise.all before it writes; flush the
    // whole chain rather than guessing at its length.
    for (let i = 0; i < 10; i++) await Promise.resolve();
    expect(mockBatchSetThreadData).toHaveBeenCalledWith(
      expect.objectContaining({ 'thread-1': expect.objectContaining({ conversationId: 'conv-1' }) }),
    );
  });

  it('does nothing before the list has loaded, or when no task names a thread', () => {
    load(undefined);
    load([{ id: 't1', taskOutput: { kind: 'none' } }]);
    expect(mockFetchQuery).not.toHaveBeenCalled();
  });

  it('sends nothing speculative on a slow or data-saver connection', () => {
    mockShouldPrefetch.mockReturnValue(false);
    load([emailTask('t1', 'thread-1')]);
    expect(mockFetchQuery).not.toHaveBeenCalled();
  });
});