From 75128d1bf21066892dee5832252da93e97a593b5 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:00:13 +0200 Subject: [PATCH] =?UTF-8?q?test:=20Importpfad=20gegen=20nachgebildete=20TM?= =?UTF-8?q?DB-Antworten=20pr=C3=BCfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 5 +- tests/catalog.test.ts | 157 ++++++++++++++++++++++ tests/fixtures/tmdb.ts | 289 +++++++++++++++++++++++++++++++++++++++++ tests/setup.ts | 21 +++ 4 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 tests/catalog.test.ts create mode 100644 tests/fixtures/tmdb.ts create mode 100644 tests/setup.ts diff --git a/package.json b/package.json index d237808..f058435 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flimmer", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "license": "MIT", @@ -15,7 +15,8 @@ "db:studio": "prisma studio", "db:seed": "tsx prisma/seed.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": { "@prisma/adapter-pg": "^7.10.0", diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts new file mode 100644 index 0000000..feba4d3 --- /dev/null +++ b/tests/catalog.test.ts @@ -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"); +}); diff --git a/tests/fixtures/tmdb.ts b/tests/fixtures/tmdb.ts new file mode 100644 index 0000000..cbda498 --- /dev/null +++ b/tests/fixtures/tmdb.ts @@ -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 = { + 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; + }, + }; +} diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..76c208f --- /dev/null +++ b/tests/setup.ts @@ -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).", + ); +}