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