Files
flimmer/tests/library.test.ts
T
Florian Wilde 9ee8664233
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
feat: eigene Sammlung — „hab ich" mit Quelle
Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen
nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe.

Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel
gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört,
und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der
Verfügbarkeit — was man hat, ist die verlässlichere Antwort.

Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
2026-09-01 21:15:53 +02:00

391 lines
14 KiB
TypeScript

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 } });
});
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] } } });
});