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; autoClass?: "auto" | "auto_heuristic" | "manual" | null; sorted?: 0 | 1; } function seed(db: Database, opts: SeedOpts) { const { id, type, name = `Item ${id}`, seriesName = null, seriesJellyfinId = null, seasonNumber = null, episodeNumber = null, autoClass = "manual", sorted = 1, } = 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, auto_class, sorted, apple_compat, job_type, notes) VALUES (?, 'pending', 0, ?, ?, 'direct_play', 'copy', NULL)", ) .run(id, autoClass, sorted); } 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, { bucket: "review" }); 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, { bucket: "review" }); 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: auto_heuristic (ready) first, then manual, then by name", () => { const db = makeDb(); seed(db, { id: 1, type: "Movie", name: "Zodiac", autoClass: "auto_heuristic" }); seed(db, { id: 2, type: "Movie", name: "Arrival", autoClass: "manual" }); seed(db, { id: 3, type: "Movie", name: "Blade Runner", autoClass: "auto_heuristic" }); const { groups } = buildReviewGroups(db, { bucket: "review" }); const names = groups.map((g) => (g.kind === "movie" ? g.item.name : g.seriesName)); expect(names).toEqual(["Blade Runner", "Zodiac", "Arrival"]); }); test("series readyCount counts auto_heuristic episodes", () => { const db = makeDb(); seed(db, { id: 1, type: "Episode", seriesName: "Show", seriesJellyfinId: "s", seasonNumber: 1, episodeNumber: 1, autoClass: "auto_heuristic", }); seed(db, { id: 2, type: "Episode", seriesName: "Show", seriesJellyfinId: "s", seasonNumber: 1, episodeNumber: 2, autoClass: "manual", }); const { groups } = buildReviewGroups(db, { bucket: "review" }); if (groups[0].kind !== "series") throw new Error("expected series"); expect(groups[0].readyCount).toBe(1); }); 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, { bucket: "review" }); 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"); }); test("bucket=inbox returns sorted=0 plans only", () => { const db = makeDb(); seed(db, { id: 1, type: "Movie", name: "Fresh", autoClass: null, sorted: 0 }); seed(db, { id: 2, type: "Movie", name: "Old", autoClass: "manual", sorted: 1 }); const inbox = buildReviewGroups(db, { bucket: "inbox" }); expect(inbox.groups).toHaveLength(1); if (inbox.groups[0].kind !== "movie") throw new Error("expected movie"); expect(inbox.groups[0].item.name).toBe("Fresh"); const review = buildReviewGroups(db, { bucket: "review" }); expect(review.groups).toHaveLength(1); if (review.groups[0].kind !== "movie") throw new Error("expected movie"); expect(review.groups[0].item.name).toBe("Old"); }); });