mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user