Files
STTracker/packages/core/test/engagements.test.ts
Lucas WintherandClaude Opus 5.5 c6865b2522 fix(core): drill-down lists descendants under untracked intermediates
listEntries({ parentId: X }) now applies the top-level rule relative to X: this user's
live entries on descendants of X (up to three levels) with no live-entry work strictly
between them and X, so it agrees with childSummary. Restoring an engagement's re-parenting
onto a soft-deleted entry throws ValidationError("entryId", "target entry is deleted").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:05:12 +01:00

148 lines
7.5 KiB
TypeScript

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