/** * AI Software Factory — E2E API helper (typed wrappers over the public REST * API v1). Used by specs to set up/verify state without driving the UI for * bulk operations (e.g. seeding 100 rows) and to assert server-side state. * * Conventions: * - Every method takes an APIRequestContext (the `api` fixture) so the * caller controls the authenticated identity (demo user vs secondUser). * - Every method asserts a success status and returns the typed body; a * failed call throws with the response text included (useful in reports). * - Routes mirror apps/api controllers (Phase 13 public API v1 is the same * surface the web app uses internally). */ import type { APIRequestContext } from "@playwright/test"; import { expect, request as pwRequest } from "@playwright/test"; import type { BlockDto, CommentDto, PageDto, RecordDto, Workspace, } from "../fixtures"; /** API origin (kept in sync with fixtures.ts / playwright.config.ts). */ const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface UserDto { id: string; email: string; name: string; } // The real member-list response (workspaces.service.ts listMembers → // `{ data, nextCursor }`, each row `{ userId, email, name, displayName, // avatarUrl, role, joinedAt }`). The stale shape (id/workspaceId + a phantom // token) was part of D4 (TASK-0095 S9 repair). export interface WorkspaceMemberDto { userId: string; email: string; name: string; displayName: string; avatarUrl: string | null; role: "OWNER" | "ADMIN" | "MEMBER" | "GUEST"; joinedAt: string | null; } export interface InvitationDto { id: string; workspaceId: string; email: string; role: "OWNER" | "ADMIN" | "MEMBER" | "GUEST"; token: string; status: string; } export interface DatabaseDto { id: string; workspaceId: string; pageId: string | null; title: string; properties: { id: string; name: string; type: string; config?: unknown }[]; views: { id: string; name: string; type: string }[]; } export interface RecordQuery { filter?: unknown; sort?: unknown; group?: unknown; limit?: number; offset?: number; } export interface CommentReplyDto { id: string; commentId: string; body: string; createdBy: string; } export interface FileDto { id: string; workspaceId: string; name: string; size: number; mimeType: string; status: "UPLOADING" | "SCANNING" | "READY" | "VIRUS_DETECTED" | "FAILED"; /** 900 s presigned download URL (attachment disposition). */ downloadUrl?: string; /** 900 s presigned preview URL (inline disposition). */ previewUrl?: string; /** 900 s presigned thumbnail URL (image/video files only; null otherwise). */ thumbnailUrl?: string | null; expiresAt?: string; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- export async function ok( ctx: APIRequestContext, res: Awaited>, what: string, ): Promise { expect( res.status(), `${what} — expected success, got ${res.status()}: ${await res.text().catch(() => "")}`, ).toBeGreaterThanOrEqual(200); expect( res.status(), `${what} — expected 2xx, got ${res.status()}: ${await res.text().catch(() => "")}`, ).toBeLessThan(300); return (await res.json()) as T; } /** POST/PUT/PATCH returning the JSON body (asserts 2xx). */ export async function write( ctx: APIRequestContext, method: "POST" | "PUT" | "PATCH", url: string, data: unknown, what: string, headers?: Record, ): Promise { const res = await ctx[method.toLowerCase() as "post" | "put" | "patch"](url, { data, headers, }); return ok(ctx, res, what); } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- export async function register( ctx: APIRequestContext, email: string, password: string, name = "E2E User", ): Promise<{ user: UserDto }> { const res = await ctx.post("/api/v1/auth/register", { data: { email, password, name }, }); expect(res.status(), `register ${email}: ${await res.text()}`).toBe(201); return ok(ctx, res, "register"); } export async function login( ctx: APIRequestContext, email: string, password: string, ): Promise<{ accessToken?: string; refreshToken?: string }> { const res = await ctx.post("/api/v1/auth/login", { data: { email, password } }); expect(res.status(), `login ${email}: ${await res.text()}`).toBe(200); return ok(ctx, res, "login"); } export async function me(ctx: APIRequestContext): Promise { const body = await ok<{ user: UserDto }>(ctx, await ctx.get("/api/v1/auth/me"), "GET /me"); return body.user; } // --------------------------------------------------------------------------- // Workspaces // --------------------------------------------------------------------------- export async function createWorkspace( ctx: APIRequestContext, name: string, ): Promise { // The API returns the BARE workspace + role (workspaces.service.ts `create` // → `{ ...workspace, role: "OWNER" }`) — no wrapper (D4 repair, TASK-0095 S9). return write(ctx, "POST", "/api/v1/workspaces", { name }, "createWorkspace"); } export async function renameWorkspace( ctx: APIRequestContext, id: string, name: string, ): Promise { // Bare workspace (D4 repair — the PATCH route returns the updated workspace). return write(ctx, "PATCH", `/api/v1/workspaces/${id}`, { name }, "renameWorkspace"); } export async function deleteWorkspace(ctx: APIRequestContext, id: string): Promise { const res = await ctx.delete(`/api/v1/workspaces/${id}`); expect(res.status(), `deleteWorkspace: ${await res.text()}`).toBe(204); } export async function listMembers( ctx: APIRequestContext, workspaceId: string, ): Promise { // The API returns `{ data, nextCursor }` (listMembers is cursor-paginated) // (D4 repair — the helper previously read a `{ members }` wrapper). const body = await ok<{ data: WorkspaceMemberDto[] }>( ctx, await ctx.get(`/api/v1/workspaces/${workspaceId}/members`), "listMembers", ); return body.data; } /** * Issue an invitation through the public route. NOTE (D3): the API hashes the * token and emails it — the plaintext token is NEVER in the response, so this * returns the invitation row WITHOUT a usable token. Tests that must actually * bring a second user into a workspace use `addMemberViaSeam` instead. * (The route + the bare response shape are pinned here, D4 repair.) */ export async function inviteMember( ctx: APIRequestContext, workspaceId: string, email: string, role: "ADMIN" | "MEMBER" | "GUEST" = "MEMBER", ): Promise<{ id: string; email: string; role: string; expiresAt: string }> { return write<{ id: string; email: string; role: string; expiresAt: string }>( ctx, "POST", `/api/v1/workspaces/${workspaceId}/members/invites`, { email, role }, "inviteMember", ); } /** * The internal service token for the e2e stack (TASK-0095 S9). Set on the * API via .env (CI) / the local dev env; the seam is closed without it. * Throws a clear error if unset (a spec that needs the two-user seam but the * stack was not started with it — a configuration problem, not a skip). */ export function seamServiceToken(): string { const token = process.env.E2E_INTERNAL_SERVICE_TOKEN; if (!token) { throw new Error( "E2E_INTERNAL_SERVICE_TOKEN is not set — the two-user seam needs the API " + "started with INTERNAL_SERVICE_TOKEN + INTERNAL_TEST_SEAM=1 (see ci.yml e2e job).", ); } return token; } /** * Add a registered user to a workspace through the internal test seam * (TASK-0095 S9 — the D3 unblock). The e2e stack's API is started with * `INTERNAL_SERVICE_TOKEN` + `INTERNAL_TEST_SEAM=1` (see .github/workflows/ * ci.yml e2e job + .env), so `POST /internal/workspaces/:id/members` is open. * Authorization is DB-driven, so the membership takes effect immediately. */ export async function addMemberViaSeam( serviceToken: string, workspaceId: string, email: string, role: "ADMIN" | "MEMBER" | "GUEST" = "MEMBER", ): Promise { const res = await fetch(`${API_BASE}/internal/workspaces/${workspaceId}/members`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${serviceToken}` }, body: JSON.stringify({ email, role }), }); // This helper uses Node's global `fetch` (undici `Response`), where // `status` is a NUMBER property (not a method) — so `res.status` is the // correct, meaningful assertion (a 401/404 from a closed seam / unknown // user is a real number and fails `.toBe(201)`). The `res.status()` form // only applies to Playwright's `APIResponse`, which this is NOT. const text = await res.text(); expect( res.status, `addMemberViaSeam(${email}, ${role}): ${res.status} ${text}`, ).toBe(201); } // --------------------------------------------------------------------------- // AI test seam (TASK-0122) // // The API reads process.env ONLY (no dotenv) — in CI the e2e job env block // must carry AI_PROVIDER=mock + INTERNAL_SERVICE_TOKEN + INTERNAL_TEST_SEAM=1 // for these helpers to work (see .github/workflows/ci.yml). // --------------------------------------------------------------------------- /** * Arm a deterministic MID-stream provider outage on the next mock chat call * FOR `workspaceId` (the `/internal/workspaces/:id/ai/mock-fail` seam). * Exactly one request in that workspace fails — the SSE stream carries an * in-band `error` delta + terminal `done` — and the UI's Retry (a fresh * gateway call, fresh mock, same workspace) succeeds. Scoping by workspace * keeps it deterministic when Playwright's desktop + mobile projects run in * parallel against the same API (each test uses its own `freshWorkspace`). */ export async function armAiMockFailures( serviceToken: string, workspaceId: string, status: 429 | 500 | 502 | 503 | 504 = 500, deltas = 2, ): Promise { const res = await fetch(`${API_BASE}/internal/workspaces/${workspaceId}/ai/mock-fail`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${serviceToken}` }, body: JSON.stringify({ status, deltas }), }); const text = await res.text().catch(() => ""); expect( res.status, `armAiMockFailures(${workspaceId}, ${status}, ${deltas}): ${res.status} ${text}`, ).toBe(200); } /** * Set (or clear, via `null`) a workspace's monthly AI token budget — * `usageLimits.aiMonthlyTokens` (the `/internal/workspaces/:id/ai/usage-limits` * seam). Any limit < 2×maxOutputTokens (8192) trips the pre-stream 402 on a * fresh workspace (used + 2×4096 >= limit). `null` restores unlimited. */ export async function setAiBudgetLimit( serviceToken: string, workspaceId: string, tokens: number | null, ): Promise { const res = await fetch(`${API_BASE}/internal/workspaces/${workspaceId}/ai/usage-limits`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${serviceToken}` }, body: JSON.stringify({ aiMonthlyTokens: tokens }), }); const text = await res.text().catch(() => ""); expect( res.status, `setAiBudgetLimit(${workspaceId}, ${tokens}): ${res.status} ${text}`, ).toBe(200); } // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- export async function createPage( ctx: APIRequestContext, workspaceId: string, opts: { title?: string; parentId?: string | null } = {}, ): Promise { // The API returns the BARE page (page.service.ts `create` → // `{ ...page, databaseId, blockIds }`) — no wrapper (D4 repair). return write( ctx, "POST", `/api/v1/workspaces/${workspaceId}/pages`, { title: opts.title ?? "", parentId: opts.parentId ?? null }, "createPage", ); } export async function getBlocks( ctx: APIRequestContext, pageId: string, ): Promise { // The list endpoint returns the CURSOR envelope `{ data, nextCursor }` // (blocks.controller `list` → blocks.service `list`), NOT a `{ blocks }` // wrapper — the stale `body.blocks` read was undefined and broke every // consumer (final-flow step 5). Tolerate the wrapper too in case a future // endpoint re-wraps it. const body = await ok<{ data?: BlockDto[]; blocks?: BlockDto[]; nextCursor?: string | null }>( ctx, await ctx.get(`/api/v1/pages/${pageId}/blocks`), "getBlocks", ); return body.data ?? body.blocks ?? []; } // --------------------------------------------------------------------------- // Blocks // --------------------------------------------------------------------------- /** * Batch block operation (the /blocks:batch endpoint the editor uses for every * structural change — insert/update/move/delete in one round-trip). */ export async function batchBlocks( ctx: APIRequestContext, pageId: string, operations: Record[], ): Promise<{ blocks: BlockDto[]; version: number }> { const body = await write<{ blocks: BlockDto[]; version: number }>( ctx, "POST", `/api/v1/pages/${pageId}/blocks:batch`, { operations }, "batchBlocks", ); return body; } export async function createBlock( ctx: APIRequestContext, pageId: string, block: { type: string; content?: unknown; properties?: unknown; position?: string; parentId?: string | null; }, ): Promise { // `POST /pages/:pageId/blocks` returns the BARE block (block.service `create` // → `this.prisma.block.create`, no envelope). Tolerate a `{ block }` wrapper // too, in case a future endpoint re-wraps it. const body = await write<{ block?: BlockDto } & BlockDto>( ctx, "POST", `/api/v1/pages/${pageId}/blocks`, block, "createBlock", ); return (body.block ?? body) as BlockDto; } // --------------------------------------------------------------------------- // Databases // --------------------------------------------------------------------------- export async function createDatabase( ctx: APIRequestContext, workspaceId: string, opts: { title?: string; pageId?: string | null; properties?: { name: string; type: string; config?: unknown; isTitle?: boolean }[]; } = {}, ): Promise { const body = await write<{ database: DatabaseDto }>( ctx, "POST", `/api/v1/workspaces/${workspaceId}/databases`, { title: opts.title ?? "E2E DB", pageId: opts.pageId ?? null, properties: opts.properties, }, "createDatabase", ); return body.database; } export async function addProperty( ctx: APIRequestContext, databaseId: string, property: { name: string; type: string; config?: unknown }, ): Promise<{ id: string; name: string; type: string }> { const body = await write<{ property: { id: string; name: string; type: string } }>( ctx, "POST", `/api/v1/databases/${databaseId}/properties`, property, "addProperty", ); return body.property; } export async function createRecord( ctx: APIRequestContext, databaseId: string, properties: Record = {}, ): Promise { const body = await write<{ record: RecordDto }>( ctx, "POST", `/api/v1/databases/${databaseId}/records`, { properties }, "createRecord", ); return body.record; } /** * Query records with filter/sort/group (the view query endpoint). * Nested structures (filter/sort/group trees) are serialized to JSON strings * in the query params — the API accepts `filter={"op":"and",...}`. */ export async function queryRecords( ctx: APIRequestContext, databaseId: string, query: RecordQuery = {}, ): Promise<{ records: RecordDto[]; total: number }> { const params: Record = {}; for (const [k, v] of Object.entries(query)) { if (v === undefined) continue; params[k] = typeof v === "object" ? JSON.stringify(v) : String(v); } const body = await ok<{ records: RecordDto[]; total: number }>( ctx, await ctx.get(`/api/v1/databases/${databaseId}/records`, { params }), "queryRecords", ); return body; } // --------------------------------------------------------------------------- // Comments // --------------------------------------------------------------------------- export async function createComment( ctx: APIRequestContext, pageId: string, body: string, opts: { blockId?: string | null; mentionIds?: string[] } = {}, ): Promise { const c = await write<{ comment: CommentDto }>( ctx, "POST", `/api/v1/pages/${pageId}/comments`, { body, blockId: opts.blockId ?? null, mentionIds: opts.mentionIds ?? [] }, "createComment", ); return c.comment; } export async function replyToComment( ctx: APIRequestContext, commentId: string, body: string, ): Promise { const c = await write<{ reply: CommentReplyDto }>( ctx, "POST", `/api/v1/comments/${commentId}/replies`, { body }, "replyToComment", ); return c.reply; } export async function resolveComment(ctx: APIRequestContext, commentId: string): Promise { const c = await write<{ comment: CommentDto }>( ctx, "POST", `/api/v1/comments/${commentId}/resolve`, {}, "resolveComment", ); return c.comment; } // --------------------------------------------------------------------------- // Files // --------------------------------------------------------------------------- /** * PUT raw bytes to a presigned upload URL. The presign is authenticated by * the X-Amz-* query string alone; a Bearer `Authorization` header must NOT * accompany it — MinIO rejects a request carrying BOTH auth types ("request * has multiple authentication types"). The `api` fixture's fixed Bearer * header would leak into such a request, so this uses a bare, unauthenticated * context. */ export async function putPresigned( uploadUrl: string, bytes: Buffer | Uint8Array, contentType: string, ): Promise { const putCtx = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 }); try { const put = await putCtx.put(uploadUrl, { data: bytes as never, headers: { "Content-Type": contentType }, }); return put.status(); } finally { await putCtx.dispose(); } } /** * Upload through the Phase-9 presigned flow (the real contract): * POST /files/uploads → { upload: { file, uploadUrl, method, headers } } * PUT (raw bytes, EXACT signed Content-Type) * PUT /files/uploads/:id/complete → { id, status, verifiedBy } * * `workspaceId` is required: the uploads endpoint resolves the target * workspace from the `x-factory-workspace` header (it only falls back to the * JWT when the caller belongs to exactly one workspace, which a test user * generally does not). Resolves once the file reaches a terminal status * (READY / VIRUS_DETECTED / FAILED) by polling `GET /files/:id`, which also * carries the signed `downloadUrl` / `previewUrl`. * * Pass `maxWaitMs` to raise the poll window for large files (the worker scan * of a 500 MB object can exceed the default 30 s). */ export async function uploadFile( ctx: APIRequestContext, name: string, mimeType: string, bytes: Buffer | Uint8Array, workspaceId: string, maxWaitMs = 30_000, ): Promise { const session = await write<{ upload: { file: { id: string }; uploadUrl: string; method: string; headers: Record; }; }>( ctx, "POST", "/api/v1/files/uploads", { name, mimeType, size: bytes.length }, "upload-session", workspaceHeader(workspaceId), ); const fileId = session.upload.file.id; const putStatus = await putPresigned( session.upload.uploadUrl, bytes, session.upload.headers["Content-Type"] ?? mimeType, ); expect(putStatus, `presigned PUT returned ${putStatus}`).toBeLessThan(300); await write<{ id: string; status: FileDto["status"] }>( ctx, "PUT", `/api/v1/files/uploads/${fileId}/complete`, {}, "file-complete", ); const deadline = Date.now() + maxWaitMs; for (;;) { const detail = await ok( ctx, await ctx.get(`/api/v1/files/${fileId}`, { headers: workspaceHeader(workspaceId) }), "file-detail", ); if (detail.status === "READY" || detail.status === "VIRUS_DETECTED" || detail.status === "FAILED") { return detail; } if (Date.now() >= deadline) { throw new Error(`uploadFile: ${name} still ${detail.status} after ${maxWaitMs} ms`); } await new Promise((r) => setTimeout(r, 1000)); } } /** The `x-factory-workspace` header the files endpoints resolve from. */ export function workspaceHeader(workspaceId: string): Record { return { "x-factory-workspace": workspaceId }; } /** * Fetch a file record (`GET /files/:id`) with the workspace header. * Use this for UI tests that need the signed `downloadUrl`/`previewUrl`. */ export async function getFile( ctx: APIRequestContext, fileId: string, workspaceId: string, ): Promise { return ok( ctx, await ctx.get(`/api/v1/files/${fileId}`, { headers: workspaceHeader(workspaceId) }), "getFile", ); } /** Poll until a file reaches `status` (default READY) or the deadline. */ export async function waitForFileStatus( ctx: APIRequestContext, fileId: string, workspaceId: string, status: FileDto["status"] = "READY", maxWaitMs = 45_000, ): Promise { const deadline = Date.now() + maxWaitMs; for (;;) { const detail = await getFile(ctx, fileId, workspaceId); if (detail.status === status) return detail; if (detail.status === "FAILED" || detail.status === "VIRUS_DETECTED") { throw new Error(`waitForFileStatus: ${fileId} ended ${detail.status}`); } if (Date.now() >= deadline) { throw new Error(`waitForFileStatus: ${fileId} still ${detail.status} after ${maxWaitMs} ms`); } await new Promise((r) => setTimeout(r, 1000)); } } /** * Open an upload session WITHOUT completing it — used to assert server-side * size/MIME rejections (FILE_TOO_LARGE 413, MIME_MISMATCH 415). Throws an * `Error` whose `status`/`code` carry the API's error envelope. */ export async function openUploadSession( ctx: APIRequestContext, workspaceId: string, name: string, mimeType: string, size: number, ): Promise<{ status: number; code: string; body: unknown }> { const res = await ctx.post("/api/v1/files/uploads", { data: { name, mimeType, size }, headers: workspaceHeader(workspaceId), }); const body = (await res.json().catch(() => ({}))) as { error?: { code?: string; message?: string }; }; return { status: res.status(), code: body.error?.code ?? "", body }; } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- export interface SearchResult { id: string; type: "page" | "record" | "database" | "file"; title: string; snippet?: string; } export async function search( ctx: APIRequestContext, query: string, filters: Record = {}, ): Promise<{ results: SearchResult[]; tookMs?: number }> { const body = await ok<{ results: SearchResult[]; tookMs?: number }>( ctx, await ctx.get("/api/v1/search", { params: { q: query, ...filters } }), "search", ); return body; }