import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { studioFeature } from "../src/server/feature"; import { SimulatedCrash } from "../src/server/convert"; import { createStudioStore } from "../src/server/store"; import { REPO, setupApp } from "./app-helpers"; import { makeImportedCourse, VIDEO, videoBytes } from "./import-fixture"; let env: Awaited>; afterEach(() => env?.stop()); const PG = process.env.STC_TEST_PG_URL; const dbs: [string, string][] = [["sqlite", "sqlite://:memory:"], ...(PG ? [["postgres", PG] as [string, string]] : [])]; async function convert(as = "owner", id = "imp") { const r = await env.call(as, "POST", `/api/studio/courses/${id}/convert`); expect(r.status).toBe(202); const job = await env.waitJob(r.json.jobId); expect(job.error).toBeNull(); expect(job.status).toBe("done"); return job; } describe.each(dbs)("convert routes [%s]", (_name, dbUrl) => { test("the owner converts; the course is then served from the published worktree, still restricted, ACL kept", async () => { env = await setupApp([], dbUrl, (d) => makeImportedCourse(d)); const { call, ctx, db } = env; await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('imp', 'user', 'u-lea')`; expect(ctx.courses.get("imp")?.root).toBe(join(env.dataDir, "courses/imp")); const ro = (await call("owner", "GET", "/api/studio/courses/imp")).json; expect(ro).toMatchObject({ readOnly: true, convertible: true }); expect((await call("ada", "GET", "/api/studio/courses/imp")).json).toMatchObject({ readOnly: true, convertible: false }); expect((await call("owner", "GET", "/api/studio/courses")).json.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: false, imported: true, convertible: true })); const job = await convert(); expect(job.result).toMatchObject({ courseId: "imp", media: 3, resumed: false }); const c = ctx.courses.get("imp")!; expect(c.root).toBe(join(env.dataDir, "worktrees/imp/published")); expect(c.status).toBe("ok"); expect(c.manifest?.visibility).toBe("restricted"); expect(ctx.courses.list().filter((x) => x.id === "imp")).toHaveLength(1); expect(await db.sql`SELECT principal_id FROM course_acl WHERE course_id = 'imp'`).toEqual([{ principal_id: "u-lea" }]); // media through core's route, Range included const r = await env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": "lea", range: "bytes=10-19" } })); expect(r.status).toBe(206); expect(new Uint8Array(await r.arrayBuffer())).toEqual(videoBytes(4096, 7).slice(10, 20)); // anonymous users still see nothing expect((await env.app.fetch(new Request(`http://x/api/media/imp/${encodeURI(VIDEO)}`))).status).toBe(404); // the studio now edits it const st = (await call("owner", "GET", "/api/studio/courses/imp")).json; expect(st.readOnly).toBe(false); expect(st.files).toContain(VIDEO); expect((await call("owner", "GET", "/api/studio/courses")).json.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: true })); }); test("an author edits and publishes a lesson title but cannot make the course public; the owner can", async () => { env = await setupApp([], dbUrl, (d) => makeImportedCourse(d)); const { call, ctx } = env; await convert(); let st = (await call("ada", "GET", "/api/studio/courses/imp")).json; const m = structuredClone(st.manifestRaw); m.modules[0].lessons[0].title = "Welcome aboard"; expect((await call("ada", "PUT", "/api/studio/courses/imp/files", { message: "Rename", manifest: m, manifestBaseHash: st.manifestHash })).status).toBe(200); expect((await call("ada", "POST", "/api/studio/courses/imp/publish")).json).toMatchObject({ ok: true, published: true }); expect(ctx.courses.get("imp")?.lessonIndex.get("l1")?.lesson.title).toBe("Welcome aboard"); st = (await call("ada", "GET", "/api/studio/courses/imp")).json; const pub = structuredClone(st.manifestRaw); pub.visibility = "public"; expect((await call("ada", "PUT", "/api/studio/courses/imp/files", { message: "Go public", manifest: pub, manifestBaseHash: st.manifestHash })).status).toBe(403); st = (await call("owner", "GET", "/api/studio/courses/imp")).json; expect((await call("owner", "PUT", "/api/studio/courses/imp/files", { message: "Go public", manifest: { ...st.manifestRaw, visibility: "public" }, manifestBaseHash: st.manifestHash })).status).toBe(200); expect((await call("owner", "POST", "/api/studio/courses/imp/publish")).status).toBe(200); expect(ctx.courses.get("imp")?.manifest?.visibility).toBe("public"); }); }); describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => { test("409 while an import job for the course is queued; the job itself re-checks", async () => { env = await setupApp([], dbUrl, (d) => makeImportedCourse(d)); // no importer feature in this app: its jobs stay queued await env.ctx.jobs.enqueue("import.code-repo", { kind: "code-repo", path: "/x", options: { courseId: "imp" } }); const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert"); expect(r.status).toBe(409); expect(r.json.error).toContain("an import job for this course is still running"); // a convert job that was queued anyway (before the import was) fails cleanly and changes nothing const id = await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } }); const j = await env.waitJob(id); expect(j.status).toBe("failed"); expect(j.error).toContain("an import job for this course is still running"); expect(existsSync(join(env.dataDir, "courses/imp/stcourse.yaml"))).toBe(true); expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false); // an import job for another course does not block await env.db.sql`DELETE FROM jobs WHERE kind = 'import.code-repo'`; await env.ctx.jobs.enqueue("import.transcribe", { courseId: "other" }); expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(202); }); test("server start recovers a conversion that crashed mid-move; learners see the course once", async () => { env = await setupApp([], dbUrl, (d) => makeImportedCourse(d)); const crashing = createStudioStore({ dataDir: env.dataDir, testHooks: { crashAt: "staging-copied", forceExdev: true } }); await expect(crashing.convertImported("imp", { name: "O", email: "o@x" })).rejects.toBeInstanceOf(SimulatedCrash); await env.ctx.courses.reload(); expect(env.ctx.courses.list().filter((c) => c.id === "imp").length).toBeLessThanOrEqual(1); // a restart: a new studio feature recovers before serving const restarted = studioFeature(env.ctx, { watch: false }); await restarted.ready; restarted.stop(); const loaded = env.ctx.courses.list().filter((c) => c.id === "imp"); expect(loaded).toHaveLength(1); expect(loaded[0]!.root).toBe(join(env.dataDir, "worktrees/imp/published")); expect(existsSync(join(env.dataDir, "courses/imp"))).toBe(false); const r = await env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": "owner", range: "bytes=0-9" } })); expect(r.status).toBe(206); // the requeued job (or the owner) retrying reports success expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409); expect(await env.feature.store.convertImported("imp", { name: "O", email: "o@x" })).toMatchObject({ resumed: true }); }); }); describe("convert route refusals", () => { test("only the owner; cross-site requests are refused; nothing changes", async () => { env = await setupApp([], undefined, (d) => makeImportedCourse(d)); for (const who of ["ada", "lea"]) expect((await env.call(who, "POST", "/api/studio/courses/imp/convert")).status).toBe(403); expect((await env.call(null, "POST", "/api/studio/courses/imp/convert")).status).toBe(401); const forged = await env.app.fetch(new Request("http://x/api/studio/courses/imp/convert", { method: "POST", headers: { "x-user": "owner", origin: "http://evil.example" } })); expect(forged.status).toBe(403); const noOrigin = await env.app.fetch(new Request("http://x/api/studio/courses/imp/convert", { method: "POST", headers: { "x-user": "owner" } })); expect(noOrigin.status).toBe(403); expect(existsSync(join(env.dataDir, "courses/imp/stcourse.yaml"))).toBe(true); expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false); expect(await env.ctx.jobs.list("studio.convert-import")).toEqual([]); }); test("overlay, studio and unknown courses; a second request while one is pending", async () => { env = await setupApp([resolve(REPO, "fixtures/courses")], undefined, (d) => makeImportedCourse(d)); expect((await env.call("owner", "POST", "/api/studio/courses/sample/convert")).status).toBe(409); expect((await env.call("owner", "POST", "/api/studio/courses/nope/convert")).status).toBe(404); await env.call("owner", "POST", "/api/studio/courses", { id: "c1", title: "C" }); expect((await env.call("owner", "POST", "/api/studio/courses/c1/convert")).status).toBe(409); await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } }); // whether the first job is still queued, running or already done, a second request is refused const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert"); expect(r.status).toBe(409); }); test("job status is owner-only and limited to studio jobs", async () => { env = await setupApp([], undefined, (d) => makeImportedCourse(d)); const { jobId } = (await env.call("owner", "POST", "/api/studio/courses/imp/convert")).json; expect((await env.call("ada", "GET", `/api/studio/jobs/${jobId}`)).status).toBe(403); await env.waitJob(jobId); const j = (await env.call("owner", "GET", `/api/studio/jobs/${jobId}`)).json; expect(j).toMatchObject({ id: jobId, status: "done", progress: 1, error: null }); const other = await env.ctx.jobs.enqueue("import.transcribe", { courseId: "x" }); expect((await env.call("owner", "GET", `/api/studio/jobs/${other}`)).status).toBe(404); }); test("a failed conversion reports the error and leaves the import as it was", async () => { env = await setupApp([], undefined, (d) => makeImportedCourse(d)); const manifest = join(env.dataDir, "courses/imp/stcourse.yaml"); // the route only checks that this is an imported course; the job validates the manifest fully await Bun.write(manifest, readFileSync(manifest, "utf8").replace("id: imp", "id: other")); const { jobId } = (await env.call("owner", "POST", "/api/studio/courses/imp/convert")).json; const j = await env.waitJob(jobId); expect(j.status).toBe("failed"); expect(j.error).toContain('"other"'); expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false); }); });