import { resolve } from "node:path"; import type { AppConfig, AuthUser, JobQueue, ServerContext } from "@stc/contracts"; import { createApp, createJobQueue, resolveConfig } from "@stc/core"; import { createCourseRepository } from "@stc/course-format"; import { connect, migrate } from "@stc/db"; import { studioFeature } from "../src/server/feature"; import { studioCourseDirs } from "../src/server/paths"; import { tempDir } from "./helpers"; export const REPO = resolve(import.meta.dir, "../../.."); export const users: Record = { ada: { id: "u-ada", email: "ada@example.org", name: "Ada Lovelace", roles: ["author"], groups: [] }, owner: { id: "u-own", email: "own@example.org", name: "Olive Owner", roles: ["owner"], groups: [] }, lea: { id: "u-lea", email: "lea@example.org", name: "Lea Learner", roles: ["learner"], groups: [] }, }; /** A server context like main.ts builds, with header-selected users (`x-user: ada`). */ export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial = {}) { const dataDir = tempDir(); prepare?.(dataDir); let dropSchema = async () => {}; if (dbUrl.startsWith("postgres")) { // a private schema per test, like packages/db's forEachDb const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`; const admin = await connect(dbUrl); await admin.sql.unsafe(`CREATE SCHEMA ${schema}`); const u = new URL(dbUrl); u.searchParams.set("options", `-c search_path=${schema}`); dbUrl = u.toString(); dropSchema = async () => { await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); await admin.close(); }; } const db = await connect(dbUrl); await migrate(db); const config = resolveConfig({ port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x", ...configOverrides }); const courses = await createCourseRepository(studioCourseDirs(config)); const ctx = { config, db, courses, jobs: null as unknown as JobQueue, auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null }, } satisfies ServerContext; const feature = studioFeature(ctx, { watch: false }); // the real queue, as composeServer builds it from the features' jobs const queue = createJobQueue(db, feature.jobs ?? {}, { pollMs: 20 }); ctx.jobs = queue; queue.start(); const app = createApp(ctx, [feature]); const call = async (as: string | null, method: string, path: string, body?: unknown) => { const headers: Record = { origin: "http://x" }; if (as) headers["x-user"] = as; let payload: BodyInit | undefined; if (body instanceof FormData) payload = body; else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); } const r = await app.fetch(new Request(`http://x${path}`, { method, headers, body: payload })); const text = await r.text(); let json: any = null; try { json = JSON.parse(text); } catch { json = text; } return { status: r.status, json }; }; /** Polls a job until it is done or failed. */ const waitJob = async (id: string) => { for (let i = 0; i < 500; i++) { const j = await queue.get(id); if (j && (j.status === "done" || j.status === "failed")) return j; await Bun.sleep(20); } throw new Error(`job ${id} did not finish`); }; return { ctx, db, dataDir, feature, app, call, waitJob, stop: async () => { feature.stop(); await queue.stop(); await db.close(); await dropSchema(); } }; }