meeting-integration-card-oauth.test.tsx5.9 KBView on GitHub
/**
 * The window-focus handler that finishes a meeting recorder's OAuth flow, and the popup
 * that starts it.
 *
 * Three things it has to get right, each of which used to strand the user:
 *  - A recorder that authenticates over MCP must not be sent through Klavis `connect`.
 *    Which kind of flow it is is read from the pending record written when the flow
 *    STARTED, not from live query state, which is undefined mid-refetch.
 *  - Refocusing Cedar while the provider's tab is still open must not throw the pending
 *    record away, or finishing the sign-in for real afterwards does nothing at all.
 *  - A popup the browser blocks must clear `isConnecting`, because every control on the
 *    credential form is disabled by it and the card is otherwise inert until a reload.
 */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';

// --- Mocks -----------------------------------------------------------------

const PROVIDER = 'circleback';
const STORAGE_KEY=[redacted];

const mockConnect = jest.fn();
const mockInitiateOAuth = jest.fn();
const mockDeleteConnection = jest.fn();
const mockToastError = jest.fn();
/** Swapped per test to drive what `integrations.list` reports. */
const mockListState: { connected: boolean } = { connected: false };
const mockRefetch = jest.fn();

jest.mock('sonner', () => ({
  toast: {
    error: (...args: unknown[]) => mockToastError(...args),
    success: jest.fn(),
    info: jest.fn(),
  },
}));

// The manager is the connected-state UI and needs a backend of its own; nothing here
// exercises it.
jest.mock('../meeting-manager', () => ({ MeetingManager: () => null }));

jest.mock('@tanstack/react-query', () => ({
  useQuery: () => ({
    data: {
      integrations: [
        {
          id: 'circleback',
          connected: mockListState.connected,
          capabilities: {
            // A recorder whose fetch path genuinely reads the MCP token, so OAuth is its
            // only connect path and the main Connect button starts the flow. Granola was
            // the original fixture, but its OAuth alternative is no longer offered: it can
            // complete the flow and still cannot backfill with the resulting token.
            connectionType: 'oauth',

            usesMcpOAuth: true,
          },
        },
      ],
    },
    isLoading: false,
    refetch: mockRefetch,
  }),
  useMutation: (options: { mutationFn: (...args: unknown[]) => unknown }) => ({
    mutateAsync: options.mutationFn,
    isPending: false,
  }),
  useQueryClient: () => ({ invalidateQueries: jest.fn() }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    integrations: {
      list: { queryOptions: () => ({ queryKey: ['integrations.list'] }), queryKey: () => ['x'] },
      initiateOAuth: { mutationOptions: () => ({ mutationFn: mockInitiateOAuth }) },
      connect: { mutationOptions: () => ({ mutationFn: mockConnect }) },
    },
    connections: {
      delete: { mutationOptions: () => ({ mutationFn: mockDeleteConnection }) },
    },
  }),
}));

// Imported after the mocks so the component resolves them.
import { MeetingIntegrationCard } from '../meeting-integration-card';

/** jsdom's own sessionStorage, which is what the component reads. */
const pendingRecord = () => window.sessionStorage.getItem(STORAGE_KEY);

/** Start the flow with the card's Connect button, so `isConnecting` is true. */
async function startOAuth() {
  fireEvent.click(screen.getByRole('button', { name: /continue with circleback/i }));
  await waitFor(() => expect(mockInitiateOAuth).toHaveBeenCalled());
}

describe('MeetingIntegrationCard OAuth flow', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    window.sessionStorage.clear();
    mockListState.connected = false;
    window.open = jest.fn(() => ({ closed: false }) as unknown as Window);
    mockInitiateOAuth.mockResolvedValue({ oauthUrl: 'https://circleback.ai/api/oauth/authorize' });
    mockRefetch.mockImplementation(async () => ({
      data: {
        integrations: [{ id: 'circleback', connected: mockListState.connected }],
      },
    }));
  });

  it('records the MCP decision when the flow starts, so the callback never calls Klavis', async () => {
    render(<MeetingIntegrationCard providerId={PROVIDER} userFacing />);
    await startOAuth();

    expect(JSON.parse(pendingRecord() ?? '{}')).toMatchObject({ usesMcpOAuth: true });

    mockListState.connected = true;
    await act(async () => {
      window.dispatchEvent(new Event('focus'));
    });

    await waitFor(() => expect(pendingRecord()).toBeNull());
    expect(mockConnect).not.toHaveBeenCalled();
  });

  it('keeps the pending record when a refocus arrives before the sign-in finishes', async () => {
    render(<MeetingIntegrationCard providerId={PROVIDER} userFacing />);
    await startOAuth();

    // The provider's tab is still open, so the row is not connected yet.
    await act(async () => {
      window.dispatchEvent(new Event('focus'));
    });
    expect(pendingRecord()).not.toBeNull();

    // The user finishes and comes back for real.
    mockListState.connected = true;
    await act(async () => {
      window.dispatchEvent(new Event('focus'));
    });
    await waitFor(() => expect(pendingRecord()).toBeNull());
  });

  it('recovers rather than locking the card when the browser blocks the popup', async () => {
    window.open = jest.fn(() => null);
    render(<MeetingIntegrationCard providerId={PROVIDER} userFacing />);
    await startOAuth();

    await waitFor(() =>
      expect(mockToastError).toHaveBeenCalledWith(
        'Your browser blocked the sign-in window. Allow popups for Cedar and retry.',
      ),
    );
    expect(pendingRecord()).toBeNull();
    // `isConnecting` gates every control on this card, so a blocked popup that left it
    // set made the card unusable with no way back but a reload.
    await waitFor(() =>
      expect(screen.getByRole('button', { name: /continue with circleback/i })).not.toBeDisabled(),
    );
  });
});