- 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>
232 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|