Contracts and core: - Feature gains auth and stop; FeatureFactory; core composeServer builds the context, runs the factories, then the job queue from the union of their jobs (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI. - AppConfig/loadConfig own the layout and settings: courseDirs include <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir, importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python. resolveConfig derives the same for tests; cachePaths shares the importer's cache layout. - canView: the global author role views every course (R14); the learner, studio and AI packages all call it. - migrate(db, packages): core first, then packages in name order. - Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on an empty file is 416. - courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts. Packages: - import, studio and AI read AppConfig instead of process.env; the importer refuses ids used by studio/repos. - learner migrations run through core migrate; library covers fall back to the importer's cached candidate behind a canView-checked route. - studio preview renders through the learner LessonView (anchors honoured, draft URLs); AI drafts go to the studio draft branch for studio courses (as the requesting user) and to <dataDir>/drafts otherwise, and the generator page says which. Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts imports them by package name; nav.roles hides links by role. Tests: compose, config, canView, media headers, migrations order, covers, preview, nav, SPA bundle without server code, and an end-to-end flow on SQLite and Postgres. Co-Authored-By: Claude Opus 5.5 <[email protected]>
71 lines
4.0 KiB
TypeScript
71 lines
4.0 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { importPacktVideo } from "../src/jobs/packt-video";
|
|
import { makeEnv } from "./harness";
|
|
import { makePacktZip, tmp } from "./fixtures";
|
|
|
|
test("imports a Packt zip into a restricted course with sections, titles and media", async () => {
|
|
const env = await makeEnv();
|
|
const zip = await makePacktZip(await tmp());
|
|
const before = await readFile(zip);
|
|
const r = await importPacktVideo(env.deps, { kind: "packt-video", path: zip, options: { title: "Docker Test" } }, env.jc);
|
|
expect(r.courseId).toBe("docker-test");
|
|
expect(r.warnings).toEqual([]);
|
|
const c = env.deps.ctx.courses.get("docker-test")!;
|
|
expect(c.status).toBe("ok");
|
|
expect(c.diagnostics).toEqual([]);
|
|
const m = c.manifest!;
|
|
expect(m.visibility).toBe("restricted");
|
|
expect(m.source?.kind).toBe("packt-video");
|
|
expect(m.source?.origin).toBe("video_zip.zip");
|
|
expect(m.source?.importedAt).toBeTruthy();
|
|
expect(m.modules.map((x) => x.title)).toEqual(["Intro to Docker", "Images (Sample)"]);
|
|
expect(m.modules.map((x) => x.lessons.map((l) => l.title))).toEqual([["Course Introduction", "Docker & Friends"], ["Topic Introduction - Images"]]);
|
|
expect(m.modules[0]!.lessons[0]).toMatchObject({ id: "m1-l1", type: "video", file: "media/video1_1.mp4", duration: 2 });
|
|
expect(env.deps.ctx.courses.resolvePath("docker-test", "media/video2_1.mp4")).toBeTruthy();
|
|
expect(m.cover).toBe("media/cover.jpg");
|
|
expect((await readdir(join(env.dataDir, "cache/covers/docker-test"))).sort()).toEqual(["frame-1.jpg", "frame-2.jpg", "frame-3.jpg"]);
|
|
expect((await readdir(c.root)).sort()).toEqual(["media", "stcourse.yaml"]); // no extraction leftovers
|
|
expect(await readdir(join(env.dataDir, "tmp"))).toEqual([]); // scratch removed
|
|
expect(Buffer.compare(await readFile(zip), before)).toBe(0); // original untouched
|
|
expect(env.progress.at(-1)![0]).toBe(1);
|
|
});
|
|
|
|
test("second import of the same title gets a fresh id; transcribe requested without a URL warns", async () => {
|
|
const env = await makeEnv();
|
|
const zip = await makePacktZip(await tmp());
|
|
await importPacktVideo(env.deps, { path: zip, options: { title: "Same" } }, env.jc);
|
|
const r = await importPacktVideo(env.deps, { path: zip, options: { title: "Same", transcribe: true } }, env.jc);
|
|
expect(r.courseId).toBe("same-2");
|
|
expect(r.warnings.join()).toContain("STC_TRANSCRIBE_URL is not set");
|
|
expect(env.queued).toEqual([]);
|
|
});
|
|
|
|
test("a zip that is not a Packt package fails and leaves nothing behind", async () => {
|
|
const env = await makeEnv();
|
|
const d = await tmp();
|
|
await Bun.write(join(d, "t", "readme.txt"), "x");
|
|
const { zipDir } = await import("./fixtures");
|
|
await zipDir(join(d, "t"), join(d, "bad.zip"));
|
|
await expect(importPacktVideo(env.deps, { path: join(d, "bad.zip") }, env.jc)).rejects.toThrow(/index\.html/);
|
|
expect(await stat(join(env.dataDir, "courses")).catch(() => null)).toBeNull();
|
|
});
|
|
|
|
test("transcribe option queues the transcription job when a URL is configured", async () => {
|
|
const env = await makeEnv({ STC_TRANSCRIBE_URL: "http://127.0.0.1:1/v1/audio/transcriptions" });
|
|
const zip = await makePacktZip(await tmp());
|
|
const r = await importPacktVideo(env.deps, { path: zip, options: { transcribe: true } }, env.jc);
|
|
expect(env.queued).toEqual([{ kind: "import.transcribe", payload: { courseId: r.courseId } }]);
|
|
});
|
|
|
|
test("an id the studio already uses (studio/repos/<id>.git) is refused, and never picked automatically", async () => {
|
|
const { mkdir } = await import("node:fs/promises");
|
|
const env = await makeEnv();
|
|
await mkdir(join(env.deps.ctx.config.studioReposDir, "taken.git"), { recursive: true });
|
|
const zip = await makePacktZip(await tmp());
|
|
await expect(importPacktVideo(env.deps, { path: zip, options: { title: "X", courseId: "taken" } }, env.jc)).rejects.toThrow(/already exists/);
|
|
const r = await importPacktVideo(env.deps, { path: zip, options: { title: "Taken" } }, env.jc);
|
|
expect(r.courseId).toBe("taken-2");
|
|
});
|