import { expect, test } from "bun:test"; import { forEachDialect } from "@sttracker/db/testing"; import { defineCustomField } from "../src/custom-fields.ts"; import { addEngagement, listEngagements, softDeleteEngagement, updateEngagement, } from "../src/engagements.ts"; import { moveEntryToWork, softDeleteEntry, upsertEntry } from "../src/entries.ts"; import { NotFoundError, ValidationError } from "../src/errors.ts"; import { getHistory, restoreVersion } from "../src/history.ts"; import { makeUser, makeWork } from "./user-layer-fixtures.ts"; forEachDialect("engagements", (getDb) => { async function setup(mediaType = "book") { const db = getDb(); const userId = await makeUser(db); const entry = await upsertEntry(db, userId, await makeWork(db, { mediaType }), { status: "completed" }, "user"); return { db, userId, entry }; } test("engagement with year precision keeps '2019'", async () => { const { db, userId, entry } = await setup(); const eg = await addEngagement( db, userId, entry.id, { finishedAt: { value: "2019", precision: "year" }, platform: "Kindle" }, "user", ); expect(eg.finishedAt).toEqual({ value: "2019", precision: "year" }); expect(eg.startedAt).toBeNull(); expect(eg.fieldSources).toEqual({ finishedAt: "user", platform: "user" }); const row = await db.k.selectFrom("engagements").selectAll().where("id", "=", eg.id).executeTakeFirstOrThrow(); expect(row.finished_at).toBe("2019"); expect(row.finished_precision).toBe("year"); expect(row.started_at).toBeNull(); expect(row.started_precision).toBe("unknown"); const [listed] = await listEngagements(db, userId, entry.id); expect(listed!.finishedAt).toEqual({ value: "2019", precision: "year" }); }); test("partial dates must match their precision", async () => { const { db, userId, entry } = await setup(); const bad = [ { value: "2019-03", precision: "year" }, { value: "2019-02-30", precision: "day" }, { value: "", precision: "day" }, { value: "2019", precision: "bogus" }, ]; for (const d of bad) { await expect(addEngagement(db, userId, entry.id, { startedAt: d as never }, "user")).rejects.toThrow( ValidationError, ); } const ok = await addEngagement( db, userId, entry.id, { startedAt: { value: "2019-03-04T20:15:00Z", precision: "day" }, finishedAt: { value: "", precision: "unknown" } }, "user", ); expect(ok.startedAt).toEqual({ value: "2019-03-04T20:15:00Z", precision: "day" }); expect(ok.finishedAt).toBeNull(); }); test("updateEngagement touches only patched keys and writes history", async () => { const { db, userId, entry } = await setup(); const eg = await addEngagement(db, userId, entry.id, { rating: 70, notes: "great" }, "user"); const up = await updateEngagement(db, userId, eg.id, { rating: 90, progress: { page: 120 } }, "plugin:hardcover:c1"); expect(up.notes).toBe("great"); expect(up.rating).toBe(90); expect(up.progress).toEqual({ page: 120 }); expect(up.fieldSources).toEqual({ rating: "plugin:hardcover:c1", notes: "user", progress: "plugin:hardcover:c1" }); const [h] = await getHistory(db, userId, "engagement", eg.id); expect(h!.before).toEqual({ rating: 70, progress: {} }); expect(h!.after).toEqual({ rating: 90, progress: { page: 120 } }); await restoreVersion(db, userId, h!.id); const [back] = await listEngagements(db, userId, entry.id); expect(back!.rating).toBe(70); expect(back!.fieldSources.rating).toBe("user"); await expect(updateEngagement(db, userId, eg.id, { rating: -1 }, "user")).rejects.toThrow(ValidationError); await expect(updateEngagement(db, userId, eg.id, { bogus: 1 } as never, "user")).rejects.toThrow(ValidationError); }); test("engagement custom fields use engagement-level definitions", async () => { const { db, userId, entry } = await setup(); const entryLevel = await defineCustomField(db, userId, { name: "Mood", type: "text" }); const def = await defineCustomField(db, userId, { name: "Format", type: "single_select", options: ["audio"], level: "engagement" }); const eg = await addEngagement(db, userId, entry.id, { customFields: { [def.id]: "audio" } }, "user"); expect(eg.customFields).toEqual({ [def.id]: "audio" }); await expect( addEngagement(db, userId, entry.id, { customFields: { [entryLevel.id]: "x" } }, "user"), ).rejects.toThrow(ValidationError); }); test("softDeleteEngagement hides it; restore brings it back", async () => { const { db, userId, entry } = await setup(); const eg = await addEngagement(db, userId, entry.id, { platform: "Switch" }, "user"); await softDeleteEngagement(db, userId, eg.id, "user"); expect(await listEngagements(db, userId, entry.id)).toEqual([]); await expect(updateEngagement(db, userId, eg.id, { platform: "PC" }, "user")).rejects.toThrow(NotFoundError); const [del] = await getHistory(db, userId, "engagement", eg.id); await restoreVersion(db, userId, del!.id); expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([eg.id]); }); test("engagements sort by start date, undated last", async () => { const { db, userId, entry } = await setup(); const undated = await addEngagement(db, userId, entry.id, {}, "user"); const later = await addEngagement(db, userId, entry.id, { startedAt: { value: "2021-05", precision: "month" } }, "user"); const early = await addEngagement(db, userId, entry.id, { startedAt: { value: "2019", precision: "year" } }, "user"); expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([early.id, later.id, undated.id]); }); test("engagements of a soft-deleted entry cannot be changed", async () => { const { db, userId, entry } = await setup(); const eg = await addEngagement(db, userId, entry.id, {}, "user"); await softDeleteEntry(db, userId, entry.id, "user"); await expect(updateEngagement(db, userId, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError); await expect(softDeleteEngagement(db, userId, eg.id, "user")).rejects.toThrow(NotFoundError); }); test("restoring a re-parenting onto a soft-deleted entry is rejected", async () => { const { db, userId, entry } = await setup(); const other = await upsertEntry(db, userId, await makeWork(db, { title: "Other" }), {}, "user"); const eg = await addEngagement(db, userId, entry.id, {}, "user"); await moveEntryToWork(db, userId, entry.id, other.workId, "user"); const [h] = await getHistory(db, userId, "engagement", eg.id); expect(h!.before).toEqual({ entryId: entry.id }); // the merged-away entry is soft-deleted, so the engagement cannot go back to it const err = await restoreVersion(db, userId, h!.id).catch((e: unknown) => e); expect(err).toBeInstanceOf(ValidationError); expect(err).toMatchObject({ field: "entryId", reason: "target entry is deleted" }); }); test("another user's engagements are not found", async () => { const { db, userId, entry } = await setup(); const bob = await makeUser(db, "bob"); const eg = await addEngagement(db, userId, entry.id, {}, "user"); await expect(addEngagement(db, bob, entry.id, {}, "user")).rejects.toThrow(NotFoundError); await expect(updateEngagement(db, bob, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError); await expect(softDeleteEngagement(db, bob, eg.id, "user")).rejects.toThrow(NotFoundError); await expect(listEngagements(db, bob, entry.id)).rejects.toThrow(NotFoundError); }); });