Files
sofa/packages/core/test/webhooks.test.ts
T
jakeandClaude Opus 4.6 554271a888 test: add webhook, credits, image-cache, and metadata tests
- New webhooks.test.ts: 34 tests covering all 3 payload parsers
  (Plex/Jellyfin/Emby), toOptionalInt, and processWebhook end-to-end
  (movie/episode watch logging, deduplication, resolution failures)
- Expand credits.test.ts: 6 new tests for refreshCredits (movie/TV
  cast+crew upsert, person deduplication, cast limit, notable crew filter)
- Expand image-cache.test.ts: 8 new tests for imageCacheEnabled,
  getLocalImagePath, and loadImageBuffer error handling
- Expand metadata.test.ts: 4 new tests for ensureBrowseTitlesExist
  (shell title creation, deduplication, mixed existing/new)

304 → 345 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:23:50 -04:00

522 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, test, vi } from "vitest";
import {
clearAllTables,
insertIntegration,
insertMovieWatch,
insertTvShow,
insertUser,
} from "@sofa/test/db";
import {
parseEmbyPayload,
parseJellyfinPayload,
parsePlexPayload,
processWebhook,
toOptionalInt,
type WebhookEvent,
} from "../src/webhooks";
const { mockResolveMovieTmdbId, mockResolveShowTmdbId } = vi.hoisted(() => ({
mockResolveMovieTmdbId: vi.fn(async () => null as number | null),
mockResolveShowTmdbId: vi.fn(async () => null as number | null),
}));
vi.mock("../src/imports/resolve", () => ({
resolveMovieTmdbId: mockResolveMovieTmdbId,
resolveShowTmdbId: mockResolveShowTmdbId,
}));
const { mockGetOrFetchTitleByTmdbId, mockRefreshTvChildren } = vi.hoisted(() => ({
mockGetOrFetchTitleByTmdbId: vi.fn(async () => null as { id: string } | null),
mockRefreshTvChildren: vi.fn(async () => {}),
}));
vi.mock("../src/metadata", () => ({
getOrFetchTitleByTmdbId: mockGetOrFetchTitleByTmdbId,
refreshTvChildren: mockRefreshTvChildren,
}));
const { mockGetTvDetails } = vi.hoisted(() => ({
mockGetTvDetails: vi.fn(async () => ({ number_of_seasons: 1 })),
}));
vi.mock("@sofa/tmdb/client", () => ({
getTvDetails: mockGetTvDetails,
}));
beforeEach(() => {
clearAllTables();
mockResolveMovieTmdbId.mockReset().mockResolvedValue(null);
mockResolveShowTmdbId.mockReset().mockResolvedValue(null);
mockGetOrFetchTitleByTmdbId.mockReset().mockResolvedValue(null);
mockRefreshTvChildren.mockReset();
mockGetTvDetails.mockReset().mockResolvedValue({ number_of_seasons: 1 });
});
// ─── toOptionalInt ──────────────────────────────────────────────────
describe("toOptionalInt", () => {
test("returns integer from number", () => {
expect(toOptionalInt(42)).toBe(42);
});
test("returns undefined for float", () => {
expect(toOptionalInt(3.14)).toBeUndefined();
});
test("parses integer from string", () => {
expect(toOptionalInt("123")).toBe(123);
});
test("returns undefined for non-numeric string", () => {
expect(toOptionalInt("abc")).toBeUndefined();
});
test("returns undefined for empty string", () => {
expect(toOptionalInt("")).toBeUndefined();
});
test("returns undefined for null/undefined", () => {
expect(toOptionalInt(null)).toBeUndefined();
expect(toOptionalInt(undefined)).toBeUndefined();
});
});
// ─── parsePlexPayload ───────────────────────────────────────────────
describe("parsePlexPayload", () => {
function makePlexForm(payload: Record<string, unknown>): FormData {
const form = new FormData();
form.set("payload", JSON.stringify(payload));
return form;
}
test("parses a movie scrobble event", () => {
const result = parsePlexPayload(
makePlexForm({
event: "media.scrobble",
Metadata: {
type: "movie",
title: "Inception",
Guid: [{ id: "tmdb://27205" }, { id: "imdb://tt1375666" }],
},
}),
);
expect(result).toEqual({
provider: "plex",
mediaType: "movie",
title: "Inception",
tmdbId: 27205,
imdbId: "tt1375666",
tvdbId: undefined,
seasonNumber: undefined,
episodeNumber: undefined,
showTitle: undefined,
});
});
test("parses an episode scrobble event", () => {
const result = parsePlexPayload(
makePlexForm({
event: "media.scrobble",
Metadata: {
type: "episode",
title: "Pilot",
grandparentTitle: "Breaking Bad",
parentIndex: 1,
index: 1,
Guid: [{ id: "tvdb://349232" }],
},
}),
);
expect(result).toEqual({
provider: "plex",
mediaType: "episode",
title: "Pilot",
tmdbId: undefined,
imdbId: undefined,
tvdbId: "349232",
seasonNumber: 1,
episodeNumber: 1,
showTitle: "Breaking Bad",
});
});
test("returns null for non-scrobble events", () => {
expect(parsePlexPayload(makePlexForm({ event: "media.play" }))).toBeNull();
});
test("returns null for missing payload", () => {
expect(parsePlexPayload(new FormData())).toBeNull();
});
test("returns null for invalid JSON", () => {
const form = new FormData();
form.set("payload", "not json");
expect(parsePlexPayload(form)).toBeNull();
});
test("returns null for unsupported media type", () => {
const result = parsePlexPayload(
makePlexForm({
event: "media.scrobble",
Metadata: { type: "track", title: "Some Song" },
}),
);
expect(result).toBeNull();
});
test("returns null when Metadata is missing", () => {
expect(parsePlexPayload(makePlexForm({ event: "media.scrobble" }))).toBeNull();
});
test("handles lowercase guid key", () => {
const result = parsePlexPayload(
makePlexForm({
event: "media.scrobble",
Metadata: {
type: "movie",
title: "Test",
guid: [{ id: "tmdb://999" }],
},
}),
);
expect(result?.tmdbId).toBe(999);
});
});
// ─── parseJellyfinPayload ───────────────────────────────────────────
describe("parseJellyfinPayload", () => {
test("parses a completed movie playback", () => {
const result = parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: true,
ItemType: "Movie",
Name: "The Matrix",
Provider_tmdb: "603",
Provider_imdb: "tt0133093",
});
expect(result).toEqual({
provider: "jellyfin",
mediaType: "movie",
title: "The Matrix",
tmdbId: 603,
imdbId: "tt0133093",
tvdbId: undefined,
seasonNumber: undefined,
episodeNumber: undefined,
showTitle: undefined,
});
});
test("parses a completed episode playback", () => {
const result = parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: true,
ItemType: "Episode",
Name: "Ozymandias",
SeriesName: "Breaking Bad",
SeasonNumber: 5,
EpisodeNumber: 14,
Provider_tvdb: "4882562",
});
expect(result).toEqual({
provider: "jellyfin",
mediaType: "episode",
title: "Ozymandias",
tmdbId: undefined,
imdbId: undefined,
tvdbId: "4882562",
seasonNumber: 5,
episodeNumber: 14,
showTitle: "Breaking Bad",
});
});
test("returns null when not PlaybackStop", () => {
expect(
parseJellyfinPayload({
NotificationType: "PlaybackStart",
PlayedToCompletion: true,
ItemType: "Movie",
}),
).toBeNull();
});
test("returns null when not played to completion", () => {
expect(
parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: false,
ItemType: "Movie",
}),
).toBeNull();
});
test("returns null for unsupported item type", () => {
expect(
parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: true,
ItemType: "Audio",
}),
).toBeNull();
});
});
// ─── parseEmbyPayload ───────────────────────────────────────────────
describe("parseEmbyPayload", () => {
test("parses playback.stop movie event", () => {
const result = parseEmbyPayload({
Event: "playback.stop",
PlayedToCompletion: true,
Item: {
Type: "Movie",
Name: "Interstellar",
ProviderIds: { Tmdb: "157336", Imdb: "tt0816692" },
},
});
expect(result).toEqual({
provider: "emby",
mediaType: "movie",
title: "Interstellar",
tmdbId: 157336,
imdbId: "tt0816692",
tvdbId: undefined,
seasonNumber: undefined,
episodeNumber: undefined,
showTitle: undefined,
});
});
test("parses PlaybackStop event variant", () => {
const result = parseEmbyPayload({
Event: "PlaybackStop",
PlayedToCompletion: true,
Item: { Type: "Movie", Name: "Test", ProviderIds: {} },
});
expect(result?.provider).toBe("emby");
});
test("parses episode event", () => {
const result = parseEmbyPayload({
Event: "playback.stop",
PlayedToCompletion: true,
Item: {
Type: "Episode",
Name: "Pilot",
SeriesName: "Lost",
ParentIndexNumber: 1,
IndexNumber: 1,
ProviderIds: { Tvdb: "127131" },
},
});
expect(result).toEqual({
provider: "emby",
mediaType: "episode",
title: "Pilot",
tmdbId: undefined,
imdbId: undefined,
tvdbId: "127131",
seasonNumber: 1,
episodeNumber: 1,
showTitle: "Lost",
});
});
test("returns null for non-stop events", () => {
expect(
parseEmbyPayload({
Event: "playback.start",
PlayedToCompletion: true,
Item: { Type: "Movie", Name: "X" },
}),
).toBeNull();
});
test("returns null when not played to completion", () => {
expect(
parseEmbyPayload({
Event: "playback.stop",
PlayedToCompletion: false,
Item: { Type: "Movie", Name: "X" },
}),
).toBeNull();
});
test("returns null when Item is missing", () => {
expect(parseEmbyPayload({ Event: "playback.stop", PlayedToCompletion: true })).toBeNull();
});
});
// ─── processWebhook ─────────────────────────────────────────────────
describe("processWebhook", () => {
const userId = "user-1";
let connectionId: string;
beforeEach(() => {
insertUser(userId);
const integration = insertIntegration(userId, "plex", "plex-token");
connectionId = integration.id;
});
test("logs a movie watch on successful resolution", async () => {
const { insertTitle } = await import("@sofa/test/db");
insertTitle({ id: "title-1", tmdbId: 27205, type: "movie", title: "Inception" });
mockResolveMovieTmdbId.mockResolvedValue(27205);
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "title-1" });
const event: WebhookEvent = {
provider: "plex",
mediaType: "movie",
title: "Inception",
tmdbId: 27205,
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("success");
});
test("returns error when movie TMDB ID cannot be resolved", async () => {
mockResolveMovieTmdbId.mockResolvedValue(null);
const event: WebhookEvent = {
provider: "plex",
mediaType: "movie",
title: "Unknown Movie",
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("error");
expect(result.message).toContain("Could not resolve TMDB ID");
});
test("returns error when title import fails", async () => {
mockResolveMovieTmdbId.mockResolvedValue(999);
mockGetOrFetchTitleByTmdbId.mockResolvedValue(null);
const event: WebhookEvent = {
provider: "jellyfin",
mediaType: "movie",
title: "Bad Movie",
tmdbId: 999,
};
const result = await processWebhook(connectionId, userId, "jellyfin", event);
expect(result.status).toBe("error");
expect(result.message).toContain("Failed to import movie");
});
test("deduplicates movie watches within 5 minutes", async () => {
mockResolveMovieTmdbId.mockResolvedValue(27205);
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "title-1" });
// Seed a recent watch
const { insertTitle } = await import("@sofa/test/db");
insertTitle({ id: "title-1", tmdbId: 27205, type: "movie", title: "Inception" });
insertMovieWatch(userId, "title-1");
const event: WebhookEvent = {
provider: "plex",
mediaType: "movie",
title: "Inception",
tmdbId: 27205,
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("ignored");
expect(result.message).toContain("Duplicate");
});
test("logs an episode watch on successful resolution", async () => {
mockResolveShowTmdbId.mockResolvedValue(1396);
const { titleId } = insertTvShow("tv-1", 1396, 1, 1, { title: "Breaking Bad" });
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: titleId });
const event: WebhookEvent = {
provider: "jellyfin",
mediaType: "episode",
title: "Pilot",
showTitle: "Breaking Bad",
tmdbId: 1396,
seasonNumber: 1,
episodeNumber: 1,
};
const result = await processWebhook(connectionId, userId, "jellyfin", event);
expect(result.status).toBe("success");
});
test("returns error when episode cannot be resolved", async () => {
mockResolveShowTmdbId.mockResolvedValue(null);
const event: WebhookEvent = {
provider: "emby",
mediaType: "episode",
title: "Some Episode",
seasonNumber: 1,
episodeNumber: 1,
};
const result = await processWebhook(connectionId, userId, "emby", event);
expect(result.status).toBe("error");
expect(result.message).toContain("Could not resolve episode");
});
test("returns error when season is not found", async () => {
mockResolveShowTmdbId.mockResolvedValue(1396);
insertTvShow("tv-1", 1396, 1, 3, { title: "Breaking Bad" });
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "tv-1" });
mockGetTvDetails.mockResolvedValue({ number_of_seasons: 1 });
const event: WebhookEvent = {
provider: "plex",
mediaType: "episode",
title: "Episode",
showTitle: "Breaking Bad",
tmdbId: 1396,
seasonNumber: 99,
episodeNumber: 1,
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("error");
expect(result.message).toContain("Season 99 not found");
});
test("returns error when episode number is not found in season", async () => {
mockResolveShowTmdbId.mockResolvedValue(1396);
insertTvShow("tv-1", 1396, 1, 3, { title: "Breaking Bad" });
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "tv-1" });
const event: WebhookEvent = {
provider: "plex",
mediaType: "episode",
title: "Episode",
showTitle: "Breaking Bad",
tmdbId: 1396,
seasonNumber: 1,
episodeNumber: 99,
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("error");
expect(result.message).toContain("S1E99 not found");
});
test("returns error when episode is missing season/episode numbers", async () => {
const event: WebhookEvent = {
provider: "plex",
mediaType: "episode",
title: "Mystery Episode",
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("error");
});
});