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, unmarkMovie, 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 } }); }); test("Sammlung ist unabhängig von der Streaming-Verfügbarkeit", async () => { const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); const fresh = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } }); assert.equal(fresh.owned, false, "standardmäßig gehört einem nichts"); assert.equal(fresh.ownedSource, null); const owned = await updateEntryDetails(userId, entry.id, { owned: true, ownedSource: " Jellyfin ", }); assert.equal(owned.owned, true); assert.equal(owned.ownedSource, "Jellyfin", "Rand wird beschnitten"); // Der Fundort bleibt davon unberührt: das eine sagt, wo es läuft, // das andere, wo es bei mir liegt. assert.equal( owned.whereToWatch, "Disney Plus", "der Vorschlag aus der Verfügbarkeit bleibt davon unberührt", ); const released = await updateEntryDetails(userId, entry.id, { owned: false, ownedSource: "", }); assert.equal(released.owned, false); assert.equal(released.ownedSource, null, "leerer Text wird zu null"); }); test("Sammlung: Filter und Zähler in der Bibliothek", async () => { const movie = await addToLibrary(userId, movieTitleId, "WATCHLIST"); const series = await addToLibrary(userId, seriesTitleId, "WATCHING"); await updateEntryDetails(userId, movie.id, { owned: true, ownedSource: "Blu-ray" }); const { getLibrary, getLibraryFacets } = await import("../src/lib/queries"); const alle = await getLibrary(userId, {}); assert.equal(alle.length, 2); const nurSammlung = await getLibrary(userId, { owned: true }); assert.equal(nurSammlung.length, 1); assert.equal(nurSammlung[0]?.entryId, movie.id); assert.equal(nurSammlung[0]?.ownedSource, "Blu-ray"); const facets = await getLibraryFacets(userId); assert.equal(facets.owned, 1, "der Zähler der Filterleiste stimmt"); assert.equal(facets.counts.ALLE, 2); await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } }); }); test("Filme: die Sichtung laesst sich wieder zuruecknehmen", async () => { const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); await markMovieWatched(userId, entry.id); assert.equal((await getProgress(userId, entry.id)).percent, 100); await unmarkMovie(userId, entry.id); const zurueck = await getProgress(userId, entry.id); assert.equal(zurueck.percent, 0, "der Fortschritt steht wieder auf null"); assert.equal(zurueck.minutesWatched, 0); assert.equal( await prisma.watch.count({ where: { userId, entryId: entry.id } }), 0, "die Sichtung ist weg", ); // Und danach laesst es sich erneut abhaken — kein Einbahnzustand. const erneut = await markMovieWatched(userId, entry.id); assert.equal(erneut.watchIds.length, 1); }); test("Filme: das Zuruecknehmen betrifft nur den laufenden Durchlauf", async () => { const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); await markMovieWatched(userId, entry.id); await startRewatch(userId, entry.id); await markMovieWatched(userId, entry.id); assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 2); await unmarkMovie(userId, entry.id); const rest = await prisma.watch.findMany({ where: { userId, entryId: entry.id }, select: { run: true }, }); assert.deepEqual( rest.map((watch) => watch.run), [0], "die Sichtung des ersten Durchlaufs bleibt fuer die Statistik stehen", ); assert.equal( (await getProgress(userId, entry.id)).percent, 0, "der laufende Durchlauf steht wieder auf null", ); }); test("unmarkMovie weist Serien ab", async () => { const entry = await addToLibrary(userId, seriesTitleId, "WATCHING"); await assert.rejects(() => unmarkMovie(userId, entry.id), /keine Filmsichtung/); });