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]>
148 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|