From 4c3c878980774d6bc93a99ebfef8766ba8a442ce Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:24:50 +0200 Subject: [PATCH] test: Kern-Tracking absichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 Tests für Aufnehmen, Abhaken, Fortschritt ohne Specials, Rewatch, Papierkorb, Rückgängig und den Zugriffsschutz zwischen Nutzern. Die Testdateien laufen jetzt seriell: parallel räumten sie sich gegenseitig die gemeinsamen Katalog-Fixtures weg. --- package.json | 4 +- tests/library.test.ts | 338 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 tests/library.test.ts diff --git a/package.json b/package.json index f058435..8209585 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flimmer", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "license": "MIT", @@ -16,7 +16,7 @@ "db:seed": "tsx prisma/seed.ts", "icons": "tsx scripts/generate-icons.ts", "screenshots": "tsx scripts/screenshots.ts", - "test": "node --env-file-if-exists=.env --conditions=react-server --import tsx --test tests/*.test.ts" + "test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts" }, "dependencies": { "@prisma/adapter-pg": "^7.10.0", diff --git a/tests/library.test.ts b/tests/library.test.ts new file mode 100644 index 0000000..d18d675 --- /dev/null +++ b/tests/library.test.ts @@ -0,0 +1,338 @@ +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 } }); +});