import { describe, expect, test } from "bun:test"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadCourse } from "@stc/course-format"; import { contentHash } from "../src/server/store"; import { ada, bob, extGit, newStore } from "./helpers"; describe("createCourse", () => { test("makes a bare repo, a published worktree on main and a valid manifest", async () => { const { dataDir, store } = await newStore(); await store.createCourse({ id: "intro-git", title: "Intro: Git & you" }, ada); expect(existsSync(join(dataDir, "studio/repos/intro-git.git/HEAD"))).toBe(true); expect(existsSync(join(dataDir, "studio/repos/intro-git.git/stcourse.yaml"))).toBe(false); expect(existsSync(join(dataDir, "courses"))).toBe(false); // the importer's folder is left alone const pub = store.paths("intro-git").published; expect(pub).toBe(join(dataDir, "worktrees/intro-git/published")); expect(await extGit(pub, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main"); const loaded = await loadCourse(join(pub, "stcourse.yaml")); expect(loaded.status).toBe("ok"); expect(loaded.manifest?.title).toBe("Intro: Git & you"); expect(await extGit(pub, "log", "-1", "--format=%an <%ae>")).toBe("Ada Lovelace "); expect(store.isManaged("intro-git")).toBe(true); expect(await store.listManaged()).toEqual(["intro-git"]); }); test("rejects bad and duplicate ids", async () => { const { store } = await newStore(); await expect(store.createCourse({ id: "Bad Id", title: "x" }, ada)).rejects.toThrow(/id/); await store.createCourse({ id: "c1", title: "x" }, ada); await expect(store.createCourse({ id: "c1", title: "y" }, ada)).rejects.toThrow(/exists/); }); }); describe("drafts and saving", () => { test("a draft worktree per author on draft/; save commits as the author; published untouched", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const dir = await store.ensureDraft("c1", ada.id); expect(dir).toBe(store.paths("c1").draft(ada.id)); expect(await extGit(dir, "rev-parse", "--abbrev-ref", "HEAD")).toBe("draft/u-ada"); const cur = await store.readFile("c1", ada.id, "lessons/welcome.md"); expect(cur).not.toBeNull(); const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Hello draft\n", baseHash: cur!.hash }], "Edit welcome"); expect(r.ok).toBe(true); expect(await extGit(dir, "log", "-1", "--format=%an <%ae>|%s")).toBe("Ada Lovelace |Edit welcome"); expect(readFileSync(join(store.paths("c1").published, "lessons/welcome.md"), "utf8")).not.toContain("Hello draft"); const st = await store.status("c1", ada.id); expect(st.ahead).toBe(1); expect(st.behind).toBe(0); expect(st.merging).toBe(false); }); test("saving unchanged content makes no commit", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: cur.content, baseHash: cur.hash }], "noop"); expect(r).toEqual({ ok: true, commit: null }); }); test("an external edit between load and save is a conflict, not an overwrite", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; const file = join(store.paths("c1").draft(ada.id), "lessons/welcome.md"); writeFileSync(file, "edited in VS Code\n"); const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "studio text\n", baseHash: cur.hash }], "x"); expect(r.ok).toBe(false); if (r.ok) return; expect(r.conflicts).toEqual([{ path: "lessons/welcome.md", current: "edited in VS Code\n", currentHash: contentHash("edited in VS Code\n") }]); expect(readFileSync(file, "utf8")).toBe("edited in VS Code\n"); }); test("baseHash null means the file must not exist yet", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "x", baseHash: null }], "x"); expect(r.ok).toBe(false); }); test("paths with spaces and non-ASCII characters", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const p = "S08 - Containerizing/Æble notes ü.md"; const r = await store.saveFiles("c1", ada, [{ path: p, content: "æøå\n", baseHash: null }], "Add notes"); expect(r.ok).toBe(true); expect((await store.readFile("c1", ada.id, p))?.content).toBe("æøå\n"); expect(await store.listFiles("c1", ada.id)).toContain(p); }); test("binary content and deletion", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const bytes = new Uint8Array([0, 1, 2, 255]); expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", content: bytes }], "bin")).ok).toBe(true); expect(new Uint8Array(readFileSync(join(store.paths("c1").draft(ada.id), "media/a.bin")))).toEqual(bytes); expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", delete: true }], "rm")).ok).toBe(true); expect(await store.listFiles("c1", ada.id)).not.toContain("media/a.bin"); }); test("path traversal and .git are rejected", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); for (const bad of ["../x.md", "/etc/passwd", ".git/config", "a/../../x", "", "a\0b"]) { await expect(store.saveFiles("c1", ada, [{ path: bad, content: "x" }], "x")).rejects.toThrow(/path/i); } expect(await store.readFile("c1", ada.id, "../../../etc/passwd")).toBeNull(); }); test("authors get separate drafts; odd author ids are made safe", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const a = await store.ensureDraft("c1", ada.id); const b = await store.ensureDraft("c1", "bob/../x y"); expect(a).not.toBe(b); expect(await extGit(b, "rev-parse", "--abbrev-ref", "HEAD")).toMatch(/^draft\/[A-Za-z0-9_-]+$/); void bob; }); });