import { afterEach, expect, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { extGit } from "./helpers"; import { REPO, setupApp } from "./app-helpers"; let env: Awaited>; afterEach(() => env?.stop()); const lesson = (path: string) => `/api/studio/courses/c1/file?path=${encodeURIComponent(path)}`; test("acceptance: create a course, add and edit a lesson, publish, a learner sees it", async () => { env = await setupApp(); const { call, ctx } = env; expect((await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" })).status).toBe(201); expect(ctx.courses.get("c1")?.status).toBe("ok"); expect(ctx.courses.get("c1")?.root).toBe(join(env.dataDir, "worktrees/c1/published")); // add a lesson (new file + manifest entry, one commit) and make the course public const st = (await call("ada", "GET", "/api/studio/courses/c1")).json; expect(st.readOnly).toBe(false); const m = structuredClone(st.manifestRaw); m.visibility = "public"; m.modules[0].lessons.push({ id: "second", title: "Second lesson", type: "text", file: "lessons/second.md" }); let r = await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Add second lesson", manifest: m, manifestBaseHash: st.manifestHash, files: [{ path: "lessons/second.md", content: "# Second\n\nFirst draft.\n", baseHash: null }], }); expect(r.status).toBe(200); expect(r.json.commit).toBeTruthy(); // edit it const f = (await call("ada", "GET", lesson("lessons/second.md"))).json; expect(f.content).toContain("First draft."); r = await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Edit second", files: [{ path: "lessons/second.md", content: "# Second\n\nEdited text.\n", baseHash: f.hash }] }); expect(r.status).toBe(200); // drafts never reach learners expect(ctx.courses.get("c1")?.lessonIndex.has("second")).toBe(false); expect((await call(null, "GET", "/api/media/c1/lessons/second.md")).status).toBe(404); // publish r = await call("ada", "POST", "/api/studio/courses/c1/publish"); expect(r.status).toBe(200); expect(r.json).toMatchObject({ ok: true, published: true }); expect(await ctx.courses.readLessonMarkdown("c1", "second")).toBe("# Second\n\nEdited text.\n"); const media = await call(null, "GET", "/api/media/c1/lessons/second.md"); expect(media.status).toBe(200); expect(media.json).toContain("Edited text."); }); test("acceptance: a conflicting external commit is shown, not overwritten", async () => { env = await setupApp(); const { call, ctx, feature } = env; await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" }); const f = (await call("ada", "GET", lesson("lessons/welcome.md"))).json; await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Studio edit", files: [{ path: "lessons/welcome.md", content: "# Studio\n", baseHash: f.hash }] }); const pub = join(env.dataDir, "worktrees/c1/published"); writeFileSync(join(pub, "lessons/welcome.md"), "# External\n"); await extGit(pub, "commit", "-qam", "Edited in VS Code"); expect(await feature.watcher.check()).toEqual(["c1"]); expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); const st = (await call("ada", "GET", "/api/studio/courses/c1")).json; expect(st.status.behind).toBe(1); const r = await call("ada", "POST", "/api/studio/courses/c1/publish"); expect(r.status).toBe(409); expect(r.json).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); const u = await call("ada", "POST", "/api/studio/courses/c1/update-draft", { from: "published" }); expect(u.status).toBe(409); const c = (await call("ada", "GET", "/api/studio/courses/c1/conflicts")).json; expect(c.files[0]).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio\n", theirs: "# External\n" }); expect((await call("ada", "POST", "/api/studio/courses/c1/resolve", { files: [{ path: "lessons/welcome.md", content: "# Studio + External\n" }] })).json.remaining).toEqual([]); expect((await call("ada", "POST", "/api/studio/courses/c1/publish")).status).toBe(200); expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# Studio + External\n"); }); test("overlay and other outside courses are read-only", async () => { env = await setupApp([resolve(REPO, "fixtures/courses")]); const list = (await env.call("ada", "GET", "/api/studio/courses")).json; expect(list.courses).toContainEqual(expect.objectContaining({ id: "sample", managed: false, message: "This course is managed outside STCourses" })); const one = await env.call("ada", "GET", "/api/studio/courses/sample"); expect(one.json).toMatchObject({ readOnly: true, message: "This course is managed outside STCourses" }); const w = await env.call("ada", "PUT", "/api/studio/courses/sample/files", { message: "x", files: [{ path: "a.md", content: "x" }] }); expect(w.status).toBe(403); expect((await env.call("ada", "POST", "/api/studio/courses", { id: "sample", title: "Dup" })).status).toBe(409); });