// v1.1 S review fix: importer jobs and "Make editable in the studio" share the course lock. Either order is safe: // a job that started first finishes and its files are converted with the course; a job that comes second fails // cleanly and never recreates /courses/. import { expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { $ } from "bun"; import { loadAll } from "@stc/course-format"; import { createStudioStore } from "@stc/feature-studio"; import { STUDIO_OWNED } from "../src/context"; import { importCodeRepo } from "../src/jobs/code-repo"; import { importPacktVideo } from "../src/jobs/packt-video"; import { transcribeCourse } from "../src/jobs/transcribe"; import { makeEnv } from "./harness"; import { makePacktZip, makeRepoZip, tmp } from "./fixtures"; const owner = { name: "Olive Owner", email: "own@example.org" }; async function imported(env: Record = {}) { const e = await makeEnv(env); const r = await importPacktVideo(e.deps, { path: await makePacktZip(await tmp()), options: { title: "Race Test" } }, e.jc); const store = createStudioStore({ dataDir: e.dataDir }); const count = async () => (await loadAll([join(e.dataDir, "courses"), join(e.dataDir, "worktrees")])).filter((c) => c.id === r.courseId).length; return { e, id: r.courseId, store, count }; } test("an importer job that started first finishes; the conversion waits and commits its files", async () => { const { e, id, store, count } = await imported(); const zip = await makeRepoZip(await tmp()); const job = importCodeRepo(e.deps, { kind: "code-repo", path: zip, options: { courseId: id } }, e.jc); const conv = store.convertImported(id, owner); const [res] = await Promise.all([job, conv]); expect(res.courseId).toBe(id); const pub = store.paths(id).published; const files = (await $`git -c core.quotepath=false ls-files`.cwd(pub).quiet()).stdout.toString(); expect(files).toContain("code/S02 - Images (Sample)/app/main.py"); expect(await Bun.file(join(pub, "stcourse.yaml")).text()).toContain("code/S02 - Images (Sample)"); expect(existsSync(join(e.dataDir, "courses", id))).toBe(false); expect(await count()).toBe(1); }); test("an importer job that comes after the conversion fails cleanly and writes nothing", async () => { const { e, id, store, count } = await imported({ STC_TRANSCRIBE_URL: "http://127.0.0.1:9/never" }); const zip = await makeRepoZip(await tmp()); const conv = store.convertImported(id, owner); const job = importCodeRepo(e.deps, { kind: "code-repo", path: zip, options: { courseId: id } }, e.jc); const tr = transcribeCourse(e.deps, { courseId: id }, e.jc); const [c, j, t] = await Promise.allSettled([conv, job, tr]); expect(c.status).toBe("fulfilled"); expect(j.status === "rejected" && (j.reason as Error).message).toBe(STUDIO_OWNED); expect(t.status === "rejected" && (t.reason as Error).message).toBe(STUDIO_OWNED); expect(existsSync(join(e.dataDir, "courses", id))).toBe(false); expect(await count()).toBe(1); // and later ones too await expect(importCodeRepo(e.deps, { kind: "code-repo", path: zip, options: { courseId: id } }, e.jc)).rejects.toThrow(STUDIO_OWNED); });