153 lines
9.2 KiB
TypeScript
153 lines
9.2 KiB
TypeScript
// 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);
|
||
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);
|
||
}
|
||
});
|