diff --git a/surfsense_web/tests/fixtures/connectors/clickup.fixture.ts b/surfsense_web/tests/fixtures/connectors/clickup.fixture.ts new file mode 100644 index 000000000..52f94fc5a --- /dev/null +++ b/surfsense_web/tests/fixtures/connectors/clickup.fixture.ts @@ -0,0 +1,28 @@ +import { type ConnectorRow, deleteConnector, runClickupOAuth } from "../../helpers/api/connectors"; +import { searchSpaceFixtures } from "../search-space.fixture"; + +export type ClickupFixtures = { + /** + * A pre-connected ClickUp connector inside the fixture's `searchSpace`. + * OAuth and MCP tool calls use E2E ClickUp fakes and are cleaned up + * automatically after the test. + */ + clickupConnector: ConnectorRow; +}; + +export const clickupFixtures = searchSpaceFixtures.extend({ + clickupConnector: async ({ request, apiToken, searchSpace }, use) => { + const { connector } = await runClickupOAuth(request, apiToken, searchSpace.id); + if (!connector) { + throw new Error( + "clickupConnector fixture: OAuth completed but no CLICKUP_CONNECTOR was created. " + + "Check the backend log — the ClickUp MCP fake likely rejected an unmodelled call." + ); + } + try { + await use(connector); + } finally { + await deleteConnector(request, apiToken, connector.id); + } + }, +}); diff --git a/surfsense_web/tests/fixtures/index.ts b/surfsense_web/tests/fixtures/index.ts index de3913c15..6f21b13f9 100644 --- a/surfsense_web/tests/fixtures/index.ts +++ b/surfsense_web/tests/fixtures/index.ts @@ -32,6 +32,8 @@ * └─ linearWithChatTest — chatThread * └─ jiraFixtures — jiraConnector * └─ jiraWithChatTest — chatThread + * └─ clickupFixtures — clickupConnector + * └─ clickupWithChatTest — chatThread * └─ slackFixtures — slackConnector * └─ slackWithChatTest — chatThread * @@ -45,6 +47,7 @@ export { chatThreadFixtures } from "./chat-thread.fixture"; export { composioCalendarFixtures } from "./connectors/composio-calendar.fixture"; export { composioDriveFixtures } from "./connectors/composio-drive.fixture"; export { composioGmailFixtures } from "./connectors/composio-gmail.fixture"; +export { clickupFixtures } from "./connectors/clickup.fixture"; export { confluenceFixtures } from "./connectors/confluence.fixture"; export { jiraFixtures } from "./connectors/jira.fixture"; export { linearFixtures } from "./connectors/linear.fixture"; @@ -61,6 +64,7 @@ import { type ChatThreadFixtures, chatThreadFixtures } from "./chat-thread.fixtu import { composioCalendarFixtures } from "./connectors/composio-calendar.fixture"; import { composioDriveFixtures } from "./connectors/composio-drive.fixture"; import { composioGmailFixtures } from "./connectors/composio-gmail.fixture"; +import { clickupFixtures } from "./connectors/clickup.fixture"; import { confluenceFixtures } from "./connectors/confluence.fixture"; import { jiraFixtures } from "./connectors/jira.fixture"; import { linearFixtures } from "./connectors/linear.fixture"; @@ -132,6 +136,10 @@ export const linearWithChatTest = linearFixtures.extend(chat export const jiraTest = jiraFixtures; /** `test` for Jira specs that also need a chat thread. */ export const jiraWithChatTest = jiraFixtures.extend(chatThreadFixtures); +/** `test` for specs that also need a pre-connected ClickUp connector. */ +export const clickupTest = clickupFixtures; +/** `test` for ClickUp specs that also need a chat thread. */ +export const clickupWithChatTest = clickupFixtures.extend(chatThreadFixtures); /** `test` for specs that also need a pre-connected Slack connector. */ export const slackTest = slackFixtures; /** `test` for Slack specs that also need a chat thread. */ diff --git a/surfsense_web/tests/helpers/api/connectors.ts b/surfsense_web/tests/helpers/api/connectors.ts index 9f0e1e31c..051b026e0 100644 --- a/surfsense_web/tests/helpers/api/connectors.ts +++ b/surfsense_web/tests/helpers/api/connectors.ts @@ -655,6 +655,56 @@ export async function runJiraOAuth( return { authUrl: auth_url, finalUrl: location, connector }; } +/** + * Drives the ClickUp MCP OAuth flow programmatically. + * + * The E2E backend keeps SurfSense's generic MCP OAuth routes real and + * patches ClickUp's external discovery/DCR/token/MCP tool boundaries. + */ +export async function runClickupOAuth( + request: APIRequestContext, + token: string, + searchSpaceId: number +): Promise<{ + authUrl: string; + finalUrl: string; + connector: ConnectorRow | null; +}> { + const initiateResp = await request.get( + `${BACKEND_URL}/api/v1/auth/mcp/clickup/connector/add?space_id=${searchSpaceId}`, + { headers: authHeaders(token) } + ); + if (!initiateResp.ok()) { + throw new Error( + `ClickUp MCP initiate failed (${initiateResp.status()}): ${await initiateResp.text()}` + ); + } + const { auth_url } = (await initiateResp.json()) as { auth_url: string }; + if (!auth_url) { + throw new Error("ClickUp MCP initiate response missing auth_url"); + } + + const state = new URL(auth_url).searchParams.get("state"); + if (!state) { + throw new Error(`ClickUp MCP auth_url missing state: ${auth_url}`); + } + + const callbackResp = await request.get( + `${BACKEND_URL}/api/v1/auth/mcp/clickup/connector/callback?code=fake-clickup-oauth-code&state=${encodeURIComponent(state)}`, + { + headers: authHeaders(token), + maxRedirects: 0, + failOnStatusCode: false, + } + ); + const location = callbackResp.headers().location ?? auth_url; + + const connectors = await listConnectors(request, token, searchSpaceId); + const connector = connectors.find((c) => c.connector_type === "CLICKUP_CONNECTOR") ?? null; + + return { authUrl: auth_url, finalUrl: location, connector }; +} + /** * Drives the Slack MCP OAuth flow programmatically. * diff --git a/surfsense_web/tests/helpers/canary.ts b/surfsense_web/tests/helpers/canary.ts index 167cd94d9..a9e745395 100644 --- a/surfsense_web/tests/helpers/canary.ts +++ b/surfsense_web/tests/helpers/canary.ts @@ -27,6 +27,7 @@ export const CANARY_TOKENS = { linearCanary: "SURFSENSE_E2E_CANARY_TOKEN_LINEAR_001", jiraCanary: "SURFSENSE_E2E_CANARY_TOKEN_JIRA_001", slackCanary: "SURFSENSE_E2E_CANARY_TOKEN_SLACK_001", + clickupCanary: "SURFSENSE_E2E_CANARY_TOKEN_CLICKUP_001", } as const; /** @@ -183,6 +184,19 @@ export const FAKE_SLACK_CHANNELS = { }, } as const; +/** + * Fake ClickUp task IDs that match what the backend MCP fake returns from + * clickup_search / clickup_get_task. + */ +export const FAKE_CLICKUP_TASKS = { + canary: { + id: "fake-clickup-task-canary-001", + name: "E2E Canary ClickUp Task", + workspaceId: "fake-clickup-workspace-001", + workspaceName: "SurfSense E2E ClickUp Workspace", + }, +} as const; + /** Generate a unique-per-run search space name. Keeps parallel tests isolated. */ export function uniqueSearchSpaceName(prefix = "e2e"): string { return `${prefix}-${randomUUID().slice(0, 8)}`;