Files
STCourses/packages/db/test/repos.test.ts
T

153 lines
7.3 KiB
TypeScript

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 } });
});