test(e2e): import, make editable, edit, publish; learner keeps progress and notes, media keep Range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
20246b442f
commit
4a49e28fcc
@@ -0,0 +1,152 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user