import { expect } from "bun:test"; import { forEachDb } from "./helpers"; import { attemptsRepo, jobsRepo, libraryRepo, notesRepo, progressRepo } from "../src"; forEachDb("progress upsert keeps one row", async (db) => { const p = progressRepo(db); await p.set("u", "c", "l1", "in-progress", 12.5); await p.set("u", "c", "l1", "done"); await p.set("u", "c", "l2", "in-progress", 3); await p.set("other", "c", "l1", "done"); const got = await p.get("u", "c"); expect(Object.keys(got).sort()).toEqual(["l1", "l2"]); expect(got.l1.state).toBe("done"); expect(got.l1.positionSeconds).toBeNull(); expect(got.l2.positionSeconds).toBe(3); expect(typeof got.l1.updatedAt).toBe("string"); await p.set("u", "c", "l3", "in-progress", 1234.567); expect((await p.get("u", "c")).l3.positionSeconds).toBe(1234.567); expect((await db.sql`SELECT COUNT(*) AS n FROM progress WHERE user_id = 'u'`)[0].n.toString()).toBe("3"); }); forEachDb("notes order puts slide page 2 before page 10", async (db) => { const n = notesRepo(db); const base = { userId: "u", courseId: "c", lessonId: "l" }; await n.create({ ...base, anchorKind: "slide", anchorValue: "10", deckId: "d", body: "ten" }); await n.create({ ...base, anchorKind: "slide", anchorValue: "2", deckId: "d", body: "two" }); await n.create({ ...base, anchorKind: "none", body: "plain" }); await n.create({ ...base, anchorKind: "video", anchorValue: "90.5", body: "video" }); await n.create({ ...base, lessonId: "other", anchorKind: "none", body: "elsewhere" }); const all = await n.list("u", "c", "l"); expect(all.map((x) => x.body)).toEqual(["plain", "two", "ten", "video"]); expect(all[1].deckId).toBe("d"); expect((await n.list("u", "c")).length).toBe(5); }); forEachDb("another user cannot update or delete a note", async (db) => { const n = notesRepo(db); const note = await n.create({ userId: "u", courseId: "c", lessonId: "l", anchorKind: "none", body: "mine" }); expect(await n.update(note.id, "intruder", "hacked")).toBeNull(); expect(await n.remove(note.id, "intruder")).toBe(false); expect((await n.list("u", "c", "l"))[0].body).toBe("mine"); expect((await n.update(note.id, "u", "edited"))?.body).toBe("edited"); expect(await n.remove(note.id, "u")).toBe(true); expect(await n.list("u", "c", "l")).toEqual([]); }); forEachDb("attempts round-trip", async (db) => { const a = attemptsRepo(db); expect(await a.get("u", "c", "l", "py")).toBeNull(); const att = { userId: "u", courseId: "c", lessonId: "l", track: "py", code: "print(1)", result: { out: "1", tests: [true] }, passed: true, updatedAt: "2026-01-01T00:00:00.000Z" }; await a.save(att); expect(await a.get("u", "c", "l", "py")).toEqual(att); await a.save({ ...att, code: "x", result: null, passed: false }); const again = await a.get("u", "c", "l", "py"); expect(again).toMatchObject({ code: "x", result: null, passed: false }); }); forEachDb("featured course and collections keep their order", async (db) => { const l = libraryRepo(db); expect(await l.getFeatured()).toBeNull(); await l.setFeatured("a"); await l.setFeatured("b"); expect(await l.getFeatured()).toBe("b"); await l.setFeatured(null); expect(await l.getFeatured()).toBeNull(); await l.saveCollection({ id: "c2", title: "Second", sort: 2, courseIds: ["z", "y"] }); await l.saveCollection({ id: "c1", title: "First", sort: 1, courseIds: ["b", "a", "c"] }); expect(await l.listCollections()).toEqual([ { id: "c1", title: "First", sort: 1, courseIds: ["b", "a", "c"] }, { id: "c2", title: "Second", sort: 2, courseIds: ["z", "y"] }, ]); await l.saveCollection({ id: "c1", title: "First!", sort: 3, courseIds: ["c", "a"] }); expect((await l.listCollections()).map((c) => [c.id, c.courseIds])).toEqual([["c2", ["z", "y"]], ["c1", ["c", "a"]]]); await l.deleteCollection("c2"); expect((await l.listCollections()).map((c) => c.id)).toEqual(["c1"]); expect((await db.sql`SELECT COUNT(*) AS n FROM collection_courses`)[0].n.toString()).toBe("2"); }); forEachDb("jobs lifecycle", async (db) => { const j = jobsRepo(db); const id = await j.insert("import", { url: "x" }); const got = await j.get(id); expect(got).toMatchObject({ kind: "import", status: "queued", progress: 0, payload: { url: "x" }, result: null, error: null, message: null }); await j.claimNext(["import"]); await j.update(id, { progress: 0.5, message: "half" }); await j.update(id, { status: "done", result: { ok: 1 } }); expect(await j.get(id)).toMatchObject({ status: "done", progress: 0.5, message: "half", result: { ok: 1 } }); await j.insert("other", {}); expect((await j.list()).length).toBe(2); expect((await j.list("import")).map((x) => x.id)).toEqual([id]); expect(await j.get("missing")).toBeNull(); }); forEachDb("claimNext returns null when nothing is queued", async (db) => { const j = jobsRepo(db); expect(await j.claimNext(["import"])).toBeNull(); await j.insert("other", {}); expect(await j.claimNext(["import"])).toBeNull(); expect(await j.claimNext([])).toBeNull(); }); forEachDb("claimNext claims oldest first and marks running", async (db) => { const j = jobsRepo(db); const a = await j.insert("import", 1); await Bun.sleep(2); const b = await j.insert("import", 2); expect((await j.claimNext(["import"]))).toMatchObject({ id: a, status: "running" }); expect((await j.claimNext(["import"]))?.id).toBe(b); expect(await j.claimNext(["import"])).toBeNull(); }); forEachDb("two concurrent claimNext calls never claim the same job", async (db) => { const j = jobsRepo(db); const ids = [await j.insert("k", 1), await j.insert("k", 2)]; for (let round = 0; round < 1; round++) { const got = await Promise.all([j.claimNext(["k"]), j.claimNext(["k"])]); expect(got.every((x) => x !== null)).toBe(true); expect(new Set(got.map((x) => x!.id)).size).toBe(2); expect([...got.map((x) => x!.id)].sort()).toEqual([...ids].sort()); } // single job, two claimers: exactly one wins const solo = jobsRepo(db); await db.sql`DELETE FROM jobs`; const only = await solo.insert("k", 3); const res = await Promise.all([solo.claimNext(["k"]), solo.claimNext(["k"])]); expect(res.filter((x) => x !== null).map((x) => x!.id)).toEqual([only]); }); forEachDb("late progress update does not resurrect a done job", async (db) => { const j = jobsRepo(db); const id = await j.insert("k", {}); await j.claimNext(["k"]); await j.update(id, { status: "done", progress: 1, result: { ok: true } }); await j.update(id, { progress: 0.4, message: "stale" }); expect(await j.get(id)).toMatchObject({ status: "done", progress: 1, message: null, result: { ok: true } }); const f = await j.insert("k", {}); await j.update(f, { status: "failed", error: "boom" }); await j.update(f, { progress: 0.9 }); expect(await j.get(f)).toMatchObject({ status: "failed", error: "boom", progress: 0 }); }); forEachDb("update only touches patched columns", async (db) => { const j = jobsRepo(db); const id = await j.insert("k", { a: 1 }); await j.claimNext(["k"]); await j.update(id, { progress: 0.5, message: "working" }); await j.update(id, { message: "still" }); expect(await j.get(id)).toMatchObject({ status: "running", progress: 0.5, message: "still", result: null, error: null, payload: { a: 1 } }); await j.update(id, { result: { r: 1 } }); expect(await j.get(id)).toMatchObject({ status: "running", progress: 0.5, message: "still", result: { r: 1 } }); });