mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
feat: add watch history import from Trakt, Simkl, and Letterboxd (#13)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import {
|
||||
importJobs,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
userMovieWatches,
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "@sofa/db/schema";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertMovieWatch,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
testDb,
|
||||
} from "@sofa/db/test-utils";
|
||||
import * as tmdbClient from "@sofa/tmdb/client";
|
||||
import type { NormalizedImport } from "../src/imports/parsers";
|
||||
import { processImportJob, readImportJob } from "../src/imports/processor";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Insert a fully-fetched movie title (lastFetchedAt set so metadata won't re-fetch). */
|
||||
function insertMovieTitle(
|
||||
id: string,
|
||||
tmdbId: number,
|
||||
movieTitle = "Test Movie",
|
||||
) {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
id,
|
||||
tmdbId,
|
||||
type: "movie",
|
||||
title: movieTitle,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Insert a fully-fetched TV title with seasons/episodes (lastFetchedAt set). */
|
||||
function insertTvShowWithFetchedAt(
|
||||
titleId: string,
|
||||
tmdbId: number,
|
||||
seasonCount = 1,
|
||||
epsPerSeason = 3,
|
||||
) {
|
||||
const result = insertTvShow(titleId, tmdbId, seasonCount, epsPerSeason);
|
||||
// Mark as fully fetched so getOrFetchTitleByTmdbId skips TMDB API calls
|
||||
testDb
|
||||
.update(titles)
|
||||
.set({ lastFetchedAt: new Date() })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Create an import job row in the DB and return its ID. */
|
||||
function createJob(
|
||||
userId: string,
|
||||
payload: NormalizedImport,
|
||||
options: {
|
||||
importWatches?: boolean;
|
||||
importWatchlist?: boolean;
|
||||
importRatings?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const id = `job-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
testDb
|
||||
.insert(importJobs)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
source: payload.source,
|
||||
status: "pending",
|
||||
payload: JSON.stringify(payload),
|
||||
importWatches: options.importWatches ?? true,
|
||||
importWatchlist: options.importWatchlist ?? true,
|
||||
importRatings: options.importRatings ?? true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
// ── Movie Import ────────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — movies", () => {
|
||||
test("imports a movie with direct tmdbId", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [
|
||||
{
|
||||
tmdbId: 550,
|
||||
title: "Fight Club",
|
||||
year: 1999,
|
||||
watchedAt: "2024-06-15T20:00:00Z",
|
||||
},
|
||||
],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(1);
|
||||
expect(job.skippedCount).toBe(0);
|
||||
expect(job.failedCount).toBe(0);
|
||||
|
||||
// Verify watch record created
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userMovieWatches)
|
||||
.where(eq(userMovieWatches.userId, userId))
|
||||
.all();
|
||||
expect(watches).toHaveLength(1);
|
||||
expect(watches[0].titleId).toBe("movie-1");
|
||||
});
|
||||
|
||||
test("deduplicates existing movie watches", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
insertMovieWatch(userId, "movie-1");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [{ tmdbId: 550, title: "Fight Club" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(0);
|
||||
expect(job.skippedCount).toBe(1);
|
||||
|
||||
// Should still be just the original watch
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userMovieWatches)
|
||||
.where(eq(userMovieWatches.userId, userId))
|
||||
.all();
|
||||
expect(watches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Episode Import ──────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — episodes", () => {
|
||||
test("imports episodes for a pre-seeded TV show", async () => {
|
||||
const userId = insertUser();
|
||||
insertTvShowWithFetchedAt("tv-1", 1399, 1, 3);
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [],
|
||||
episodes: [
|
||||
{
|
||||
showTmdbId: 1399,
|
||||
showTitle: "Test Show",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
watchedAt: "2024-01-10T20:00:00Z",
|
||||
},
|
||||
{
|
||||
showTmdbId: 1399,
|
||||
showTitle: "Test Show",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
watchedAt: "2024-01-11T20:00:00Z",
|
||||
},
|
||||
],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(2);
|
||||
expect(job.failedCount).toBe(0);
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, userId))
|
||||
.all();
|
||||
expect(watches).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Watchlist Import ────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — watchlist", () => {
|
||||
test("sets title status to watchlist", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-wl", 999, "Watchlist Movie");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "simkl",
|
||||
movies: [],
|
||||
episodes: [],
|
||||
watchlist: [{ tmdbId: 999, title: "Watchlist Movie", type: "movie" }],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload, {
|
||||
importWatches: false,
|
||||
importWatchlist: true,
|
||||
importRatings: false,
|
||||
});
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(1);
|
||||
|
||||
const statusRow = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(eq(userTitleStatus.userId, userId))
|
||||
.all();
|
||||
expect(statusRow).toHaveLength(1);
|
||||
expect(statusRow[0].status).toBe("watchlist");
|
||||
expect(statusRow[0].titleId).toBe("movie-wl");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rating Import ───────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — ratings", () => {
|
||||
test("stores rating correctly", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-r", 888, "Rated Movie");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [
|
||||
{
|
||||
tmdbId: 888,
|
||||
title: "Rated Movie",
|
||||
type: "movie",
|
||||
rating: 4,
|
||||
ratedAt: "2024-03-01T12:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload, {
|
||||
importWatches: false,
|
||||
importWatchlist: false,
|
||||
importRatings: true,
|
||||
});
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(1);
|
||||
|
||||
const ratingRows = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(eq(userRatings.userId, userId))
|
||||
.all();
|
||||
expect(ratingRows).toHaveLength(1);
|
||||
expect(ratingRows[0].ratingStars).toBe(4);
|
||||
expect(ratingRows[0].titleId).toBe("movie-r");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Job State Transitions ───────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — state transitions", () => {
|
||||
test("job starts as pending, ends as success", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-st", 111, "State Test");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "letterboxd",
|
||||
movies: [{ tmdbId: 111, title: "State Test" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
|
||||
// Before processing
|
||||
const before = readImportJob(jobId);
|
||||
expect(before.status).toBe("pending");
|
||||
expect(before.startedAt).toBeNull();
|
||||
expect(before.finishedAt).toBeNull();
|
||||
|
||||
await processImportJob(jobId);
|
||||
|
||||
// After processing
|
||||
const after = readImportJob(jobId);
|
||||
expect(after.status).toBe("success");
|
||||
expect(after.startedAt).not.toBeNull();
|
||||
expect(after.finishedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
test("empty import with no matching options succeeds with warning", async () => {
|
||||
const userId = insertUser();
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [{ tmdbId: 111, title: "A Movie" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
// Disable all import options — movies exist but importWatches is false
|
||||
const jobId = createJob(userId, payload, {
|
||||
importWatches: false,
|
||||
importWatchlist: false,
|
||||
importRatings: false,
|
||||
});
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.warnings.length).toBeGreaterThan(0);
|
||||
expect(job.warnings[0]).toContain("No items to import");
|
||||
});
|
||||
|
||||
test("readImportJob throws for non-existent job", () => {
|
||||
expect(() => readImportJob("non-existent-id")).toThrow(
|
||||
"Import job non-existent-id not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Failed Resolution ───────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — failed resolution", () => {
|
||||
test("records failure when movie cannot be resolved", async () => {
|
||||
const userId = insertUser();
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "letterboxd",
|
||||
movies: [{ title: "Completely Unknown Film ZZZZZ" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
// Mock TMDB search to return empty results (no network call)
|
||||
const searchSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
try {
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.failedCount).toBe(1);
|
||||
expect(job.importedCount).toBe(0);
|
||||
expect(job.errors.length).toBeGreaterThan(0);
|
||||
expect(job.errors[0]).toContain("Could not resolve movie");
|
||||
} finally {
|
||||
searchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import * as tmdbClient from "@sofa/tmdb/client";
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "../src/imports/resolve";
|
||||
|
||||
// The TMDB client functions (findByExternalId, searchMovies, searchTv) are
|
||||
// called by the resolve functions. We spy on them directly rather than on
|
||||
// globalThis.fetch because openapi-fetch captures the fetch reference at
|
||||
// module-init time, making globalThis.fetch mocking ineffective.
|
||||
|
||||
let findSpy: ReturnType<typeof spyOn>;
|
||||
let searchMoviesSpy: ReturnType<typeof spyOn>;
|
||||
let searchTvSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
afterEach(() => {
|
||||
findSpy?.mockRestore();
|
||||
searchMoviesSpy?.mockRestore();
|
||||
searchTvSpy?.mockRestore();
|
||||
});
|
||||
|
||||
// ── resolveMovieTmdbId ──────────────────────────────────────────────
|
||||
|
||||
describe("resolveMovieTmdbId", () => {
|
||||
test("returns tmdbId directly when provided", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId");
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
|
||||
|
||||
const result = await resolveMovieTmdbId({ tmdbId: 123 });
|
||||
expect(result).toBe(123);
|
||||
expect(findSpy).not.toHaveBeenCalled();
|
||||
expect(searchMoviesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolves via IMDB ID lookup", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [{ id: 456 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ imdbId: "tt1234567" });
|
||||
expect(result).toBe(456);
|
||||
expect(findSpy).toHaveBeenCalledWith("tt1234567", "imdb_id");
|
||||
});
|
||||
|
||||
test("falls back to TVDB lookup when no IMDB ID", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [{ id: 789 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ tvdbId: 99887 });
|
||||
expect(result).toBe(789);
|
||||
expect(findSpy).toHaveBeenCalledWith("99887", "tvdb_id");
|
||||
});
|
||||
|
||||
test("IMDB returns no movie, falls back to TVDB", async () => {
|
||||
let callIndex = 0;
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockImplementation(
|
||||
async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
// IMDB lookup — no results
|
||||
return {
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
}
|
||||
// TVDB lookup — has result
|
||||
return {
|
||||
movie_results: [{ id: 789 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await resolveMovieTmdbId({
|
||||
imdbId: "tt0000001",
|
||||
tvdbId: 99887,
|
||||
});
|
||||
expect(result).toBe(789);
|
||||
expect(findSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("falls back to title search when no IDs available", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [{ id: 321, title: "Inception", release_date: "2010-07-16" }],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ title: "Inception" });
|
||||
expect(result).toBe(321);
|
||||
expect(searchMoviesSpy).toHaveBeenCalledWith("Inception");
|
||||
});
|
||||
|
||||
test("title search with year prefers matching year", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [
|
||||
{ id: 100, title: "Dune", release_date: "1984-12-14" },
|
||||
{ id: 200, title: "Dune", release_date: "2021-10-22" },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ title: "Dune", year: 2021 });
|
||||
expect(result).toBe(200);
|
||||
});
|
||||
|
||||
test("title search without year match returns null", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [
|
||||
{ id: 100, title: "Dune", release_date: "1984-12-14" },
|
||||
{ id: 200, title: "Dune", release_date: "2021-10-22" },
|
||||
],
|
||||
} as never);
|
||||
|
||||
// year=1999 doesn't match any result — don't fall back to an arbitrary result
|
||||
const result = await resolveMovieTmdbId({ title: "Dune", year: 1999 });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when all methods fail", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({
|
||||
imdbId: "tt0000000",
|
||||
title: "NonexistentFilm",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when no identifiers at all", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId");
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
|
||||
|
||||
const result = await resolveMovieTmdbId({});
|
||||
expect(result).toBeNull();
|
||||
expect(findSpy).not.toHaveBeenCalled();
|
||||
expect(searchMoviesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cache prevents duplicate lookups", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [{ id: 555 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const cache = new Map<string, number | null>();
|
||||
|
||||
const first = await resolveMovieTmdbId({ imdbId: "tt9999999" }, cache);
|
||||
expect(first).toBe(555);
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await resolveMovieTmdbId({ imdbId: "tt9999999" }, cache);
|
||||
expect(second).toBe(555);
|
||||
// No additional calls — cache hit
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("cache stores null for unresolvable items", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
const cache = new Map<string, number | null>();
|
||||
|
||||
const first = await resolveMovieTmdbId({ title: "Nothing" }, cache);
|
||||
expect(first).toBeNull();
|
||||
|
||||
// Cache should contain a null entry
|
||||
expect(cache.size).toBe(1);
|
||||
const cachedValue = [...cache.values()][0];
|
||||
expect(cachedValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveShowTmdbId ───────────────────────────────────────────────
|
||||
|
||||
describe("resolveShowTmdbId", () => {
|
||||
test("returns tmdbId directly when provided", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId");
|
||||
|
||||
const result = await resolveShowTmdbId({ tmdbId: 42 });
|
||||
expect(result).toBe(42);
|
||||
expect(findSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolves via IMDB ID — show-level result", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [{ id: 600, name: "Breaking Bad" }],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ imdbId: "tt5555555" });
|
||||
expect(result).toBe(600);
|
||||
});
|
||||
|
||||
test("resolves via IMDB ID — episode-level result extracts show_id", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [
|
||||
{
|
||||
id: 9001,
|
||||
episode_number: 3,
|
||||
name: "Fly",
|
||||
season_number: 3,
|
||||
show_id: 700,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ imdbId: "tt7777777" });
|
||||
expect(result).toBe(700);
|
||||
});
|
||||
|
||||
test("falls back to TVDB lookup", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [{ id: 800 }],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ tvdbId: 12345 });
|
||||
expect(result).toBe(800);
|
||||
expect(findSpy).toHaveBeenCalledWith("12345", "tvdb_id");
|
||||
});
|
||||
|
||||
test("TVDB lookup extracts show_id from episode result", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [
|
||||
{
|
||||
id: 1,
|
||||
episode_number: 1,
|
||||
name: "Pilot",
|
||||
season_number: 1,
|
||||
show_id: 850,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ tvdbId: 54321 });
|
||||
expect(result).toBe(850);
|
||||
});
|
||||
|
||||
test("falls back to title search", async () => {
|
||||
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
|
||||
results: [{ id: 900, name: "The Office" }],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ title: "The Office" });
|
||||
expect(result).toBe(900);
|
||||
expect(searchTvSpy).toHaveBeenCalledWith("The Office");
|
||||
});
|
||||
|
||||
test("returns null when all methods fail", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({
|
||||
imdbId: "tt0000000",
|
||||
title: "Nothing",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("cache prevents duplicate show lookups", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [{ id: 950 }],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const cache = new Map<string, number | null>();
|
||||
|
||||
const first = await resolveShowTmdbId({ imdbId: "tt8888888" }, cache);
|
||||
expect(first).toBe(950);
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await resolveShowTmdbId({ imdbId: "tt8888888" }, cache);
|
||||
expect(second).toBe(950);
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user