mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
test(web): add shared Playwright E2E helpers and search-space fixture
This commit is contained in:
parent
a2976ee0b6
commit
ae0caad292
10 changed files with 673 additions and 0 deletions
55
surfsense_web/tests/helpers/api/auth.ts
Normal file
55
surfsense_web/tests/helpers/api/auth.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { APIRequestContext } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Direct backend auth helper. Uses the same /auth/jwt/login endpoint the
|
||||
* UI uses; mirrors lib/apis/auth-api.service.ts.
|
||||
*
|
||||
* Returns a bearer token specs can attach to API calls when they don't
|
||||
* want to go through the browser. The browser-side auth (localStorage)
|
||||
* is set up separately by tests/auth.setup.ts.
|
||||
*/
|
||||
|
||||
export const BACKEND_URL =
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
const TEST_USER_EMAIL = process.env.PLAYWRIGHT_TEST_EMAIL || "test@surfsense.net";
|
||||
const TEST_USER_PASSWORD = process.env.PLAYWRIGHT_TEST_PASSWORD || "TestPassword123!";
|
||||
|
||||
export async function loginAsTestUser(request: APIRequestContext): Promise<string> {
|
||||
const response = await request.post(`${BACKEND_URL}/auth/jwt/login`, {
|
||||
form: {
|
||||
username: TEST_USER_EMAIL,
|
||||
password: TEST_USER_PASSWORD,
|
||||
grant_type: "password",
|
||||
},
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Login to ${BACKEND_URL}/auth/jwt/login failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
const { access_token } = (await response.json()) as { access_token: string };
|
||||
if (!access_token) {
|
||||
throw new Error("Backend response missing access_token");
|
||||
}
|
||||
return access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard auth headers for backend API calls. Optionally injects an
|
||||
* X-E2E-Scenario header that the test-only ScenarioMiddleware in
|
||||
* surfsense_backend/tests/e2e/run_backend.py reads to flip fake behavior.
|
||||
*/
|
||||
export function authHeaders(
|
||||
token: string,
|
||||
extra?: Record<string, string>
|
||||
): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(extra ?? {}),
|
||||
};
|
||||
}
|
||||
193
surfsense_web/tests/helpers/api/connectors.ts
Normal file
193
surfsense_web/tests/helpers/api/connectors.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import type { APIRequestContext } from "@playwright/test";
|
||||
import { authHeaders, BACKEND_URL } from "./auth";
|
||||
|
||||
export type ConnectorRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: Record<string, unknown>;
|
||||
last_indexed_at: string | null;
|
||||
is_indexable: boolean;
|
||||
};
|
||||
|
||||
export async function listConnectors(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
searchSpaceId: number
|
||||
): Promise<ConnectorRow[]> {
|
||||
const response = await request.get(
|
||||
`${BACKEND_URL}/api/v1/search-source-connectors?search_space_id=${searchSpaceId}`,
|
||||
{ headers: authHeaders(token) }
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`listConnectors failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
return Array.isArray(data) ? data : (data?.items ?? []);
|
||||
}
|
||||
|
||||
export async function getConnector(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
connectorId: number
|
||||
): Promise<ConnectorRow> {
|
||||
const response = await request.get(
|
||||
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{ headers: authHeaders(token) }
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`getConnector(${connectorId}) failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as ConnectorRow;
|
||||
}
|
||||
|
||||
export async function updateConnectorConfig(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
connectorId: number,
|
||||
config: Record<string, unknown>
|
||||
): Promise<ConnectorRow> {
|
||||
const response = await request.put(
|
||||
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{ headers: authHeaders(token), data: { config } }
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`updateConnectorConfig(${connectorId}) failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as ConnectorRow;
|
||||
}
|
||||
|
||||
export async function deleteConnector(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
connectorId: number
|
||||
): Promise<void> {
|
||||
const response = await request.delete(
|
||||
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{ headers: authHeaders(token) }
|
||||
);
|
||||
if (!response.ok() && response.status() !== 404) {
|
||||
throw new Error(
|
||||
`deleteConnector(${connectorId}) failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listComposioDriveFolders(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
connectorId: number,
|
||||
parentId?: string
|
||||
): Promise<{ items: Array<Record<string, unknown>> }> {
|
||||
const url = parentId
|
||||
? `${BACKEND_URL}/api/v1/connectors/${connectorId}/composio-drive/folders?parent_id=${encodeURIComponent(parentId)}`
|
||||
: `${BACKEND_URL}/api/v1/connectors/${connectorId}/composio-drive/folders`;
|
||||
const response = await request.get(url, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`listComposioDriveFolders(${connectorId}) failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as { items: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
export type IndexBody = {
|
||||
folders?: Array<{ id: string; name: string; mimeType: string }>;
|
||||
files?: Array<{ id: string; name: string; mimeType: string }>;
|
||||
indexing_options?: {
|
||||
max_files_per_folder?: number;
|
||||
incremental_sync?: boolean;
|
||||
include_subfolders?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function triggerIndex(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
connectorId: number,
|
||||
searchSpaceId: number,
|
||||
body: IndexBody
|
||||
): Promise<{ ok: true }> {
|
||||
const response = await request.post(
|
||||
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?search_space_id=${searchSpaceId}`,
|
||||
{ headers: authHeaders(token), data: body }
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`triggerIndex(${connectorId}) failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the OAuth flow for a Composio toolkit programmatically.
|
||||
*
|
||||
* Steps mirror what the UI does (see use-connector-dialog.ts):
|
||||
* 1) GET /api/v1/auth/composio/connector/add?space_id=&toolkit_id= -> { auth_url }
|
||||
* 2) Follow the auth_url (which the E2E fake makes same-origin so it
|
||||
* lands on the callback directly with ?connectedAccountId=...).
|
||||
* 3) Backend creates the connector and redirects to the frontend
|
||||
* success page.
|
||||
*
|
||||
* Returns the newly-created (or reconnected) connector row.
|
||||
*/
|
||||
export async function runComposioOAuth(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
searchSpaceId: number,
|
||||
toolkitId: "googledrive" | "gmail" | "googlecalendar" = "googledrive"
|
||||
): Promise<{
|
||||
authUrl: string;
|
||||
finalUrl: string;
|
||||
connector: ConnectorRow | null;
|
||||
}> {
|
||||
// Step 1: kick off OAuth, get auth_url.
|
||||
const initiateResp = await request.get(
|
||||
`${BACKEND_URL}/api/v1/auth/composio/connector/add?space_id=${searchSpaceId}&toolkit_id=${toolkitId}`,
|
||||
{
|
||||
headers: authHeaders(token),
|
||||
}
|
||||
);
|
||||
if (!initiateResp.ok()) {
|
||||
throw new Error(
|
||||
`composio initiate failed (${initiateResp.status()}): ${await initiateResp.text()}`
|
||||
);
|
||||
}
|
||||
const { auth_url } = (await initiateResp.json()) as { auth_url: string };
|
||||
if (!auth_url) {
|
||||
throw new Error("composio initiate response missing auth_url");
|
||||
}
|
||||
|
||||
// Step 2: follow the auth_url. The fake makes this same-origin and
|
||||
// pointing at the callback. Use maxRedirects=0 so we can inspect
|
||||
// the final redirect target manually.
|
||||
const callbackResp = await request.get(auth_url, {
|
||||
headers: authHeaders(token),
|
||||
maxRedirects: 0,
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
const location = callbackResp.headers().location ?? auth_url;
|
||||
|
||||
// Step 3: look up the resulting connector (if any).
|
||||
const connectors = await listConnectors(request, token, searchSpaceId);
|
||||
const composioType =
|
||||
toolkitId === "googledrive"
|
||||
? "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
|
||||
: toolkitId === "gmail"
|
||||
? "COMPOSIO_GMAIL_CONNECTOR"
|
||||
: "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR";
|
||||
const connector =
|
||||
connectors.find((c) => c.connector_type === composioType) ?? null;
|
||||
|
||||
return { authUrl: auth_url, finalUrl: location, connector };
|
||||
}
|
||||
40
surfsense_web/tests/helpers/api/documents.ts
Normal file
40
surfsense_web/tests/helpers/api/documents.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { APIRequestContext } from "@playwright/test";
|
||||
import { authHeaders, BACKEND_URL } from "./auth";
|
||||
|
||||
export type DocumentRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
document_type: string;
|
||||
status: { state?: string } | string;
|
||||
};
|
||||
|
||||
type Paginated<T> = {
|
||||
items?: T[];
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export async function listDocuments(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
searchSpaceId: number,
|
||||
limit = 100
|
||||
): Promise<DocumentRow[]> {
|
||||
const response = await request.get(
|
||||
`${BACKEND_URL}/api/v1/documents?search_space_id=${searchSpaceId}&limit=${limit}`,
|
||||
{ headers: authHeaders(token) }
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`listDocuments failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
const body = (await response.json()) as Paginated<DocumentRow> | DocumentRow[];
|
||||
return Array.isArray(body) ? body : (body.items ?? []);
|
||||
}
|
||||
|
||||
export function isDocumentReady(doc: DocumentRow): boolean {
|
||||
const state =
|
||||
typeof doc.status === "string" ? doc.status : doc.status?.state;
|
||||
return state === "ready" || state === "READY";
|
||||
}
|
||||
42
surfsense_web/tests/helpers/api/search-spaces.ts
Normal file
42
surfsense_web/tests/helpers/api/search-spaces.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { APIRequestContext } from "@playwright/test";
|
||||
import { authHeaders, BACKEND_URL } from "./auth";
|
||||
|
||||
export type SearchSpaceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export async function createSearchSpace(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
name: string,
|
||||
description = "E2E test search space"
|
||||
): Promise<SearchSpaceRow> {
|
||||
const response = await request.post(`${BACKEND_URL}/api/v1/searchspaces`, {
|
||||
headers: authHeaders(token),
|
||||
data: { name, description },
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`createSearchSpace failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as SearchSpaceRow;
|
||||
}
|
||||
|
||||
export async function deleteSearchSpace(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
id: number
|
||||
): Promise<void> {
|
||||
const response = await request.delete(`${BACKEND_URL}/api/v1/searchspaces/${id}`, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!response.ok() && response.status() !== 404) {
|
||||
// 404 is acceptable: the test may have already deleted the space.
|
||||
throw new Error(
|
||||
`deleteSearchSpace(${id}) failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue