test: Importpfad gegen nachgebildete TMDB-Antworten prüfen

npm test läuft ohne TMDB-Schlüssel gegen die Entwicklungsdatenbank und deckt
Kopfdaten, Staffeln, Episoden, Streamingdienste je Region, wiederholten
Import und die Auffrischlogik ab.

Dabei aufgefallen: importSeries gab den Titel zurück, bevor die Staffel- und
Episodenzähler geschrieben waren — die Zähler standen auf 0.
This commit is contained in:
Florian Wilde
2026-09-01 19:00:13 +02:00
parent e11cb6abef
commit 75128d1bf2
4 changed files with 470 additions and 2 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
@@ -15,7 +15,8 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"icons": "tsx scripts/generate-icons.ts", "icons": "tsx scripts/generate-icons.ts",
"screenshots": "tsx scripts/screenshots.ts" "screenshots": "tsx scripts/screenshots.ts",
"test": "node --env-file-if-exists=.env --conditions=react-server --import tsx --test tests/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
+157
View File
@@ -0,0 +1,157 @@
/**
* Prüft den TMDB-Importpfad gegen nachgebildete Antworten.
*
* npm test
*
* Braucht eine erreichbare Entwicklungsdatenbank (docker-compose.dev.yml) und
* keinen TMDB-Schlüssel. Angelegt werden ausschließlich Titel mit TMDB-IDs
* ab 999000 — echte Daten bleiben unberührt und werden am Ende aufgeräumt.
*/
import "./setup";
import assert from "node:assert/strict";
import test, { after, before } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID, COLLECTION_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import {
clearCatalogCaches,
ensureTitle,
importCollection,
importMovie,
importSeries,
} from "../src/lib/catalog";
const stub = installTmdbStub();
async function cleanup() {
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
await prisma.watchProvider.deleteMany({ where: { tmdbId: { in: [8, 30, 3, 337] } } });
}
before(cleanup);
after(async () => {
await cleanup();
stub.restore();
await prisma.$disconnect();
});
test("Serie mit Staffeln und Episoden übernehmen", async () => {
const title = await importSeries(SERIES_ID, { region: "DE" });
assert.equal(title.title, "Nordlicht");
assert.equal(title.type, "SERIES");
assert.equal(title.imdbId, "tt9990010");
assert.equal(title.inProduction, true);
assert.deepEqual(title.genres, ["Drama", "Mystery"]);
assert.equal(title.runtime, 48, "durchschnittliche Episodenlänge aus episode_run_time");
// Specials zählen nicht mit: 2 reguläre Staffeln, 5 reguläre Episoden.
assert.equal(title.seasonCount, 2);
assert.equal(title.episodeCount, 5);
const seasons = await prisma.season.findMany({
where: { titleId: title.id },
orderBy: { seasonNumber: "asc" },
});
assert.deepEqual(
seasons.map((s) => s.seasonNumber),
[0, 1, 2],
"Specials werden importiert, aber nicht mitgezählt",
);
const episodes = await prisma.episode.findMany({
where: { titleId: title.id, seasonNumber: 1 },
orderBy: { episodeNumber: "asc" },
});
assert.equal(episodes.length, 3);
assert.equal(episodes[0]?.name, "Ankunft");
assert.equal(
episodes[2]?.runtime,
48,
"Episode ohne eigene Laufzeit fällt auf die Serienlänge zurück",
);
assert.equal(episodes[0]?.airDate?.toISOString().slice(0, 10), "2023-03-14");
});
test("Streamingdienste werden je Region übernommen", async () => {
const title = await prisma.title.findUniqueOrThrow({
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
include: { providers: { include: { provider: true } } },
});
const de = title.providers.filter((p) => p.region === "DE");
assert.equal(de.length, 3, "zwei Abo-Dienste und ein Kauf-Dienst für DE");
const flatrate = de.filter((p) => p.kind === "FLATRATE").map((p) => p.provider.name).sort();
assert.deepEqual(flatrate, ["Netflix", "WOW"]);
const netflix = de.find((p) => p.provider.name === "Netflix");
assert.equal(netflix?.provider.color, "#e50914", "bekannter Dienst bekommt seine Akzentfarbe");
const at = title.providers.filter((p) => p.region === "AT");
assert.equal(at.length, 0, "nur die angeforderte Region wird geschrieben");
});
test("erneuter Import legt nichts doppelt an", async () => {
clearCatalogCaches();
const before = await prisma.episode.count({
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
});
await importSeries(SERIES_ID, { region: "DE" });
const after = await prisma.episode.count({
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
});
assert.equal(after, before, "Episoden werden aktualisiert, nicht dupliziert");
const titles = await prisma.title.count({ where: { tmdbId: SERIES_ID, type: "SERIES" } });
assert.equal(titles, 1);
});
test("Film mit Collection übernehmen", async () => {
const movie = await importMovie(MOVIE_ID, "DE");
assert.equal(movie.type, "MOVIE");
assert.equal(movie.runtime, 118);
assert.equal(movie.tmdbCollectionId, COLLECTION_ID);
assert.equal(movie.tmdbCollectionName, "Vorführer-Reihe");
assert.equal(movie.releaseDate?.toISOString().slice(0, 10), "2024-10-17");
});
test("Collection wird nach Erscheinungsdatum sortiert importiert", async () => {
clearCatalogCaches();
const { collection, titles } = await importCollection(COLLECTION_ID, "DE");
assert.equal(collection.name, "Vorführer-Reihe");
assert.equal(titles.length, 2);
assert.deepEqual(
titles.map((t) => t.tmdbId),
[MOVIE_ID, MOVIE_2_ID],
"die Fixture liefert die Teile verdreht — der Import muss sortieren",
);
});
test("ensureTitle greift auf den Katalog zurück, statt erneut abzurufen", async () => {
clearCatalogCaches();
const countBefore = stub.calls.length;
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
assert.equal(stub.calls.length, countBefore, "frisch importierter Titel löst keinen Abruf aus");
});
test("ensureTitle frischt veraltete Einträge auf", async () => {
clearCatalogCaches();
await prisma.title.update({
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
data: { syncedAt: new Date(Date.now() - 40 * 24 * 3_600_000) },
});
const countBefore = stub.calls.length;
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
assert.ok(stub.calls.length > countBefore, "veralteter Eintrag wird neu geholt");
});
+289
View File
@@ -0,0 +1,289 @@
/**
* Nachgebildete TMDB-Antworten.
*
* Damit lässt sich der komplette Importpfad (Kopfdaten, Staffeln, Episoden,
* Streamingdienste, Collections) prüfen, ohne einen echten Schlüssel zu
* brauchen — und ohne bei jedem Lauf die TMDB-API zu belasten. Der Aufbau
* entspricht dem, was TMDB tatsächlich liefert; gekürzt auf die Felder, die
* Flimmer verwendet.
*/
export const SERIES_ID = 999001;
export const MOVIE_ID = 999002;
export const MOVIE_2_ID = 999003;
export const COLLECTION_ID = 999100;
export const seriesDetails = {
id: SERIES_ID,
name: "Nordlicht",
original_name: "Nordlys",
overview:
"Eine Meteorologin kehrt in ihr Heimatdorf am Polarkreis zurück und stößt auf ein Wetterphänomen, das es nicht geben dürfte.",
tagline: "Manche Lichter wollen nicht gesehen werden.",
poster_path: "/abcdef1234567890.jpg",
backdrop_path: "/bcdefa1234567890.jpg",
first_air_date: "2023-03-14",
last_air_date: "2025-05-02",
episode_run_time: [48],
status: "Returning Series",
in_production: true,
genres: [
{ id: 18, name: "Drama" },
{ id: 9648, name: "Mystery" },
],
original_language: "no",
homepage: "https://example.org/nordlicht",
vote_average: 8.1,
number_of_seasons: 2,
number_of_episodes: 5,
external_ids: { imdb_id: "tt9990010" },
seasons: [
{
id: 500000,
season_number: 0,
name: "Specials",
overview: "Zusätzliche Folgen außerhalb der Handlung.",
poster_path: null,
air_date: "2023-12-24",
episode_count: 1,
},
{
id: 500001,
season_number: 1,
name: "Staffel 1",
overview: "Die Rückkehr.",
poster_path: "/cdefab1234567890.jpg",
air_date: "2023-03-14",
episode_count: 3,
},
{
id: 500002,
season_number: 2,
name: "Staffel 2",
overview: "Der lange Winter.",
poster_path: "/defabc1234567890.jpg",
air_date: "2025-04-11",
episode_count: 2,
},
],
"watch/providers": {
results: {
DE: {
link: "https://www.themoviedb.org/tv/999001/watch?locale=DE",
flatrate: [
{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" },
{ provider_id: 30, provider_name: "WOW", logo_path: "/wowlogo123456789.jpg" },
],
buy: [
{
provider_id: 3,
provider_name: "Google Play Movies",
logo_path: "/googleplay123456.jpg",
},
],
},
AT: {
flatrate: [{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" }],
},
},
},
};
export const seasons: Record<number, unknown> = {
0: {
id: 500000,
season_number: 0,
name: "Specials",
air_date: "2023-12-24",
episodes: [
{
id: 600000,
season_number: 0,
episode_number: 1,
name: "Weihnachten am Fjord",
overview: "Eine Folge außer der Reihe.",
still_path: "/still000000000001.jpg",
air_date: "2023-12-24",
runtime: 30,
},
],
},
1: {
id: 500001,
season_number: 1,
name: "Staffel 1",
air_date: "2023-03-14",
episodes: [
{
id: 600001,
season_number: 1,
episode_number: 1,
name: "Ankunft",
overview: "Ida steigt in Bodø aus dem Zug.",
still_path: "/still000000000002.jpg",
air_date: "2023-03-14",
runtime: 52,
},
{
id: 600002,
season_number: 1,
episode_number: 2,
name: "Der Messwert",
overview: "Ein Gerät zeigt etwas Unmögliches an.",
still_path: null,
air_date: "2023-03-21",
runtime: 47,
},
{
id: 600003,
season_number: 1,
episode_number: 3,
name: "Polarnacht",
overview: "Die Sonne bleibt weg — länger als sie darf.",
still_path: null,
air_date: "2023-03-28",
// Ohne Laufzeit: muss auf episode_run_time zurückfallen.
runtime: null,
},
],
},
2: {
id: 500002,
season_number: 2,
name: "Staffel 2",
air_date: "2025-04-11",
episodes: [
{
id: 600004,
season_number: 2,
episode_number: 1,
name: "Rückkehr des Lichts",
overview: null,
still_path: null,
air_date: "2025-04-11",
runtime: 50,
},
{
id: 600005,
season_number: 2,
episode_number: 2,
name: "Der zweite Winter",
overview: null,
still_path: null,
// Noch nicht ausgestrahlt — kommt in Etappe 5 im Kalender vor.
air_date: "2099-01-08",
runtime: 50,
},
],
},
};
export const movieDetails = {
id: MOVIE_ID,
imdb_id: "tt9990020",
title: "Die Stille zwischen den Bildern",
original_title: "The Silence Between Frames",
overview: "Ein Filmvorführer entdeckt in einer alten Rolle Bilder, die dort nicht sein können.",
tagline: "24 Bilder pro Sekunde. Eins davon lügt.",
poster_path: "/movie0001234567890.jpg",
backdrop_path: "/movieback01234567.jpg",
release_date: "2024-10-17",
runtime: 118,
status: "Released",
genres: [
{ id: 27, name: "Horror" },
{ id: 53, name: "Thriller" },
],
original_language: "en",
homepage: null,
vote_average: 7.4,
belongs_to_collection: {
id: COLLECTION_ID,
name: "Vorführer-Reihe",
poster_path: "/collection00123456.jpg",
},
"watch/providers": {
results: {
DE: {
flatrate: [
{ provider_id: 337, provider_name: "Disney Plus", logo_path: "/disney1234567890.jpg" },
],
rent: [
{ provider_id: 3, provider_name: "Google Play Movies", logo_path: "/googleplay123456.jpg" },
],
},
},
},
};
export const movie2Details = {
...movieDetails,
id: MOVIE_2_ID,
imdb_id: "tt9990030",
title: "Die Stille zwischen den Bildern II",
original_title: "The Silence Between Frames II",
release_date: "2026-02-06",
runtime: 126,
poster_path: "/movie0002234567890.jpg",
"watch/providers": { results: {} },
};
export const collectionDetails = {
id: COLLECTION_ID,
name: "Vorführer-Reihe",
overview: "Zwei Filme über alte Projektoren und das, was sie zeigen.",
poster_path: "/collection00123456.jpg",
backdrop_path: null,
parts: [
// Absichtlich in falscher Reihenfolge — der Import muss nach Datum sortieren.
{ id: MOVIE_2_ID, title: movie2Details.title, release_date: "2026-02-06" },
{ id: MOVIE_ID, title: movieDetails.title, release_date: "2024-10-17" },
],
};
/**
* Ersetzt `fetch` durch eine Fassung, die aus den Fixtures antwortet.
* Liefert eine Funktion zum Zurücksetzen sowie die Liste der Aufrufe.
*/
export function installTmdbStub() {
const original = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input.toString());
calls.push(url.pathname);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
const path = url.pathname;
if (path === `/3/tv/${SERIES_ID}`) return json(seriesDetails);
const seasonMatch = path.match(new RegExp(`^/3/tv/${SERIES_ID}/season/(\\d+)$`));
if (seasonMatch?.[1] !== undefined) {
const number = Number(seasonMatch[1]);
const season = seasons[number];
return season
? json(season)
: new Response(JSON.stringify({ status_message: "Not found" }), { status: 404 });
}
if (path === `/3/movie/${MOVIE_ID}`) return json(movieDetails);
if (path === `/3/movie/${MOVIE_2_ID}`) return json(movie2Details);
if (path === `/3/collection/${COLLECTION_ID}`) return json(collectionDetails);
return new Response(JSON.stringify({ status_message: `Kein Fixture für ${path}` }), {
status: 404,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return {
calls,
restore() {
globalThis.fetch = original;
},
};
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Testumgebung.
*
* Muss als allererster Import stehen: statische Importe werden vor jedem
* anderen Anweisungscode ausgeführt, und die Module lesen ihre Konfiguration
* beim Laden. Die Werte hier sind Platzhalter — die Tests sprechen nie mit
* der echten TMDB-API.
*/
process.env.TMDB_API_KEY = process.env.TMDB_API_KEY || "fixture-key";
process.env.TMDB_REGION = "DE";
process.env.TMDB_LANGUAGE = "de-DE";
process.env.AUTH_SECRET = process.env.AUTH_SECRET || "fixture-secret-fixture-secret-fixture";
process.env.APP_URL = process.env.APP_URL || "http://localhost:3011";
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL fehlt. Tests brauchen die Entwicklungsdatenbank:\n" +
" docker compose -f docker-compose.dev.yml up -d\n" +
"und eine .env (npm test lädt sie mit --env-file).",
);
}