CI / pruefen (pull_request) Canceled after 0s
War ein Film einmal als gesehen markiert, gab es keinen Weg zurueck: im
Zustand "gesehen" stand statt eines Knopfes ein <p> — ein Etikett ohne
Klickziel. Der Statuswechsel konnte das nicht heilen, weil "gesehen" aus den
Sichtungen abgeleitet wird und setStatus() die absichtlich nicht anfasst.
Und eine Gegenfunktion fuer Filme gab es nicht: unmarkEpisodes() und
unmarkSeason() waren da, ein unmarkMovie() nie geschrieben.
unmarkMovie() ist auf run: entry.currentRun und episodeId: null eingegrenzt.
Wer einen Film dreimal gesehen hat, hat drei Sichtungen — ohne die
Eingrenzung loeschte das Zuruecknehmen alle drei und verfaelschte
rueckwirkend die Statistik.
Die gruene Plakette bleibt stehen, ist jetzt aber klickbar, so wie sich
Episodenhaekchen auch abwaehlen lassen. Ueber die Schnittstelle geht es mit
DELETE {"action":"movie"} als Gegenstueck zum gleichnamigen POST, ohne dass
man die watchIds kennen muss.
Drei Tests: Zuruecknehmen samt erneutem Abhaken, die Eingrenzung auf den
laufenden Durchlauf, und dass Serien abgewiesen werden. Klickweg und
Endpunkt gegen einen Produktionsbau geprueft.
Der im Issue vermutete Nebenpunkt trifft nicht zu: report() blendet das
Zuruecknehmen nur bei ids.length > 0 ein, ein wirkungsloses Angebot gab es
also nie.
Refs #5
445 lines
16 KiB
TypeScript
445 lines
16 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,
|
|
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/);
|
|
});
|