import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { SCHEMA } from "../../db/schema"; import type { JellyfinItem } from "../../types"; import type { JellyfinConfig } from "../jellyfin"; import type { RescanConfig } from "../rescan"; import { _resetDedupe, processWebhookEvent } from "../webhook"; 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; } const JF: JellyfinConfig = { url: "http://jf", apiKey: "k" }; const RESCAN_CFG: RescanConfig = { audioLanguages: [], radarr: null, sonarr: null, radarrLibrary: null, sonarrLibrary: null, }; function fakeItem(over: Partial = {}): JellyfinItem { return { Id: "jf-1", Type: "Movie", Name: "Test Movie", Path: "/movies/Test.mkv", Container: "mkv", MediaStreams: [ { Type: "Video", Index: 0, Codec: "h264" }, { Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true }, ], ...over, }; } describe("processWebhookEvent — acceptance", () => { beforeEach(() => _resetDedupe()); afterEach(() => _resetDedupe()); test("rejects unknown events", async () => { const db = makeDb(); const res = await processWebhookEvent( { event: "PlaybackStart", itemId: "jf-1", itemType: "Movie" }, { db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() }, ); expect(res.accepted).toBe(false); expect(res.reason).toContain("event"); }); test("rejects non-Movie/Episode types", async () => { const db = makeDb(); const res = await processWebhookEvent( { event: "ItemUpdated", itemId: "jf-1", itemType: "Trailer" }, { db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem({ Type: "Trailer" }) }, ); expect(res.accepted).toBe(false); expect(res.reason).toContain("itemType"); }); test("rejects missing itemId", async () => { const db = makeDb(); const res = await processWebhookEvent( { event: "ItemUpdated", itemType: "Movie" }, { db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() }, ); expect(res.accepted).toBe(false); expect(res.reason).toContain("itemId"); }); test("dedupes bursts within 5s and accepts again after", async () => { const db = makeDb(); let fakeNow = 1_000_000; const getItemFn = async () => fakeItem(); const payload = { event: "ItemUpdated", itemId: "jf-1", itemType: "Movie" }; const first = await processWebhookEvent(payload, { db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn, now: () => fakeNow, }); expect(first.accepted).toBe(true); fakeNow += 1000; const second = await processWebhookEvent(payload, { db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn, now: () => fakeNow, }); expect(second.accepted).toBe(false); expect(second.reason).toBe("deduped"); fakeNow += 5001; const third = await processWebhookEvent(payload, { db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn, now: () => fakeNow, }); expect(third.accepted).toBe(true); }); test("drops when Jellyfin returns no item", async () => { const db = makeDb(); const res = await processWebhookEvent( { event: "ItemUpdated", itemId: "jf-missing", itemType: "Movie" }, { db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null }, ); expect(res.accepted).toBe(false); expect(res.reason).toContain("no item"); }); }); describe("processWebhookEvent — done-status override", () => { beforeEach(() => _resetDedupe()); async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) { return processWebhookEvent( { event: "ItemUpdated", itemId: item.Id, itemType: item.Type as "Movie" | "Episode" }, { db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item }, ); } function planStatusFor(db: Database, jellyfinId: string): string { return ( db .prepare("SELECT rp.status FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?") .get(jellyfinId) as { status: string } ).status; } test("a webhook that analyzes to is_noop=1 leaves a done plan as done", async () => { const db = makeDb(); const fresh = fakeItem(); await runWebhook(db, fresh); db .prepare( "UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)", ) .run(fresh.Id); _resetDedupe(); await runWebhook(db, fresh); expect(planStatusFor(db, fresh.Id)).toBe("done"); }); test("a webhook that analyzes to is_noop=0 flips a done plan back to pending", async () => { const db = makeDb(); // audio_languages=['deu'] means a file with english OG + french extra // audio should remove french → is_noop=0. const cfg: RescanConfig = { ...RESCAN_CFG, audioLanguages: ["deu"] }; const fresh = fakeItem({ MediaStreams: [ { Type: "Video", Index: 0, Codec: "h264" }, { Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true }, { Type: "Audio", Index: 2, Codec: "aac", Language: "fra" }, ], }); await runWebhook(db, fresh, cfg); db .prepare( "UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)", ) .run(fresh.Id); _resetDedupe(); await runWebhook(db, fresh, cfg); expect(planStatusFor(db, fresh.Id)).toBe("pending"); }); });