Files
netfelix-audio-fix/server/services/__tests__/webhook.test.ts
2026-04-15 06:55:43 +02:00

185 lines
5.6 KiB
TypeScript

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> = {}): 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 playback-related NotificationTypes (the plugin publishes many, we only want ItemAdded)", async () => {
const db = makeDb();
for (const nt of ["PlaybackStart", "PlaybackProgress", "UserDataSaved", "ItemUpdated"]) {
const res = await processWebhookEvent(
{ NotificationType: nt, ItemId: "jf-1", ItemType: "Movie" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
);
expect(res.accepted).toBe(false);
expect(res.reason).toContain("NotificationType");
}
});
test("rejects non-Movie/Episode types", async () => {
const db = makeDb();
const res = await processWebhookEvent(
{ NotificationType: "ItemAdded", 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(
{ NotificationType: "ItemAdded", 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 = { NotificationType: "ItemAdded", 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(
{ NotificationType: "ItemAdded", 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(
{ NotificationType: "ItemAdded", 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");
});
});