import "./setup"; import assert from "node:assert/strict"; import test, { after, before, beforeEach } from "node:test"; import { installTmdbStub, SERIES_ID, MOVIE_ID } from "./fixtures/tmdb"; import { prisma } from "../src/lib/db"; import { importMovie, importSeries } from "../src/lib/catalog"; import { addToLibrary, getNextEpisode, getProgress, getWatchedEpisodeIds, markEpisodesWatched, markMovieWatched, markSeasonWatched, markUpTo, moveToTrash, restoreFromTrash, setStatus, startRewatch, undoWatches, unmarkEpisodes, updateEntryDetails, watchNextEpisode, } from "../src/lib/library"; /** * Prüft das Kern-Tracking: Aufnehmen, Abhaken, Fortschritt, Rewatch. * Läuft gegen die Entwicklungsdatenbank mit eigenem Testkonto und * nachgebildeten TMDB-Daten. */ const stub = installTmdbStub(); const TEST_EMAIL = "test-library@flimmer.invalid"; const OTHER_EMAIL = "test-library-other@flimmer.invalid"; let userId: string; let seriesTitleId: string; let movieTitleId: string; async function cleanup() { await prisma.user.deleteMany({ where: { email: { in: [TEST_EMAIL, OTHER_EMAIL] } } }); await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } }); } before(async () => { await cleanup(); const user = await prisma.user.create({ data: { email: TEST_EMAIL, name: "Testkonto", region: "DE" }, select: { id: true }, }); userId = user.id; seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id; movieTitleId = (await importMovie(MOVIE_ID, "DE")).id; }); beforeEach(async () => { // Jeder Test startet mit einer leeren Bibliothek, aber demselben Katalog. await prisma.watch.deleteMany({ where: { userId } }); await prisma.libraryEntry.deleteMany({ where: { userId } }); }); after(async () => { await cleanup(); stub.restore(); await prisma.$disconnect(); }); /** Alle regulären Episoden der Testserie, in Reihenfolge. */ async function regularEpisodes() { return prisma.episode.findMany({ where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } }, orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], select: { id: true, seasonNumber: true, episodeNumber: true, runtime: true }, }); } test("Titel aufnehmen ist wiederholbar und schlägt den Streamingdienst vor", async () => { const first = await addToLibrary(userId, seriesTitleId, "WATCHLIST"); const second = await addToLibrary(userId, seriesTitleId, "WATCHING"); assert.equal(first.id, second.id, "kein zweiter Eintrag für denselben Titel"); assert.equal(first.status, "WATCHLIST"); assert.equal( first.whereToWatch, "Netflix", "der erste Abo-Dienst der Region wird als Startwert übernommen", ); }); test("Papierkorb behält den Fortschritt", async () => { const entry = await addToLibrary(userId, seriesTitleId); const episodes = await regularEpisodes(); await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); await moveToTrash(userId, entry.id); const trashed = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } }); assert.ok(trashed.deletedAt, "liegt im Papierkorb statt gelöscht zu sein"); // Erneutes Aufnehmen belebt denselben Eintrag wieder. const revived = await addToLibrary(userId, seriesTitleId); assert.equal(revived.id, entry.id); assert.equal(revived.deletedAt, null); const progress = await getProgress(userId, entry.id); assert.equal(progress.watched, 1, "die Sichtung hat den Umweg überlebt"); await moveToTrash(userId, entry.id); await restoreFromTrash(userId, entry.id); const restored = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } }); assert.equal(restored.deletedAt, null); }); test("Fortschritt zählt Specials nicht mit", async () => { const entry = await addToLibrary(userId, seriesTitleId); const progress = await getProgress(userId, entry.id); assert.equal(progress.total, 5, "5 reguläre Episoden, die Weihnachtsfolge zählt nicht"); assert.equal(progress.watched, 0); assert.equal(progress.percent, 0); const special = await prisma.episode.findFirstOrThrow({ where: { titleId: seriesTitleId, seasonNumber: 0 }, }); await markEpisodesWatched(userId, entry.id, [special.id]); const after = await getProgress(userId, entry.id); assert.equal(after.watched, 0, "ein Special bewegt den Fortschrittsbalken nicht"); assert.ok(after.minutesWatched > 0, "die Schauzeit wird trotzdem gezählt"); }); test("nächste Episode folgt der Reihenfolge und überspringt Specials", async () => { const entry = await addToLibrary(userId, seriesTitleId); const first = await getNextEpisode(userId, entry.id); assert.equal(first?.seasonNumber, 1); assert.equal(first?.episodeNumber, 1); await watchNextEpisode(userId, entry.id); const second = await getNextEpisode(userId, entry.id); assert.equal(second?.episodeNumber, 2); // Über die Staffelgrenze hinweg await markSeasonWatched( userId, entry.id, (await prisma.season.findFirstOrThrow({ where: { titleId: seriesTitleId, seasonNumber: 1 } })) .id, ); const third = await getNextEpisode(userId, entry.id); assert.equal(third?.seasonNumber, 2, "nach Staffel 1 kommt Staffel 2"); assert.equal(third?.episodeNumber, 1); }); test("Status ergibt sich aus den Sichtungen", async () => { const entry = await addToLibrary(userId, seriesTitleId, "WATCHLIST"); const episodes = await regularEpisodes(); const afterFirst = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); assert.equal(afterFirst.status, "WATCHING", "die erste Episode startet die Serie"); const afterAll = await markEpisodesWatched( userId, entry.id, episodes.map((episode) => episode.id), ); assert.equal(afterAll.status, "COMPLETED", "die letzte Episode schließt sie ab"); const completed = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } }); assert.ok(completed.startedAt && completed.finishedAt); await unmarkEpisodes(userId, entry.id, [episodes[0]!.id]); const reopened = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } }); assert.equal(reopened.status, "WATCHING", "eine zurückgenommene Episode öffnet sie wieder"); assert.equal(reopened.finishedAt, null); }); test("Pausiert und Abgebrochen bleiben stehen", async () => { const entry = await addToLibrary(userId, seriesTitleId); const episodes = await regularEpisodes(); await setStatus(userId, entry.id, "PAUSED"); const result = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); assert.equal(result.status, "PAUSED", "eine Sichtung hebt die Pause nicht selbsttätig auf"); await setStatus(userId, entry.id, "DROPPED"); const dropped = await markEpisodesWatched(userId, entry.id, [episodes[1]!.id]); assert.equal(dropped.status, "DROPPED"); }); test("„bis hierhin gesehen\" hakt alles davor ab", async () => { const entry = await addToLibrary(userId, seriesTitleId); const episodes = await regularEpisodes(); // Staffel 2, Episode 1 → alles aus Staffel 1 plus diese eine const target = episodes.find((e) => e.seasonNumber === 2 && e.episodeNumber === 1)!; const result = await markUpTo(userId, entry.id, target.id); assert.equal(result.watchIds.length, 4, "3 aus Staffel 1 und die Zielepisode"); const progress = await getProgress(userId, entry.id); assert.equal(progress.watched, 4); assert.equal(progress.percent, 80); }); test("doppeltes Abhaken legt keine zweite Sichtung an", async () => { const entry = await addToLibrary(userId, seriesTitleId); const episodes = await regularEpisodes(); await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); const again = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); assert.equal(again.watchIds.length, 0); const count = await prisma.watch.count({ where: { userId, episodeId: episodes[0]!.id } }); assert.equal(count, 1); }); test("Rückgängig entfernt genau die eben angelegten Sichtungen", async () => { const entry = await addToLibrary(userId, seriesTitleId); const episodes = await regularEpisodes(); await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); const season = await markSeasonWatched( userId, entry.id, (await prisma.season.findFirstOrThrow({ where: { titleId: seriesTitleId, seasonNumber: 1 } })) .id, ); assert.equal(season.watchIds.length, 2, "die bereits gesehene Episode ist nicht dabei"); await undoWatches(userId, entry.id, season.watchIds); const progress = await getProgress(userId, entry.id); assert.equal(progress.watched, 1, "die erste Episode bleibt gesehen"); }); test("Rewatch setzt den Fortschritt zurück, behält aber die Schauzeit", async () => { const entry = await addToLibrary(userId, seriesTitleId); const episodes = await regularEpisodes(); await markEpisodesWatched( userId, entry.id, episodes.map((episode) => episode.id), ); const firstRun = await getProgress(userId, entry.id); assert.equal(firstRun.percent, 100); await startRewatch(userId, entry.id); const secondRun = await getProgress(userId, entry.id); assert.equal(secondRun.watched, 0, "der neue Durchlauf beginnt bei null"); assert.equal(secondRun.minutesWatched, 0); const total = await prisma.watch.aggregate({ where: { userId }, _sum: { minutes: true } }); assert.equal( total._sum.minutes, firstRun.minutesWatched, "die Sichtungen des ersten Durchlaufs bleiben für die Statistik erhalten", ); // Dieselbe Episode lässt sich im neuen Durchlauf erneut abhaken. const result = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); assert.equal(result.watchIds.length, 1); const watched = await getWatchedEpisodeIds(userId, entry.id); assert.deepEqual([...watched], [episodes[0]!.id], "die Häkchen zeigen nur den aktuellen Durchlauf"); }); test("verbleibende Laufzeit schrumpft mit dem Fortschritt", async () => { const entry = await addToLibrary(userId, seriesTitleId); const episodes = await regularEpisodes(); const before = await getProgress(userId, entry.id); const sum = episodes.reduce((total, episode) => total + (episode.runtime ?? 0), 0); assert.equal(before.minutesLeft, sum); await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]); const after = await getProgress(userId, entry.id); assert.equal(after.minutesLeft, sum - (episodes[0]!.runtime ?? 0)); assert.equal(after.minutesWatched, episodes[0]!.runtime); }); test("Filme: gesehen, Rewatch und Laufzeit", async () => { const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); const result = await markMovieWatched(userId, entry.id); assert.equal(result.status, "COMPLETED"); assert.equal(result.watchIds.length, 1); const progress = await getProgress(userId, entry.id); assert.equal(progress.percent, 100); assert.equal(progress.minutesWatched, 118); assert.equal(progress.minutesLeft, 0); const again = await markMovieWatched(userId, entry.id); assert.equal(again.watchIds.length, 0, "zweimal im selben Durchlauf zählt nicht doppelt"); await startRewatch(userId, entry.id); await markMovieWatched(userId, entry.id); const runs = await prisma.watch.count({ where: { userId, entryId: entry.id } }); assert.equal(runs, 2, "der zweite Durchlauf wird eigenständig gezählt"); }); test("Bewertung, Notiz und Fundort lassen sich pflegen", async () => { const entry = await addToLibrary(userId, movieTitleId); const updated = await updateEntryDetails(userId, entry.id, { rating: 9, notes: " Zweimal gesehen, beim zweiten Mal besser. ", whereToWatch: "Blu-ray", favorite: true, }); assert.equal(updated.rating, 9); assert.equal(updated.notes, "Zweimal gesehen, beim zweiten Mal besser.", "Rand wird beschnitten"); assert.equal(updated.whereToWatch, "Blu-ray"); assert.equal(updated.favorite, true); await assert.rejects( () => updateEntryDetails(userId, entry.id, { rating: 11 }), /zwischen 1 und 10/, ); }); test("fremde Einträge sind nicht erreichbar", async () => { const other = await prisma.user.create({ data: { email: OTHER_EMAIL, name: "Andere" }, select: { id: true }, }); const entry = await addToLibrary(userId, movieTitleId); await assert.rejects( () => updateEntryDetails(other.id, entry.id, { rating: 1 }), /gehört nicht zu deiner Bibliothek/, ); await prisma.user.delete({ where: { id: other.id } }); });