Files
STCourses/packages/feature-studio/test/convert-routes.test.ts
T
Lucas WintherandClaude Opus 5.5 f02e30450c fix(studio): review round 2 - a conversion that fails after its commit recovers without a restart
- The convert job reloads the course on success and on failure, so learners
  get the intact import (rolled back) or the published studio course, never a
  course whose folder moved away.
- POST /convert on a course with an incomplete conversion (.converting-<id>,
  staging or retired folders) queues a job that recovers and finishes it
  instead of answering 409; the studio page shows "Conversion incomplete" with
  Retry for the owner.
- The 409 for pending import jobs names their kinds and ids; the dead withLock
  in git.ts is gone; the dot-folder discovery rule is documented in
  docs/operations.md and spec section 5.
- Route tests (SQLite and Postgres) inject a failed cross-filesystem copy, a
  checksum mismatch and a pre-commit failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:00:45 +01:00

232 lines
14 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { studioFeature } from "../src/server/feature";
import { SimulatedCrash } from "../src/server/convert";
import { createStudioStore } from "../src/server/store";
import { REPO, setupApp } from "./app-helpers";
import { makeImportedCourse, VIDEO, videoBytes } from "./import-fixture";
let env: Awaited<ReturnType<typeof setupApp>>;
afterEach(() => env?.stop());
const PG = process.env.STC_TEST_PG_URL;
const dbs: [string, string][] = [["sqlite", "sqlite://:memory:"], ...(PG ? [["postgres", PG] as [string, string]] : [])];
async function convert(as = "owner", id = "imp") {
const r = await env.call(as, "POST", `/api/studio/courses/${id}/convert`);
expect(r.status).toBe(202);
const job = await env.waitJob(r.json.jobId);
expect(job.error).toBeNull();
expect(job.status).toBe("done");
return job;
}
describe.each(dbs)("convert routes [%s]", (_name, dbUrl) => {
test("the owner converts; the course is then served from the published worktree, still restricted, ACL kept", async () => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
const { call, ctx, db } = env;
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('imp', 'user', 'u-lea')`;
expect(ctx.courses.get("imp")?.root).toBe(join(env.dataDir, "courses/imp"));
const ro = (await call("owner", "GET", "/api/studio/courses/imp")).json;
expect(ro).toMatchObject({ readOnly: true, convertible: true });
expect((await call("ada", "GET", "/api/studio/courses/imp")).json).toMatchObject({ readOnly: true, convertible: false });
expect((await call("owner", "GET", "/api/studio/courses")).json.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: false, imported: true, convertible: true }));
const job = await convert();
expect(job.result).toMatchObject({ courseId: "imp", media: 3, resumed: false });
const c = ctx.courses.get("imp")!;
expect(c.root).toBe(join(env.dataDir, "worktrees/imp/published"));
expect(c.status).toBe("ok");
expect(c.manifest?.visibility).toBe("restricted");
expect(ctx.courses.list().filter((x) => x.id === "imp")).toHaveLength(1);
expect(await db.sql`SELECT principal_id FROM course_acl WHERE course_id = 'imp'`).toEqual([{ principal_id: "u-lea" }]);
// media through core's route, Range included
const r = await env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": "lea", range: "bytes=10-19" } }));
expect(r.status).toBe(206);
expect(new Uint8Array(await r.arrayBuffer())).toEqual(videoBytes(4096, 7).slice(10, 20));
// anonymous users still see nothing
expect((await env.app.fetch(new Request(`http://x/api/media/imp/${encodeURI(VIDEO)}`))).status).toBe(404);
// the studio now edits it
const st = (await call("owner", "GET", "/api/studio/courses/imp")).json;
expect(st.readOnly).toBe(false);
expect(st.files).toContain(VIDEO);
expect((await call("owner", "GET", "/api/studio/courses")).json.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: true }));
});
test("an author edits and publishes a lesson title but cannot make the course public; the owner can", async () => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
const { call, ctx } = env;
await convert();
let st = (await call("ada", "GET", "/api/studio/courses/imp")).json;
const m = structuredClone(st.manifestRaw);
m.modules[0].lessons[0].title = "Welcome aboard";
expect((await call("ada", "PUT", "/api/studio/courses/imp/files", { message: "Rename", manifest: m, manifestBaseHash: st.manifestHash })).status).toBe(200);
expect((await call("ada", "POST", "/api/studio/courses/imp/publish")).json).toMatchObject({ ok: true, published: true });
expect(ctx.courses.get("imp")?.lessonIndex.get("l1")?.lesson.title).toBe("Welcome aboard");
st = (await call("ada", "GET", "/api/studio/courses/imp")).json;
const pub = structuredClone(st.manifestRaw);
pub.visibility = "public";
expect((await call("ada", "PUT", "/api/studio/courses/imp/files", { message: "Go public", manifest: pub, manifestBaseHash: st.manifestHash })).status).toBe(403);
st = (await call("owner", "GET", "/api/studio/courses/imp")).json;
expect((await call("owner", "PUT", "/api/studio/courses/imp/files", { message: "Go public", manifest: { ...st.manifestRaw, visibility: "public" }, manifestBaseHash: st.manifestHash })).status).toBe(200);
expect((await call("owner", "POST", "/api/studio/courses/imp/publish")).status).toBe(200);
expect(ctx.courses.get("imp")?.manifest?.visibility).toBe("public");
});
});
describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
test("409 while an import job for the course is queued; the job itself re-checks", async () => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
// no importer feature in this app: its jobs stay queued
await env.ctx.jobs.enqueue("import.code-repo", { kind: "code-repo", path: "/x", options: { courseId: "imp" } });
const blocker = await env.ctx.jobs.list("import.code-repo");
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(r.status).toBe(409);
expect(r.json.error).toContain("an import job for this course is still running");
expect(r.json.error).toContain(`import.code-repo ${blocker[0]!.id}`);
// a convert job that was queued anyway (before the import was) fails cleanly and changes nothing
const id = await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } });
const j = await env.waitJob(id);
expect(j.status).toBe("failed");
expect(j.error).toContain("an import job for this course is still running");
expect(existsSync(join(env.dataDir, "courses/imp/stcourse.yaml"))).toBe(true);
expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false);
// an import job for another course does not block
await env.db.sql`DELETE FROM jobs WHERE kind = 'import.code-repo'`;
await env.ctx.jobs.enqueue("import.transcribe", { courseId: "other" });
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(202);
});
test("server start recovers a conversion that crashed mid-move; learners see the course once", async () => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
const crashing = createStudioStore({ dataDir: env.dataDir, testHooks: { crashAt: "staging-copied", forceExdev: true } });
await expect(crashing.convertImported("imp", { name: "O", email: "o@x" })).rejects.toBeInstanceOf(SimulatedCrash);
await env.ctx.courses.reload();
expect(env.ctx.courses.list().filter((c) => c.id === "imp").length).toBeLessThanOrEqual(1);
// a restart: a new studio feature recovers before serving
const restarted = studioFeature(env.ctx, { watch: false });
await restarted.ready;
restarted.stop();
const loaded = env.ctx.courses.list().filter((c) => c.id === "imp");
expect(loaded).toHaveLength(1);
expect(loaded[0]!.root).toBe(join(env.dataDir, "worktrees/imp/published"));
expect(existsSync(join(env.dataDir, "courses/imp"))).toBe(false);
const r = await env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": "owner", range: "bytes=0-9" } }));
expect(r.status).toBe(206);
// the requeued job (or the owner) retrying reports success
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409);
expect(await env.feature.store.convertImported("imp", { name: "O", email: "o@x" })).toMatchObject({ resumed: true });
});
});
describe.each(dbs)("a conversion that fails without a restart [%s]", (_name, dbUrl) => {
const media = (as: string) => env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": as, range: "bytes=10-19" } }));
const learnerView = async (where: "import" | "studio") => {
const all = env.ctx.courses.list().filter((c) => c.id === "imp");
expect(all).toHaveLength(1);
expect(all[0]!.status).toBe("ok");
expect(all[0]!.root).toBe(join(env.dataDir, where === "import" ? "courses/imp" : "worktrees/imp/published"));
const r = await media("lea");
expect(r.status).toBe(206);
expect(new Uint8Array(await r.arrayBuffer())).toEqual(videoBytes(4096, 7).slice(10, 20));
};
const cases: [string, { forceExdev?: boolean; failAt?: string; corruptStaging?: boolean }, "import" | "studio", RegExp][] = [
["the cross-filesystem copy fails (disk full)", { forceExdev: true, failAt: "staging-copied" }, "studio", /injected failure at staging-copied/],
["the copied media fail their checksum", { forceExdev: true, corruptStaging: true }, "studio", /do not match/],
["the checkout fails before the commit point", { failAt: "worktree" }, "import", /injected failure at worktree/],
];
test.each(cases)("%s: learners keep a working course and the owner's retry completes", async (_c, hooks, after, error) => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d), {}, { storeTestHooks: hooks });
await env.db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('imp', 'user', 'u-lea')`;
await learnerView("import");
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(r.status).toBe(202);
const j = await env.waitJob(r.json.jobId);
expect(j.status).toBe("failed");
expect(j.error).toMatch(error);
// no restart: learners already get the intact import or the finished studio course
await learnerView(after);
if (after === "studio") {
const st = (await env.call("owner", "GET", "/api/studio/courses/imp")).json;
expect(st).toMatchObject({ readOnly: false, conversionIncomplete: true, canRetryConversion: true });
expect((await env.call("ada", "GET", "/api/studio/courses/imp")).json).toMatchObject({ conversionIncomplete: true, canRetryConversion: false });
} else {
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json).toMatchObject({ readOnly: true, convertible: true });
}
// the cause goes away (space freed, disk fixed); the owner retries through the same route
delete hooks.failAt;
delete hooks.corruptStaging;
expect((await env.call("ada", "POST", "/api/studio/courses/imp/convert")).status).toBe(403);
const again = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(again.status).toBe(202);
const j2 = await env.waitJob(again.json.jobId);
expect(j2.error).toBeNull();
expect(j2.status).toBe("done");
await learnerView("studio");
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json.conversionIncomplete).toBe(false);
expect(readdirSync(join(env.dataDir, "courses"))).toEqual([]);
expect(readdirSync(join(env.dataDir, "media"))).toEqual(["imp"]);
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409);
});
});
describe("convert route refusals", () => {
test("only the owner; cross-site requests are refused; nothing changes", async () => {
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
for (const who of ["ada", "lea"]) expect((await env.call(who, "POST", "/api/studio/courses/imp/convert")).status).toBe(403);
expect((await env.call(null, "POST", "/api/studio/courses/imp/convert")).status).toBe(401);
const forged = await env.app.fetch(new Request("http://x/api/studio/courses/imp/convert", { method: "POST", headers: { "x-user": "owner", origin: "http://evil.example" } }));
expect(forged.status).toBe(403);
const noOrigin = await env.app.fetch(new Request("http://x/api/studio/courses/imp/convert", { method: "POST", headers: { "x-user": "owner" } }));
expect(noOrigin.status).toBe(403);
expect(existsSync(join(env.dataDir, "courses/imp/stcourse.yaml"))).toBe(true);
expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false);
expect(await env.ctx.jobs.list("studio.convert-import")).toEqual([]);
});
test("overlay, studio and unknown courses; a second request while one is pending", async () => {
env = await setupApp([resolve(REPO, "fixtures/courses")], undefined, (d) => makeImportedCourse(d));
expect((await env.call("owner", "POST", "/api/studio/courses/sample/convert")).status).toBe(409);
expect((await env.call("owner", "POST", "/api/studio/courses/nope/convert")).status).toBe(404);
await env.call("owner", "POST", "/api/studio/courses", { id: "c1", title: "C" });
expect((await env.call("owner", "POST", "/api/studio/courses/c1/convert")).status).toBe(409);
await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } });
// whether the first job is still queued, running or already done, a second request is refused
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(r.status).toBe(409);
});
test("job status is owner-only and limited to studio jobs", async () => {
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
const { jobId } = (await env.call("owner", "POST", "/api/studio/courses/imp/convert")).json;
expect((await env.call("ada", "GET", `/api/studio/jobs/${jobId}`)).status).toBe(403);
await env.waitJob(jobId);
const j = (await env.call("owner", "GET", `/api/studio/jobs/${jobId}`)).json;
expect(j).toMatchObject({ id: jobId, status: "done", progress: 1, error: null });
const other = await env.ctx.jobs.enqueue("import.transcribe", { courseId: "x" });
expect((await env.call("owner", "GET", `/api/studio/jobs/${other}`)).status).toBe(404);
});
test("a failed conversion reports the error and leaves the import as it was", async () => {
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
const manifest = join(env.dataDir, "courses/imp/stcourse.yaml");
// the route only checks that this is an imported course; the job validates the manifest fully
await Bun.write(manifest, readFileSync(manifest, "utf8").replace("id: imp", "id: other"));
const { jobId } = (await env.call("owner", "POST", "/api/studio/courses/imp/convert")).json;
const j = await env.waitJob(jobId);
expect(j.status).toBe("failed");
expect(j.error).toContain('"other"');
expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false);
});
});