Files
STCourses/apps/server/test/studio-import-e2e.test.ts
T

154 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, string> };
const call = (method: string, path: string, opts: Opts = {}) => {
const headers: Record<string, string> = { 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<any>;
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("[email protected]", "Olive Owner");
const lea = await signUp("[email protected]", "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("[email protected]", "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);
const pg = PG;
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);
}
});