import { expect } from "bun:test"; import { join } from "node:path"; import type { AuthUser, ServerContext } from "@stc/contracts"; import { createApp, createJobQueue } from "@stc/core"; import { createCourseRepository } from "@stc/course-format"; import { forEachDb } from "../../db/test/helpers"; import { importFeature } from "../src"; import { makeVideo, tmp } from "./fixtures"; const owner: AuthUser = { id: "o", email: "o@x", name: "O", roles: ["owner"], groups: [] }; const learner: AuthUser = { id: "l", email: "l@x", name: "L", roles: ["learner"], groups: [] }; async function setup(db: any, env: Record = {}) { const dataDir = await tmp("stc-data-"); const courses = await createCourseRepository([join(dataDir, "courses")]); const ctx = { config: { port: 0, databaseUrl: "", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "" }, db, courses, auth: { userFromRequest: async (req: Request) => ({ owner, learner }[req.headers.get("x-user") ?? ""] ?? null) }, } as unknown as ServerContext; const feature = importFeature(ctx, { env: { STC_PYTHON: process.env.STC_PYTHON, ...env } }); const queue = createJobQueue(db, feature.jobs!, { pollMs: 10 }); ctx.jobs = queue; const app = createApp(ctx, [feature]); const call = (path: string, init: RequestInit & { as?: string; json?: unknown } = {}) => app.fetch(new Request(`http://x${path}`, { method: init.method, headers: { ...(init.as ? { "x-user": init.as } : {}), ...(init.json !== undefined ? { "content-type": "application/json" } : {}) }, body: init.json !== undefined ? JSON.stringify(init.json) : undefined, })); return { ctx, queue, call, dataDir }; } const until = async (f: () => Promise) => { for (let i = 0; i < 600; i++) { if (await f()) return; await Bun.sleep(20); } throw new Error("timeout"); }; forEachDb("only the owner reaches the import API", async (db) => { const { call } = await setup(db); expect((await call("/api/import/info")).status).toBe(401); expect((await call("/api/import/info", { as: "learner" })).status).toBe(403); expect((await call("/api/import/jobs", { as: "learner", method: "POST", json: {} })).status).toBe(403); const r = await call("/api/import/info", { as: "owner" }); expect(await r.json()).toMatchObject({ transcribe: false, coursesDirServed: true }); }); forEachDb("job creation validates input", async (db) => { const { call } = await setup(db); const post = async (json: unknown) => call("/api/import/jobs", { as: "owner", method: "POST", json }); expect((await post({ kind: "nope", path: "/tmp" })).status).toBe(400); expect((await post({ kind: "video-folder" })).status).toBe(400); expect((await post({ kind: "video-folder", path: "/definitely/not/here" })).status).toBe(400); expect((await post({ kind: "pdf-deck", path: "/tmp", options: { courseId: "x" } })).status).toBe(400); expect((await post({ kind: "code-repo", path: "/tmp" })).status).toBe(400); expect((await post({ kind: "transcribe", options: { courseId: "x" } })).status).toBe(409); }); forEachDb("a folder import runs through the real queue, then cover candidates can be chosen", async (db) => { const { call, queue, dataDir } = await setup(db); queue.start(); try { const src = join(await tmp(), "Queue Videos"); await makeVideo(join(src, "1 - a.mp4"), 1); await makeVideo(join(src, "2 - b.mp4"), 1); const r = await call("/api/import/jobs", { as: "owner", method: "POST", json: { kind: "video-folder", path: src, options: { title: "Queued", bogus: 1 } } }); expect(r.status).toBe(202); const { id } = (await r.json()) as { id: string }; await until(async () => ["done", "failed"].includes((await queue.get(id))!.status)); const job = (await queue.get(id))!; expect(job.error).toBeNull(); expect(job.status).toBe("done"); expect(job.result).toMatchObject({ courseId: "queued", warnings: [] }); expect(job.payload).toEqual({ kind: "video-folder", path: src, options: { title: "Queued" } }); const list = (await (await call("/api/import/jobs", { as: "owner" })).json()) as any[]; expect(list.map((j) => j.id)).toContain(id); const courses = (await (await call("/api/import/courses", { as: "owner" })).json()) as any[]; expect(courses).toEqual([{ id: "queued", title: "Queued", modules: [{ id: "m1", title: "Videos", lessons: [{ id: "l1", title: "a" }, { id: "l2", title: "b" }] }] }]); const covers = (await (await call("/api/import/covers/queued", { as: "owner" })).json()) as any; expect(covers.current).toBe("media/cover.jpg"); expect(covers.candidates).toEqual(["frame-1.jpg", "frame-2.jpg"]); const img = await call("/api/import/covers/queued/frame-2.jpg", { as: "owner" }); expect(img.headers.get("content-type")).toBe("image/jpeg"); expect((await call("/api/import/covers/queued/frame-2.jpg")).status).toBe(401); expect((await call("/api/import/covers/queued/..%2F..%2Fx.jpg", { as: "owner" })).status).toBe(404); const set = await call("/api/import/covers/queued", { as: "owner", method: "POST", json: { name: "frame-2.jpg" } }); expect(set.status).toBe(200); expect((await call("/api/import/covers/queued", { as: "owner", method: "POST", json: { name: "../evil.jpg" } })).status).toBe(400); expect(await Bun.file(join(dataDir, "courses/queued/media/cover.jpg")).exists()).toBe(true); } finally { await queue.stop(); } });