add-mcp-step.test.tsx6.9 KBView on GitHub
/**
 * The description a user types into the add-MCP form is applied to the row the OAuth
 * callback writes, and the two ends of that round trip do not agree on the server URL
 * string.
 *
 * `probeMcpServer` echoes back exactly what was typed; the callback stores the URL its
 * driver carries, which for Pylon has a trailing slash the user never typed. Matching
 * those as raw strings finds nothing, and the form used to close as though it had
 * worked, leaving a connection with the callback's generic auto-text. An entry with no
 * real description is one no agent reaches for, so this is silent data loss, not a
 * cosmetic miss.
 *
 * These tests pin both halves: the row IS found across that difference, and a genuine
 * miss is reported rather than swallowed.
 */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';

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

const PROBED_URL = 'https://mcp.usepylon.com';
/** What Cedar's callback actually persists: the driver's URL, trailing slash and all. */
const STORED_URL = 'https://mcp.usepylon.com/';

const mockProbeMcpServer = jest.fn();
const mockInitiateOAuth = jest.fn();
const mockUpdateMcpConnectionSettings = jest.fn();
const mockAddMcpConnection = jest.fn();
const mockUpsertCredential = jest.fn();
const mockUpsertOrgCredential = jest.fn();
const mockFetchQuery = jest.fn();
const mockToastError = jest.fn();

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

jest.mock('@tanstack/react-query', () => ({
  // The component only ever reads `mutateAsync` and `isPending`, and each call site
  // gets its options straight from the tRPC mock below, so handing back the mocked
  // mutationFn wires each button to its own spy.
  useMutation: (options: { mutationFn: (...args: unknown[]) => unknown }) => ({
    mutateAsync: options.mutationFn,
    isPending: false,
  }),
  useQueryClient: () => ({ fetchQuery: mockFetchQuery, invalidateQueries: jest.fn() }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    integrations: {
      probeMcpServer: { mutationOptions: () => ({ mutationFn: mockProbeMcpServer }) },
      addMcpConnection: { mutationOptions: () => ({ mutationFn: mockAddMcpConnection }) },
      updateMcpConnectionSettings: {
        mutationOptions: () => ({ mutationFn: mockUpdateMcpConnectionSettings }),
      },
      initiateOAuth: { mutationOptions: () => ({ mutationFn: mockInitiateOAuth }) },
      listMcpConnections: {
        queryOptions: () => ({ queryKey: ['listMcpConnections'] }),
      },
      list: { queryKey: () => ['integrations.list'] },
    },
    credentialVault: {
      upsert: { mutationOptions: () => ({ mutationFn: mockUpsertCredential }) },
      upsertOrg: { mutationOptions: () => ({ mutationFn: mockUpsertOrgCredential }) },
      list: { queryKey: () => ['credentialVault.list'] },
    },
  }),
}));

// Imported after the mocks so the component resolves them.
import { AddMcpStep } from '../add-mcp-step';

/** A popup handle the poll loop reads as still open. */
const openPopup = { closed: false } as unknown as Window;

function renderStep() {
  const onDone = jest.fn();
  const onOAuthOwnershipChange = jest.fn();
  render(
    <AddMcpStep
      isOrgAdmin={false}
      onOAuthOwnershipChange={onOAuthOwnershipChange}
      onDone={onDone}
    />,
  );
  return { onDone, onOAuthOwnershipChange };
}

/** The message the OAuth callback page posts back into this window. */
function postCallbackSuccess() {
  act(() => {
    window.dispatchEvent(
      new MessageEvent('message', {
        origin: window.location.origin,
        data: { type: 'mcp_oauth_connected', success: true },
      }),
    );
  });
}

/** Paste the URL, probe it, type a description and press Connect. */
async function walkToConnected(instructions: string) {
  fireEvent.change(screen.getByLabelText('MCP server URL'), { target: { value: PROBED_URL } });
  fireEvent.click(screen.getByRole('button', { name: 'Check' }));
  await screen.findByText('Ready to connect');

  fireEvent.change(screen.getByLabelText('When should the agent use this?'), {
    target: { value: instructions },
  });
  fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
  await waitFor(() => expect(mockInitiateOAuth).toHaveBeenCalled());

  postCallbackSuccess();
}

describe('AddMcpStep, applying the typed description after OAuth', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    window.open = jest.fn(() => openPopup);
    mockProbeMcpServer.mockResolvedValue({
      verdict: 'dcr',
      providerId: 'pylon',
      // The server echoes the input back verbatim.
      serverUrl: PROBED_URL,
      issuerHost: 'app.usepylon.com',
      scopes: [],
      redirectUri: null,
      discoveryError: null,
    });
    mockInitiateOAuth.mockResolvedValue({ oauthUrl: 'https://app.usepylon.com/oauth/authorize' });
    mockUpdateMcpConnectionSettings.mockResolvedValue({ success: true });
  });

  it('finds the row the callback wrote even though its URL carries a trailing slash', async () => {
    mockFetchQuery.mockResolvedValue({
      connections: [{ id: 'conn-1', name: 'Pylon', serverUrl: STORED_URL }],
    });

    const { onDone } = renderStep();
    await walkToConnected('Our support desk. Use it for ticket history.');

    await waitFor(() =>
      expect(mockUpdateMcpConnectionSettings).toHaveBeenCalledWith({
        connectionId: 'conn-1',
        instructions: 'Our support desk. Use it for ticket history.',
      }),
    );
    await waitFor(() => expect(onDone).toHaveBeenCalled());
  });

  it('ignores a callback for a sign-in it did not start, and reports when it owns one', async () => {
    mockFetchQuery.mockResolvedValue({ connections: [] });
    const { onDone, onOAuthOwnershipChange } = renderStep();

    // A Reconnect running in the list behind this dialog finishes. That message belongs
    // to the section, which has stood down only while THIS step has a flow of its own.
    postCallbackSuccess();
    expect(mockFetchQuery).not.toHaveBeenCalled();
    expect(onDone).not.toHaveBeenCalled();
    expect(mockToastError).not.toHaveBeenCalled();
    expect(onOAuthOwnershipChange).not.toHaveBeenCalledWith(true);

    await walkToConnected('Our support desk.');
    expect(onOAuthOwnershipChange).toHaveBeenCalledWith(true);
    await waitFor(() => expect(onOAuthOwnershipChange).toHaveBeenLastCalledWith(false));
  });

  it('says so when no row matches, rather than closing as though it saved', async () => {
    mockFetchQuery.mockResolvedValue({
      connections: [{ id: 'other', name: 'Notion', serverUrl: 'https://mcp.notion.com/sse' }],
    });

    renderStep();
    await walkToConnected('Our support desk.');

    await waitFor(() =>
      expect(mockToastError).toHaveBeenCalledWith(
        "Connected, but the description was not saved. Add it from the entry's Edit.",
      ),
    );
    expect(mockUpdateMcpConnectionSettings).not.toHaveBeenCalled();
  });
});