- Media exclusions move from the editable root .gitignore to the repo's info/exclude, and dirtyFiles never counts files listed in media.json, so an author's or remote's .gitignore cannot block publish or Sync. Publish and Sync refuse commits that track a file over referenced media. - Importer jobs (code-repo, pdf-deck, transcribe) and the cover route run under the course lock now shared with the studio (core withCourseLock) and re-check the course inside it; after conversion they fail with "this course is now edited in the studio". Convert answers 409 while an import job for the course is queued or running, and the job re-checks. - Crash-safe conversion: the import folder is hidden (.converting-<id>) before the published worktree exists; cross-filesystem moves copy into a staging dir, fsync, verify size and sha256, then rename; the source is retired as .converted-<id>.<n>. Recovery runs at server start and before every convert; a re-run of a finished conversion reports success. The loader skips dot-folders. - Minor: no raw NUL bytes in source, link fallback only on EXDEV/EPERM/EMLINK, referenced media are not read whole by the editor, missing media are logged, mediaDir JSDoc, docs on per-author copies across filesystems. Co-Authored-By: Claude Opus 5.5 <[email protected]>
57 lines
3.2 KiB
TypeScript
57 lines
3.2 KiB
TypeScript
// 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 <dataDir>/courses/<id>.
|
|
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: "[email protected]" };
|
|
|
|
async function imported(env: Record<string, string> = {}) {
|
|
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);
|
|
});
|