fix(core): roll up descendants and surface orphaned entries; SQL-derived title cursor
- childSummary counts the user's live entries up to three levels below the work. - The default library view (parentId null) lists entries with no ancestor (up to three levels) carrying a live entry of this user, so episodes of an untracked show still show. - The title keyset cursor carries the SQL lower(w.title) value, so SQLite no longer skips non-ASCII titles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c65939d61
commit
512e6e9464
2 files changed
+84
-9
No files matched your search
@@ -127,6 +127,56 @@ forEachDialect("entries", (getDb) => {
|
||||
const children = await listEntries(db, userId, { parentId: show });
|
||||
expect(children.items).toHaveLength(3);
|
||||
expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true);
|
||||
|
||||
// grandchildren: show → season (no entry) → episodes still roll up to the show
|
||||
const show2 = await makeWork(db, { title: "The Expanse", mediaType: "show" });
|
||||
const show2Entry = await upsertEntry(db, userId, show2, {}, "user");
|
||||
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show2 });
|
||||
for (const status of ["completed", "planned"] as const) {
|
||||
const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: season });
|
||||
await upsertEntry(db, userId, ep, { status }, "user");
|
||||
}
|
||||
const top2 = await listEntries(db, userId, {});
|
||||
expect(top2.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id, show2Entry.id].sort());
|
||||
expect(top2.items.find((i) => i.id === show2Entry.id)!.childSummary).toEqual({ total: 2, completed: 1 });
|
||||
});
|
||||
|
||||
test("orphaned child entries are top level until an ancestor has an entry", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const show = await makeWork(db, { title: "Severance", mediaType: "show" });
|
||||
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show });
|
||||
const ep = await makeWork(db, { title: "Good News About Hell", mediaType: "episode", parentId: season });
|
||||
const epEntry = await upsertEntry(db, userId, ep, { status: "completed" }, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||
|
||||
// another user's show entry does not hide it
|
||||
const bob = await makeUser(db, "bob");
|
||||
await upsertEntry(db, bob, show, {}, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||
|
||||
const showEntry = await upsertEntry(db, userId, show, {}, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([showEntry.id]);
|
||||
// a soft-deleted ancestor entry does not hide it
|
||||
await softDeleteEntry(db, userId, showEntry.id, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||
});
|
||||
|
||||
test("title pagination does not skip non-ASCII titles", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const titles = ["Éclair", "Ölund", "Zeta", "alpha", "日本", "éclair 2", "BETA"];
|
||||
for (const title of titles) await upsertEntry(db, userId, await makeWork(db, { title }), {}, "user");
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const page = await listEntries(db, userId, { sort: "title", limit: 1, cursor });
|
||||
seen.push(...page.items.map((x) => x.work.title));
|
||||
if (!page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
expect(seen).toHaveLength(titles.length);
|
||||
expect([...seen].sort()).toEqual([...titles].sort());
|
||||
});
|
||||
|
||||
test("listEntries filters, sorts and paginates", async () => {
|
||||
|
||||
Reference in new issue
Block a user