// End to end for v1.1 S: import -> make editable in the studio -> edit -> publish, through the production feature set, // on SQLite and Postgres. The learner must see the change and keep progress and notes; media must still answer Range. // Synthetic material only (ffmpeg test pattern videos). import { describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "@stc/core"; import { makeVideo } from "../../../packages/feature-import/test/fixtures"; import { packageMigrations, productFeatures } from "../src/features"; import { startServer } from "../src/main"; import { PG, pgSchema } from "./pg"; const ORIGIN = "http://localhost:3000"; const cookieFrom = (r: Response) => r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; "); const CLIP1 = "01 Intro – Übersicht.mp4"; const CLIP2 = "02 Next step.mp4"; async function run(databaseUrl: string) { const dataDir = mkdtempSync(join(tmpdir(), "stc-e2e-si-")); const inbox = join(dataDir, "import-inbox"); const folder = join(inbox, "Synthetic Course"); mkdirSync(folder, { recursive: true }); await makeVideo(join(folder, CLIP1), 2); await makeVideo(join(folder, CLIP2), 1); const config = loadConfig({ STC_PORT: "0", STC_DATABASE_URL: databaseUrl, STC_SECRET: "e2e-secret-0123456789abcdef0123456789", STC_DATA_DIR: dataDir, STC_COURSE_DIRS: "", STC_PUBLIC_URL: ORIGIN, STC_IMPORT_ROOTS: inbox, }); const s = await startServer(config, productFeatures({ studioWatch: false }), { migrations: packageMigrations }); const base = s.server.url.toString().replace(/\/$/, ""); type Opts = { cookie?: string; body?: unknown; headers?: Record }; const call = (method: string, path: string, opts: Opts = {}) => { const headers: Record = { origin: ORIGIN, ...opts.headers }; if (opts.cookie) headers.cookie = opts.cookie; if (opts.body !== undefined) headers["content-type"] = "application/json"; return fetch(base + path, { method, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) }); }; const json = async (method: string, path: string, opts: Opts = {}) => (await call(method, path, opts)).json() as Promise; const signUp = async (email: string, name: string) => { const r = await call("POST", "/api/auth/sign-up/email", { body: { email, name, password: "correct horse battery" } }); expect(r.status).toBe(200); return cookieFrom(r); }; const waitJob = async (path: string, cookie: string) => { for (let i = 0; i < 600; i++) { const j = await json("GET", path, { cookie }); if (j.status === "done" || j.status === "failed") return j; await Bun.sleep(50); } throw new Error(`timeout waiting for ${path}`); }; try { const owner = await signUp("olive@example.org", "Olive Owner"); const lea = await signUp("lea@example.org", "Lea Learner"); const leaId = (await json("GET", "/api/me", { cookie: lea })).user.id as string; // 1. import a folder of videos (restricted by default) const imp = await call("POST", "/api/import/jobs", { cookie: owner, body: { kind: "video-folder", path: folder, options: { courseId: "synth", title: "Synthetic Course" } } }); expect(imp.status).toBe(202); const impId = ((await imp.json()) as { id: string }).id; let done: any = null; for (let i = 0; i < 600 && !done; i++) { const j = ((await json("GET", "/api/import/jobs", { cookie: owner })) as any[]).find((x) => x.id === impId); if (j && (j.status === "done" || j.status === "failed")) done = j; else await Bun.sleep(50); } expect(done?.error ?? null).toBeNull(); expect(done.status).toBe("done"); expect(existsSync(join(dataDir, "courses/synth/stcourse.yaml"))).toBe(true); // 2. the owner grants the learner access; the learner makes progress and writes a note expect((await call("PUT", "/api/admin/courses/synth/access", { cookie: owner, body: { acl: [{ principal_type: "user", principal_id: leaId }] } })).status).toBe(200); const before = await json("GET", "/api/courses/synth", { cookie: lea }); expect(before.modules[0].lessons.map((l: { id: string; title: string }) => [l.id, l.title])).toEqual([["l1", "Intro – Übersicht"], ["l2", "Next step"]]); expect((await call("PUT", "/api/courses/synth/progress/l1", { cookie: lea, body: { state: "done" } })).status).toBe(200); expect((await call("PUT", "/api/courses/synth/progress/l2", { cookie: lea, body: { state: "in-progress", positionSeconds: 1 } })).status).toBe(200); expect((await call("POST", "/api/courses/synth/notes", { cookie: lea, body: { lessonId: "l2", anchorKind: "video", anchorValue: 1, body: "Remember this bit" } })).status).toBe(201); const progressBefore = await json("GET", "/api/courses/synth/progress", { cookie: lea }); const notesBefore = await json("GET", "/api/courses/synth/notes", { cookie: lea }); expect(progressBefore.l1.state).toBe("done"); expect(notesBefore.map((n: { body: string }) => n.body)).toEqual(["Remember this bit"]); // 3. only the owner may convert; the learner's attempt changes nothing expect((await call("POST", "/api/studio/courses/synth/convert", { cookie: lea })).status).toBe(403); const ro = await json("GET", "/api/studio/courses/synth", { cookie: owner }); expect(ro).toMatchObject({ readOnly: true, imported: true, convertible: true }); const conv = await call("POST", "/api/studio/courses/synth/convert", { cookie: owner }); expect(conv.status).toBe(202); const job = await waitJob(`/api/studio/jobs/${((await conv.json()) as { jobId: string }).jobId}`, owner); expect(job.error).toBeNull(); expect(job.result).toMatchObject({ courseId: "synth", media: 2 }); expect(existsSync(join(dataDir, "courses/synth"))).toBe(false); expect(existsSync(join(dataDir, "studio/repos/synth.git/HEAD"))).toBe(true); const pubClip = join(dataDir, "worktrees/synth/published/media", CLIP1); expect(statSync(pubClip).ino).toBe(statSync(join(dataDir, "media/synth/media", CLIP1)).ino); // 4. edit a lesson title in the studio and publish const st = await json("GET", "/api/studio/courses/synth", { cookie: owner }); expect(st.readOnly).toBe(false); const m = structuredClone(st.manifestRaw); expect(m.visibility).toBe("restricted"); m.modules[0].lessons[0].title = "Welcome aboard"; expect((await call("PUT", "/api/studio/courses/synth/files", { cookie: owner, body: { message: "Rename the first lesson", manifest: m, manifestBaseHash: st.manifestHash } })).status).toBe(200); // drafts never reach learners expect((await json("GET", "/api/courses/synth", { cookie: lea })).modules[0].lessons[0].title).toBe("Intro – Übersicht"); const pub = await call("POST", "/api/studio/courses/synth/publish", { cookie: owner }); expect(pub.status).toBe(200); expect(await pub.json()).toMatchObject({ ok: true, published: true }); // 5. the learner sees the change and keeps progress and notes const after = await json("GET", "/api/courses/synth", { cookie: lea }); expect(after.modules[0].lessons.map((l: { title: string }) => l.title)).toEqual(["Welcome aboard", "Next step"]); expect(await json("GET", "/api/courses/synth/progress", { cookie: lea })).toEqual(progressBefore); expect(await json("GET", "/api/courses/synth/notes", { cookie: lea })).toEqual(notesBefore); expect(after.progress.l1.state).toBe("done"); // 6. media still play: Range gives 206, an unsatisfiable range 416; the URL is unchanged const lesson = await json("GET", "/api/courses/synth/lessons/l1", { cookie: lea }); expect(lesson.lesson.title).toBe("Welcome aboard"); expect(lesson.video.file).toBe(`media/${CLIP1}`); const size = statSync(pubClip).size; const part = await call("GET", lesson.video.url, { cookie: lea, headers: { range: "bytes=0-99" } }); expect(part.status).toBe(206); expect(part.headers.get("content-range")).toBe(`bytes 0-99/${size}`); expect((await part.arrayBuffer()).byteLength).toBe(100); expect((await call("GET", lesson.video.url, { cookie: lea, headers: { range: `bytes=${size + 10}-` } })).status).toBe(416); const full = await call("GET", lesson.video.url, { cookie: lea }); expect(full.status).toBe(200); expect((await full.arrayBuffer()).byteLength).toBe(size); // 7. still restricted: anonymous users and other accounts see nothing expect((await call("GET", "/api/courses/synth")).status).toBe(404); expect((await call("GET", lesson.video.url)).status).toBe(404); const other = await signUp("max@example.org", "Max Other"); expect((await call("GET", "/api/courses/synth", { cookie: other })).status).toBe(404); expect((await call("GET", "/api/media/synth/..%2F..%2Fmedia%2Fsynth%2Fstcourse.yaml", { cookie: lea })).status).toBe(404); } finally { await s.stop(); } } describe("studio import end to end", () => { test("import, make editable, edit, publish [sqlite]", () => run("sqlite://:memory:"), 120_000); if (PG) { test("import, make editable, edit, publish [postgres]", async () => { const db = await pgSchema(PG); try { await run(db.url); } finally { await db.drop(); } }, 120_000); } });