/** * AI Software Factory — Integration seed helpers. * * Data fixtures for integration specs. Two rules keep these specs honest: * * 1. Prefer going THROUGH THE API (register → login → create workspace) * so the spec exercises the real authorization layer. `createUser` / * `createWorkspace` below do exactly that via the app's HTTP surface. * * 2. Use direct Prisma writes only for state the API cannot (or should * not) create on demand — e.g. forcing a WebhookDelivery row to * `PENDING`, backdating an audit log, or bulk-seeding 10k records for * a query test. Those helpers are clearly marked and must never be * used to bypass authorization (the spec's job is to prove the API * enforces it). */ import type { PrismaClient } from "@prisma/client"; export interface ApiRequest { ( method: string, urlPath: string, opts?: { body?: unknown; headers?: Record }, ): Promise; } /** Random unique email (collision-safe across shared-container runs). */ export function uniqueEmail(prefix = "integ"): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; } export function randomPassword(): string { return `pw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } /** * Register + log a user in through the API. Returns the user id and the * access token (specs that need to call as this user pass it as a Bearer * header; cookie-based flows use the response Set-Cookie via fetch's * `credentials: "include"`). */ export async function createUser( request: ApiRequest, opts?: { name?: string }, ): Promise<{ userId: string; email: string; password: string; accessToken?: string }> { const email = uniqueEmail(); const password = randomPassword(); const reg = await request("POST", "/api/v1/auth/register", { body: { email, password, name: opts?.name ?? "Integration User" }, }); if (reg.status !== 201 && reg.status !== 200) { throw new Error(`register failed (${reg.status}): ${await reg.text()}`); } const login = await request("POST", "/api/v1/auth/login", { body: { email, password }, }); if (login.status !== 200) { throw new Error(`login failed (${login.status}): ${await login.text()}`); } const body = (await login.json()) as { user?: { id: string }; accessToken?: string; }; return { userId: body.user?.id ?? "", email, password, accessToken: body.accessToken, }; } /** Bearer header for a user created by createUser(). */ export function authHeaders(accessToken: string): Record { return { Authorization: `Bearer ${accessToken}` }; } /** * Create a workspace through the API (the caller becomes OWNER). Returns * the workspace id + slug. */ export async function createWorkspace( request: ApiRequest, headers: Record, name = `Integ WS ${Date.now()}`, ): Promise<{ workspaceId: string; slug: string }> { const res = await request("POST", "/api/v1/workspaces", { headers, body: { name }, }); if (res.status !== 201 && res.status !== 200) { throw new Error(`createWorkspace failed (${res.status}): ${await res.text()}`); } // The API returns the BARE workspace + role (workspaces.service.ts // `create` → `{ ...workspace, role: "OWNER" }`) — no wrapper. (D4 repair, // TASK-0095 S9: this helper parsed a `{ workspace }` wrapper the API never // emits.) const body = (await res.json()) as { id: string; slug: string }; return { workspaceId: body.id, slug: body.slug }; } /** * Add a registered user to a workspace through the internal test seam * (TASK-0095 S9 — the D3 unblock). `createTestApp` configures * `INTERNAL_SERVICE_TOKEN` + `INTERNAL_TEST_SEAM=1`, so the in-process app * opens `POST /internal/workspaces/:id/members` (service-token guard, * fail-closed in production). Authorization is DB-driven, so the membership * takes effect immediately — no re-login of `member` needed. * * The public invite flow (D3) hashes the token and mails it, so an automated * test cannot complete it without a mail server; this seam is the recorded * test-only alternative (never reachable in production — see the seam's * module docs). */ export async function addMemberViaSeam( request: ApiRequest, internalServiceToken: string, workspaceId: string, email: string, role: "ADMIN" | "MEMBER" | "GUEST" = "MEMBER", ): Promise { const res = await request("POST", `/internal/workspaces/${workspaceId}/members`, { headers: { Authorization: `Bearer ${internalServiceToken}` }, body: { email, role }, }); if (res.status !== 201 && res.status !== 200) { throw new Error(`addMemberViaSeam failed (${res.status}): ${await res.text()}`); } } // --------------------------------------------------------------------------- // Direct-DB helpers (state the API cannot create on demand) // --------------------------------------------------------------------------- /** * Bulk-seed records into a database (bypasses the API on purpose — this is * a performance/fixture helper; the 10k-row test needs a seed the API loop * would take minutes to produce). Idempotency is NOT provided; call once. * * Each seeded record gets one RecordProperty (the database's TITLE property, * which always exists) with a stable, unique title. Records created this way * have pageId = null — specs that need record-backed pages create those * records through the API instead. */ export async function seedRecords( prisma: PrismaClient, databaseId: string, workspaceId: string, count: number, batchSize = 500, ): Promise { const titleProp = await prisma.databaseProperty.findFirst({ where: { databaseId, type: "TITLE" }, }); if (!titleProp) { throw new Error( `seedRecords: database ${databaseId} has no TITLE property — create the ` + "database via the API first (it always adds the title property).", ); } let inserted = 0; for (let offset = 0; offset < count; offset += batchSize) { const n = Math.min(batchSize, count - offset); // Positions are unique per database by construction, so they double as a // safe join key to pair the freshly-created records (ids are generated // by createMany and not returned) with their title property rows. const positions = Array.from({ length: n }, (_, i) => `a${String(offset + i).padStart(12, "0")}`, ); await prisma.databaseRecord.createMany({ data: positions.map((position) => ({ workspaceId, databaseId, position })), }); const created = await prisma.databaseRecord.findMany({ where: { databaseId, position: { in: positions } }, select: { id: true, position: true }, }); const props = created.map((rec) => ({ recordId: rec.id, propertyId: titleProp.id, value: { title: `Seeded record ${rec.position.slice(1)}` }, })); if (props.length) { await prisma.recordProperty.createMany({ data: props }); } inserted += n; } return inserted; } /** * Force a webhook delivery row to a terminal state (the API only advances * deliveries through the worker; specs verify the worker's effect by * inspecting the row it leaves behind). */ export async function markDelivery( prisma: PrismaClient, deliveryId: string, status: "DELIVERED" | "FAILED", ): Promise { await prisma.webhookDelivery.update({ where: { id: deliveryId }, data: { status, attemptedAt: new Date() }, }); } /** * Count audit events for an actor+action (spec asserts security-sensitive * operations were audited). */ export async function countAuditEvents( prisma: PrismaClient, where: { actorId?: string; action?: string; actionPrefix?: string }, ): Promise { return prisma.auditLog.count({ where: { ...(where.actorId ? { actorId: where.actorId } : {}), ...(where.action ? { action: where.action } : where.actionPrefix ? { action: { startsWith: where.actionPrefix } } : {}), }, }); }