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>
This commit is contained in:
2026-03-22 12:23:50 -04:00
co-authored by Claude Opus 4.6
parent 5f3e14b415
commit 554271a888
4 changed files with 873 additions and 10 deletions
+205 -3
View File
@@ -1,12 +1,39 @@
import { beforeEach, describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { persons, titleCast } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/test/db";
import { clearAllTables, eq, insertTitle, testDb } from "@sofa/test/db";
import { getCastForTitle } from "../src/credits";
const { mockGetMovieCredits, mockGetTvAggregateCredits } = vi.hoisted(() => ({
mockGetMovieCredits: vi.fn(async () => ({
cast: [] as Record<string, unknown>[],
crew: [] as Record<string, unknown>[],
})),
mockGetTvAggregateCredits: vi.fn(async () => ({
cast: [] as Record<string, unknown>[],
crew: [] as Record<string, unknown>[],
})),
}));
vi.mock("@sofa/tmdb/client", () => ({
getMovieCredits: mockGetMovieCredits,
getTvAggregateCredits: mockGetTvAggregateCredits,
}));
vi.mock("../src/image-cache", () => ({
imageCacheEnabled: () => false,
cacheProfilePhotos: async () => {},
}));
vi.mock("../src/thumbhash", () => ({
generatePersonThumbHash: async () => null,
}));
import { getCastForTitle, refreshCredits } from "../src/credits";
beforeEach(() => {
clearAllTables();
mockGetMovieCredits.mockReset().mockResolvedValue({ cast: [], crew: [] });
mockGetTvAggregateCredits.mockReset().mockResolvedValue({ cast: [], crew: [] });
});
function insertPerson(id: string, tmdbId: number, name: string, profilePath: string | null = null) {
@@ -40,6 +67,8 @@ function insertCastEntry(
.run();
}
// ─── getCastForTitle ────────────────────────────────────────────────
describe("getCastForTitle", () => {
test("returns cast members ordered by displayOrder", () => {
insertTitle({ id: "m1", tmdbId: 1 });
@@ -78,3 +107,176 @@ describe("getCastForTitle", () => {
expect(cast).toHaveLength(0);
});
});
// ─── refreshCredits ─────────────────────────────────────────────────
describe("refreshCredits", () => {
test("creates persons and cast entries for a movie", async () => {
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
mockGetMovieCredits.mockResolvedValue({
cast: [
{ id: 1, name: "Actor A", profile_path: "/a.jpg", character: "Hero", popularity: 10 },
{ id: 2, name: "Actor B", profile_path: null, character: "Villain", popularity: 5 },
],
crew: [
{
id: 3,
name: "Dir Person",
profile_path: "/d.jpg",
job: "Director",
department: "Directing",
popularity: 8,
},
],
});
await refreshCredits("m1");
const castRows = testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all();
const personRows = testDb.select().from(persons).all();
expect(personRows).toHaveLength(3);
expect(castRows).toHaveLength(3);
// Cast should be ordered: actors 0,1 then crew at 100+
const sorted = castRows.sort((a, b) => a.displayOrder - b.displayOrder);
expect(sorted[0].department).toBe("Acting");
expect(sorted[0].character).toBe("Hero");
expect(sorted[1].department).toBe("Acting");
expect(sorted[2].department).toBe("Directing");
expect(sorted[2].job).toBe("Director");
});
test("creates persons and cast entries for a TV show", async () => {
insertTitle({ id: "tv1", tmdbId: 200, type: "tv" });
mockGetTvAggregateCredits.mockResolvedValue({
cast: [
{
id: 10,
name: "TV Actor",
profile_path: "/tv.jpg",
popularity: 7,
total_episode_count: 24,
roles: [{ character: "Main Character", episode_count: 24 }],
},
],
crew: [
{
id: 20,
name: "Show Creator",
profile_path: null,
department: "Production",
popularity: 3,
jobs: [{ job: "Creator", episode_count: 24 }],
},
],
});
await refreshCredits("tv1");
const castRows = testDb.select().from(titleCast).where(eq(titleCast.titleId, "tv1")).all();
expect(castRows).toHaveLength(2);
const actor = castRows.find((r) => r.department === "Acting");
expect(actor?.character).toBe("Main Character");
expect(actor?.episodeCount).toBe(24);
const creator = castRows.find((r) => r.job === "Creator");
expect(creator).toBeDefined();
});
test("does nothing for nonexistent title", async () => {
await refreshCredits("nonexistent");
expect(mockGetMovieCredits).not.toHaveBeenCalled();
expect(mockGetTvAggregateCredits).not.toHaveBeenCalled();
});
test("limits movie cast to 20 entries", async () => {
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
const largeCast = Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
name: `Actor ${i}`,
profile_path: null,
character: `Char ${i}`,
popularity: 1,
}));
mockGetMovieCredits.mockResolvedValue({ cast: largeCast, crew: [] });
await refreshCredits("m1");
const castRows = testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all();
expect(castRows).toHaveLength(20);
});
test("deduplicates persons by tmdbId", async () => {
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
mockGetMovieCredits.mockResolvedValue({
cast: [{ id: 1, name: "Person", profile_path: null, character: "Role A", popularity: 5 }],
crew: [
{
id: 1,
name: "Person",
profile_path: null,
job: "Director",
department: "Directing",
popularity: 5,
},
],
});
await refreshCredits("m1");
const personRows = testDb.select().from(persons).all();
expect(personRows).toHaveLength(1);
const castRows = testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all();
expect(castRows).toHaveLength(2);
});
test("filters crew to notable departments only", async () => {
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
mockGetMovieCredits.mockResolvedValue({
cast: [],
crew: [
{
id: 1,
name: "Director",
profile_path: null,
job: "Director",
department: "Directing",
popularity: 5,
},
{
id: 2,
name: "Grip",
profile_path: null,
job: "Key Grip",
department: "Camera",
popularity: 1,
},
{
id: 3,
name: "Writer",
profile_path: null,
job: "Writer",
department: "Writing",
popularity: 3,
},
],
});
await refreshCredits("m1");
const castRows = testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all();
// Only Director and Writer are in NOTABLE_DEPARTMENTS
expect(castRows).toHaveLength(2);
const jobs = castRows.map((r) => r.job).sort();
expect(jobs).toEqual(["Director", "Writer"]);
});
});
+83 -6
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { loadImageBuffer } from "../src/image-cache";
import { getLocalImagePath, imageCacheEnabled, loadImageBuffer } from "../src/image-cache";
beforeEach(() => {
process.env.IMAGE_CACHE_ENABLED = "false";
@@ -10,18 +10,52 @@ afterEach(() => {
delete process.env.IMAGE_CACHE_ENABLED;
});
function mockFetch(impl: (input: string | URL | Request) => Promise<Response>) {
vi.spyOn(globalThis, "fetch").mockImplementation(impl as unknown as typeof fetch);
}
// ─── imageCacheEnabled ──────────────────────────────────────────────
describe("imageCacheEnabled", () => {
test("returns false when IMAGE_CACHE_ENABLED is 'false'", () => {
process.env.IMAGE_CACHE_ENABLED = "false";
expect(imageCacheEnabled()).toBe(false);
});
test("returns true when IMAGE_CACHE_ENABLED is unset", () => {
delete process.env.IMAGE_CACHE_ENABLED;
expect(imageCacheEnabled()).toBe(true);
});
test("returns true when IMAGE_CACHE_ENABLED is 'true'", () => {
process.env.IMAGE_CACHE_ENABLED = "true";
expect(imageCacheEnabled()).toBe(true);
});
});
// ─── getLocalImagePath ──────────────────────────────────────────────
describe("getLocalImagePath", () => {
test("joins cache dir with category and basename", () => {
const result = getLocalImagePath("posters", "/some/deep/path/poster.jpg");
expect(result).toContain("posters");
expect(result).toContain("poster.jpg");
expect(result).not.toContain("some/deep");
});
});
// ─── loadImageBuffer ────────────────────────────────────────────────
describe("loadImageBuffer", () => {
test("uses category-specific TMDB sizes when cache is disabled", async () => {
const urls: string[] = [];
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((async (
input: string | URL | Request,
) => {
mockFetch(async (input) => {
urls.push(String(input));
return new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
}) as typeof fetch);
});
await loadImageBuffer("/poster.jpg", "posters");
await loadImageBuffer("/profile.jpg", "profiles");
@@ -30,7 +64,50 @@ describe("loadImageBuffer", () => {
"https://image.tmdb.org/t/p/w500/poster.jpg",
"https://image.tmdb.org/t/p/w185/profile.jpg",
]);
});
fetchSpy.mockRestore();
test("returns null on fetch failure", async () => {
mockFetch(async () => new Response(null, { status: 404 }));
const result = await loadImageBuffer("/missing.jpg", "posters");
expect(result).toBeNull();
});
test("returns null on network error", async () => {
mockFetch(async () => {
throw new Error("Network error");
});
const result = await loadImageBuffer("/fail.jpg", "posters");
expect(result).toBeNull();
});
test("returns buffer on success", async () => {
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
mockFetch(async () => {
return new Response(imageBytes, {
status: 200,
headers: { "content-type": "image/png" },
});
});
const result = await loadImageBuffer("/image.png", "posters");
expect(result).toBeInstanceOf(Buffer);
expect(result).toHaveLength(4);
});
test("rejects images exceeding size limit via content-length", async () => {
mockFetch(async () => {
return new Response(new Uint8Array([1]), {
status: 200,
headers: {
"content-type": "image/jpeg",
"content-length": String(11 * 1024 * 1024),
},
});
});
const result = await loadImageBuffer("/huge.jpg", "posters");
expect(result).toBeNull();
});
});
+64 -1
View File
@@ -1,13 +1,20 @@
import { describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test } from "vitest";
import { titles } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/test/db";
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
import {
ensureBrowseTitlesExist,
extractMovieContentRating,
extractTvContentRating,
pickBestTrailer,
} from "../src/metadata";
beforeEach(() => {
clearAllTables();
});
function makeVideo(overrides: Partial<TmdbVideo> = {}): TmdbVideo {
return {
id: "v1",
@@ -213,3 +220,59 @@ describe("extractTvContentRating", () => {
expect(extractTvContentRating(show)).toBeNull();
});
});
// ─── ensureBrowseTitlesExist ────────────────────────────────────────
describe("ensureBrowseTitlesExist", () => {
test("creates shell titles for new entries", () => {
const result = ensureBrowseTitlesExist([
{ tmdbId: 100, type: "movie", title: "Movie A", posterPath: "/a.jpg" },
{ tmdbId: 200, type: "tv", title: "Show B", posterPath: "/b.jpg" },
]);
expect(result.size).toBe(2);
expect(result.get("100-movie")).toBeDefined();
expect(result.get("200-tv")).toBeDefined();
const allTitles = testDb.select().from(titles).all();
expect(allTitles).toHaveLength(2);
// Shell titles have no lastFetchedAt
expect(allTitles[0].lastFetchedAt).toBeNull();
expect(allTitles[1].lastFetchedAt).toBeNull();
});
test("returns existing titles without creating duplicates", () => {
insertTitle({ id: "existing-1", tmdbId: 100, type: "movie", title: "Movie A" });
const result = ensureBrowseTitlesExist([
{ tmdbId: 100, type: "movie", title: "Movie A", posterPath: "/a.jpg" },
]);
expect(result.size).toBe(1);
expect(result.get("100-movie")?.id).toBe("existing-1");
const allTitles = testDb.select().from(titles).all();
expect(allTitles).toHaveLength(1);
});
test("handles mix of existing and new titles", () => {
insertTitle({ id: "existing-1", tmdbId: 100, type: "movie", title: "Movie A" });
const result = ensureBrowseTitlesExist([
{ tmdbId: 100, type: "movie", title: "Movie A", posterPath: "/a.jpg" },
{ tmdbId: 200, type: "tv", title: "Show B", posterPath: "/b.jpg" },
]);
expect(result.size).toBe(2);
expect(result.get("100-movie")?.id).toBe("existing-1");
expect(result.get("200-tv")).toBeDefined();
const allTitles = testDb.select().from(titles).all();
expect(allTitles).toHaveLength(2);
});
test("returns empty map for empty input", () => {
const result = ensureBrowseTitlesExist([]);
expect(result.size).toBe(0);
});
});
+521
View File
@@ -0,0 +1,521 @@
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");
});
});