diff --git a/server/api/__tests__/review-groups.test.ts b/server/api/__tests__/review-groups.test.ts new file mode 100644 index 0000000..60d6f0d --- /dev/null +++ b/server/api/__tests__/review-groups.test.ts @@ -0,0 +1,161 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { SCHEMA } from "../../db/schema"; +import { buildReviewGroups } from "../review"; + +function makeDb(): Database { + const db = new Database(":memory:"); + for (const stmt of SCHEMA.split(";")) { + const trimmed = stmt.trim(); + if (trimmed) db.run(trimmed); + } + return db; +} + +interface SeedOpts { + id: number; + type: "Movie" | "Episode"; + name?: string; + seriesName?: string | null; + seriesJellyfinId?: string | null; + seasonNumber?: number | null; + episodeNumber?: number | null; + confidence?: "high" | "low"; +} + +function seed(db: Database, opts: SeedOpts) { + const { + id, + type, + name = `Item ${id}`, + seriesName = null, + seriesJellyfinId = null, + seasonNumber = null, + episodeNumber = null, + confidence = "high", + } = opts; + db + .prepare( + "INSERT INTO media_items (id, jellyfin_id, type, name, series_name, series_jellyfin_id, season_number, episode_number, file_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run(id, `jf-${id}`, type, name, seriesName, seriesJellyfinId, seasonNumber, episodeNumber, `/x/${id}.mkv`); + db + .prepare( + "INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes) VALUES (?, 'pending', 0, ?, 'direct_play', 'copy', NULL)", + ) + .run(id, confidence); +} + +describe("buildReviewGroups", () => { + test("returns a complete series with every pending episode", () => { + const db = makeDb(); + for (let i = 1; i <= 30; i++) { + seed(db, { + id: i, + type: "Episode", + seriesName: "Breaking Bad", + seriesJellyfinId: "bb", + seasonNumber: 1, + episodeNumber: i, + }); + } + + const { groups, totalItems } = buildReviewGroups(db); + + expect(groups).toHaveLength(1); + const series = groups[0]; + expect(series.kind).toBe("series"); + if (series.kind !== "series") throw new Error("expected series"); + expect(series.episodeCount).toBe(30); + expect(series.seasons).toHaveLength(1); + expect(series.seasons[0].episodes).toHaveLength(30); + expect(totalItems).toBe(30); + }); + + test("buckets episodes by season with null ordered last", () => { + const db = makeDb(); + for (let ep = 1; ep <= 3; ep++) { + seed(db, { + id: ep, + type: "Episode", + seriesName: "Lost", + seriesJellyfinId: "lost", + seasonNumber: 1, + episodeNumber: ep, + }); + } + for (let ep = 1; ep <= 2; ep++) { + seed(db, { + id: 10 + ep, + type: "Episode", + seriesName: "Lost", + seriesJellyfinId: "lost", + seasonNumber: 2, + episodeNumber: ep, + }); + } + seed(db, { id: 99, type: "Episode", seriesName: "Lost", seriesJellyfinId: "lost", seasonNumber: null }); + + const { groups } = buildReviewGroups(db); + expect(groups).toHaveLength(1); + const lost = groups[0]; + if (lost.kind !== "series") throw new Error("expected series"); + expect(lost.seasons.map((s) => s.season)).toEqual([1, 2, null]); + expect(lost.seasons[0].episodes).toHaveLength(3); + expect(lost.seasons[1].episodes).toHaveLength(2); + expect(lost.seasons[2].episodes).toHaveLength(1); + }); + + test("sorts groups: high-confidence first, then by name", () => { + const db = makeDb(); + seed(db, { id: 1, type: "Movie", name: "Zodiac", confidence: "high" }); + seed(db, { id: 2, type: "Movie", name: "Arrival", confidence: "low" }); + seed(db, { id: 3, type: "Movie", name: "Blade Runner", confidence: "high" }); + + const { groups } = buildReviewGroups(db); + const names = groups.map((g) => (g.kind === "movie" ? g.item.name : g.seriesName)); + expect(names).toEqual(["Blade Runner", "Zodiac", "Arrival"]); + }); + + test("minConfidence is low when any episode in the series is low", () => { + const db = makeDb(); + seed(db, { + id: 1, + type: "Episode", + seriesName: "Show", + seriesJellyfinId: "s", + seasonNumber: 1, + episodeNumber: 1, + confidence: "high", + }); + seed(db, { + id: 2, + type: "Episode", + seriesName: "Show", + seriesJellyfinId: "s", + seasonNumber: 1, + episodeNumber: 2, + confidence: "low", + }); + + const { groups } = buildReviewGroups(db); + expect(groups).toHaveLength(1); + if (groups[0].kind !== "series") throw new Error("expected series"); + expect(groups[0].minConfidence).toBe("low"); + }); + + test("excludes plans that are not pending or are is_noop=1", () => { + const db = makeDb(); + seed(db, { id: 1, type: "Movie", name: "Pending" }); + seed(db, { id: 2, type: "Movie", name: "Approved" }); + db.prepare("UPDATE review_plans SET status = 'approved' WHERE item_id = ?").run(2); + seed(db, { id: 3, type: "Movie", name: "Noop" }); + db.prepare("UPDATE review_plans SET is_noop = 1 WHERE item_id = ?").run(3); + + const { groups, totalItems } = buildReviewGroups(db); + expect(groups).toHaveLength(1); + expect(totalItems).toBe(1); + if (groups[0].kind !== "movie") throw new Error("expected movie"); + expect(groups[0].item.name).toBe("Pending"); + }); +}); diff --git a/server/api/review.ts b/server/api/review.ts index b36cac4..71ca7ba 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -385,7 +385,7 @@ type ReviewGroup = seasons: Array<{ season: number | null; episodes: ReviewItemRow[] }>; }; -function buildReviewGroups(db: ReturnType): { groups: ReviewGroup[]; totalItems: number } { +export function buildReviewGroups(db: ReturnType): { groups: ReviewGroup[]; totalItems: number } { const rows = db .prepare(` SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,