inboxCreateRace.test.tsx4.4 KBView on GitHub
import { renderHook, act } from '@testing-library/react';

/**
 * Two sub-inboxes added back to back, which is what clicking through the template
 * gallery actually looks like.
 *
 * The failure this pins is not a rejected write — both creates succeed on the server.
 * It is the REFETCH each one used to fire on settle: create #1's refetch is issued
 * after #2 has already written its optimistic row, so the response (a server list
 * that predates #2) lands last and overwrites it. The row disappears, the template
 * card flips back to unselected, and the user clicks again — "adding an inbox doesn't
 * save", from the outside.
 */

type InboxRow = { id: string; name: string; position: number; rule: unknown };

// `mock`-prefixed so babel-jest lets the hoisted `jest.mock` factories close over them.
let mockCache: InboxRow[] = [];
const mockInvalidate = jest.fn();

type Lifecycle = {
  onMutate?: (vars: never) => Promise<unknown> | unknown;
  onSettled?: () => void;
  onError?: (e: unknown, vars: never, ctx: unknown) => void;
};

jest.mock('@tanstack/react-query', () => ({
  useQuery: (() => {
    let call = 0;
    return () =>
      call++ % 2 === 0
        ? { data: { settings: {} }, isLoading: false, isPending: false }
        : { data: mockCache, isLoading: false, isPending: false };
  })(),
  useMutation: (opts: Lifecycle) => {
    return {
      mutate: jest.fn(),
      // Resolve on a later microtask so a second create can interleave.
      mutateAsync: jest.fn(async (vars: never) => {
        const ctx = await opts?.onMutate?.(vars);
        await Promise.resolve();
        opts?.onSettled?.();
        return ctx;
      }),
    };
  },
  useQueryClient: () => ({
    getQueryData: () => mockCache,
    setQueryData: (_key: unknown, next: InboxRow[]) => {
      mockCache = next;
    },
    cancelQueries: jest.fn(),
    invalidateQueries: mockInvalidate,
    fetchQuery: jest.fn(),
  }),
}));

jest.mock('@/modules/auth/utils/auth-client', () => ({
  useSession: () => ({ data: { user: { id: 'u1' } } }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    settings: {
      get: { queryKey: () => ['settings'], queryOptions: () => ({}) },
      save: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
    },
    mail: {
      listInboxes: { queryKey: () => ['inboxes'], queryOptions: () => ({}) },
      createInbox: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
      updateInbox: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
      deleteInbox: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
      reorderInboxes: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
      previewSplitQuery: { queryOptions: () => ({}) },
      getSplitCounts: { queryKey: () => ['counts'] },
      listThreads: { infiniteQueryKey: () => ['threads'] },
    },
  }),
}));

import { useInboxes } from '@/modules/threads/hooks/use-inboxes';

beforeEach(() => {
  mockCache = [];
  mockInvalidate.mockClear();
});

describe('useInboxes — concurrent adds', () => {
  it('keeps both rows when two inboxes are added back to back', async () => {
    const { result } = renderHook(() => useInboxes());

    await act(async () => {
      await Promise.all([
        result.current.addInbox({ name: 'Calendar', rule: { kind: 'all' } }),
        result.current.addInbox({ name: 'Starred', rule: { kind: 'all' } }),
      ]);
    });

    expect(mockCache.map((i) => i.name).sort()).toEqual(['Calendar', 'Starred']);
  });

  it('gives each concurrent add its own position', async () => {
    const { result } = renderHook(() => useInboxes());

    await act(async () => {
      await Promise.all([
        result.current.addInbox({ name: 'Calendar', rule: { kind: 'all' } }),
        result.current.addInbox({ name: 'Starred', rule: { kind: 'all' } }),
      ]);
    });

    const positions = mockCache.map((i) => i.position).sort();
    expect(new Set(positions).size).toBe(positions.length);
  });

  it('refetches the list once, after the last create settles', async () => {
    const { result } = renderHook(() => useInboxes());

    await act(async () => {
      await Promise.all([
        result.current.addInbox({ name: 'Calendar', rule: { kind: 'all' } }),
        result.current.addInbox({ name: 'Starred', rule: { kind: 'all' } }),
      ]);
    });

    const inboxInvalidations = mockInvalidate.mock.calls.filter(
      ([arg]) => (arg as { queryKey?: string[] })?.queryKey?.[0] === 'inboxes',
    );
    expect(inboxInvalidations).toHaveLength(1);
  });
});