Files
sofa/packages/core/test/webhooks.test.ts
T
jakeandGitHub 0c747a9275 fix(webhooks): ignore episode-level Plex TMDB GUIDs when parsing scrobble events (#53)
Plex attaches TMDB GUIDs that identify the individual episode, not the parent series, so using them as `tmdbId` caused incorrect series lookups. Now only movie scrobbles populate `tmdbId` from the TMDB GUID; episode scrobbles rely on IMDB/TVDB IDs (and the title) to resolve the show via `resolveShowTmdbId`.

- Extract IMDB/TMDB/TVDB prefix constants to avoid magic strings
- Add `parsePlexPayload` test asserting `tmdbId` is `undefined` for episode events
- Add `processWebhook` integration test verifying `resolveShowTmdbId` is called without a TMDB ID for Plex episodes
- Hoist `makePlexForm` helper to module scope so it's available across all describe blocks
2026-07-12 16:53:04 -04:00

581 lines
17 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 });
});
function makePlexForm(payload: Record<string, unknown>): FormData {
const form = new FormData();
form.set("payload", JSON.stringify(payload));
return form;
}
// ─── 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", () => {
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("ignores episode-level Plex TMDB GUIDs", () => {
const result = parsePlexPayload(
makePlexForm({
event: "media.scrobble",
Metadata: {
type: "episode",
title: "Episode Title",
grandparentTitle: "The Pitt",
parentIndex: 1,
index: 14,
Guid: [{ id: "imdb://tt35740476" }, { id: "tmdb://6951292" }, { id: "tvdb://11560129" }],
},
}),
);
expect(result).toEqual({
provider: "plex",
mediaType: "episode",
title: "Episode Title",
tmdbId: undefined,
imdbId: "tt35740476",
tvdbId: "11560129",
seasonNumber: 1,
episodeNumber: 14,
showTitle: "The Pitt",
});
});
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("resolves parsed Plex episodes without passing the episode TMDB ID", async () => {
mockResolveShowTmdbId.mockResolvedValue(1396);
const { titleId } = insertTvShow("tv-1", 1396, 1, 14, { title: "The Pitt" });
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: titleId });
const event = parsePlexPayload(
makePlexForm({
event: "media.scrobble",
Metadata: {
type: "episode",
title: "Episode Title",
grandparentTitle: "The Pitt",
parentIndex: 1,
index: 14,
Guid: [{ id: "imdb://tt35740476" }, { id: "tmdb://6951292" }, { id: "tvdb://11560129" }],
},
}),
);
expect(event).not.toBeNull();
if (!event) throw new Error("Expected Plex episode event");
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("success");
expect(mockResolveShowTmdbId).toHaveBeenCalledWith({
tmdbId: undefined,
imdbId: "tt35740476",
tvdbId: "11560129",
title: "The Pitt",
});
});
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");
});
});