Files
flimmer/tests/transfer.test.ts
T
Florian Wilde 96a42594a9 feat: Erscheinungsmeldung für Watchlist-Filme
Der Auftrag nennt sie ausdrücklich, das Profil hatte den Schalter schon —
verschickt wurde bisher nichts.

Betrachtet wird ein Zeitfenster der letzten Tage statt eines Zustandswechsels:
TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
trotzdem nie.

Die Meldung hängt nicht am TMDB-Abruf, sondern an den Erscheinungsdaten, die
ohnehin im Katalog stehen — sie funktioniert also auch ohne Schlüssel.
2026-09-01 20:50:17 +02:00

278 lines
9.6 KiB
TypeScript

import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { installTmdbStub, MOVIE_ID, SERIES_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library";
import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer";
import { purgeExpired, runSync, TRASH_RETENTION_DAYS } from "../src/lib/sync";
import { notifyUser } from "../src/lib/notify";
/** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */
const stub = installTmdbStub();
const TEST_EMAIL = "test-transfer@flimmer.invalid";
let userId: string;
let movieTitleId: string;
async function cleanup() {
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
await prisma.title.deleteMany({ where: { status: { in: ["Importiert", "Manuell angelegt"] } } });
}
before(async () => {
await cleanup();
userId = (
await prisma.user.create({ data: { email: TEST_EMAIL, name: "Transfer" }, select: { id: true } })
).id;
await importSeries(SERIES_ID, { region: "DE" });
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
});
beforeEach(async () => {
await prisma.watch.deleteMany({ where: { userId } });
await prisma.libraryEntry.deleteMany({ where: { userId } });
await prisma.notificationLog.deleteMany({ where: { userId } });
await prisma.title.deleteMany({ where: { status: { in: ["Importiert", "Manuell angelegt"] } } });
});
after(async () => {
await cleanup();
stub.restore();
await prisma.$disconnect();
});
test("CSV wird mit Komma und mit Semikolon gelesen", () => {
const comma = parseCsv('Titel,Typ\nNordlicht,Serie\n"Ein, Titel",Film\n');
assert.deepEqual(comma, [
["Titel", "Typ"],
["Nordlicht", "Serie"],
["Ein, Titel", "Film"],
]);
const semicolon = parseCsv("Titel;Typ\nNordlicht;Serie\n");
assert.deepEqual(semicolon, [
["Titel", "Typ"],
["Nordlicht", "Serie"],
]);
const quoted = parseCsv('Titel,Notiz\nPfand,"Zeile eins\nZeile zwei"\n');
assert.equal(quoted[1]?.[1], "Zeile eins\nZeile zwei", "Zeilenumbrüche in Anführungszeichen");
});
test("CSV-Import legt an, ordnet zu und überschreibt nichts", async () => {
const csv = [
"Titel;Typ;Status;Bewertung;Notiz",
"Die Stille zwischen den Bildern;Film;Abgeschlossen;8;Sehr gut",
"Ein Film, den es nicht gibt;Film;Watchlist;;",
].join("\n");
const first = await importCsv(userId, csv);
assert.equal(first.processed, 2);
assert.equal(first.added, 2);
assert.deepEqual(first.errors, []);
// Der bekannte Titel wurde einem vorhandenen Katalogeintrag zugeordnet,
// statt ein zweites Mal angelegt zu werden.
const known = await prisma.libraryEntry.findFirst({
where: { userId, title: { title: "Die Stille zwischen den Bildern" } },
select: { rating: true, status: true, notes: true, title: { select: { tmdbId: true } } },
});
assert.ok(known, "der vorhandene Katalogtitel wurde erkannt");
assert.ok(known.title.tmdbId, "es wurde kein zweiter, leerer Titel angelegt");
assert.equal(known.status, "COMPLETED");
assert.equal(known.rating, 8);
assert.equal(known.notes, "Sehr gut");
// Der unbekannte wurde als manueller Titel angelegt.
const created = await prisma.title.findFirst({
where: { title: "Ein Film, den es nicht gibt" },
select: { tmdbId: true },
});
assert.ok(created);
assert.equal(created.tmdbId, null, "manuell angelegte Titel haben keine TMDB-Kennung");
// Zweiter Durchlauf ändert nichts.
const second = await importCsv(userId, csv);
assert.equal(second.added, 0);
assert.equal(second.skipped, 2, "vorhandene Einträge bleiben unangetastet");
});
test("CSV ohne Titelspalte wird abgelehnt", async () => {
const result = await importCsv(userId, "Jahr;Status\n2024;Watchlist\n");
assert.equal(result.added, 0);
assert.match(result.errors[0] ?? "", /Titelspalte/);
});
test("Trakt-Export wird verstanden", async () => {
const trakt = JSON.stringify([
{
show: { title: "Nordlicht", year: 2023, ids: { tmdb: 999001 } },
last_watched_at: "2025-04-01T20:00:00.000Z",
plays: 8,
},
{ movie: { title: "Hochwasser", year: 2023, ids: { tmdb: 9100015 } }, rating: 9 },
]);
const result = await importTrakt(userId, trakt);
assert.equal(result.processed, 2);
assert.equal(result.added, 2);
const entries = await prisma.libraryEntry.findMany({
where: { userId },
include: { title: { select: { title: true, type: true } } },
});
const series = entries.find((entry) => entry.title.title === "Nordlicht");
assert.equal(series?.title.type, "SERIES");
assert.equal(series?.status, "COMPLETED", "was gespielt wurde, gilt als gesehen");
const movie = entries.find((entry) => entry.title.title === "Hochwasser");
assert.equal(movie?.title.type, "MOVIE");
assert.equal(movie?.status, "WATCHLIST");
assert.equal(movie?.rating, 9);
});
test("Trakt-Datei ohne brauchbaren Inhalt meldet das", async () => {
const kaputt = await importTrakt(userId, "kein json");
assert.match(kaputt.errors[0] ?? "", /JSON/);
const leer = await importTrakt(userId, "[]");
assert.match(leer.errors[0] ?? "", /keine Einträge/);
});
test("Export enthält Bibliothek und Sichtungen", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHING");
await markMovieWatched(userId, entry.id);
const csv = await exportCsv(userId);
const lines = csv.split("\n");
assert.match(lines[0] ?? "", /^Titel,Typ,Jahr,TMDB,Status/);
assert.equal(lines.length, 2, "Kopfzeile plus ein Eintrag");
assert.match(lines[1] ?? "", /Die Stille zwischen den Bildern/);
const json = (await exportJson(userId)) as {
format: string;
library: unknown[];
watches: { minutes: number }[];
};
assert.equal(json.format, "flimmer-export");
assert.equal(json.library.length, 1);
assert.equal(json.watches.length, 1);
assert.equal(json.watches[0]?.minutes, 118);
});
test("der Papierkorb wird erst nach der Frist geleert", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHING");
await markMovieWatched(userId, entry.id);
await moveToTrash(userId, entry.id);
// Frisch im Papierkorb: bleibt liegen.
await purgeExpired();
assert.ok(await prisma.libraryEntry.findUnique({ where: { id: entry.id } }));
// Über die Frist hinaus: verschwindet samt Sichtungen.
await prisma.libraryEntry.update({
where: { id: entry.id },
data: { deletedAt: new Date(Date.now() - (TRASH_RETENTION_DAYS + 1) * 86_400_000) },
});
const purged = await purgeExpired();
assert.ok(purged.entries >= 1);
assert.equal(await prisma.libraryEntry.findUnique({ where: { id: entry.id } }), null);
assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 0);
});
test("abgelaufene Sitzungen werden mit aufgeräumt", async () => {
await prisma.session.create({
data: {
userId,
tokenHash: `abgelaufen-${Date.now()}`,
expiresAt: new Date(Date.now() - 86_400_000),
},
});
const result = await purgeExpired();
assert.ok(result.sessions >= 1);
assert.equal(await prisma.session.count({ where: { userId } }), 0);
});
test("dieselbe Nachricht wird nicht zweimal verschickt", async () => {
// Ohne Kanal wird nichts verschickt — der Doppelschutz greift trotzdem.
const message = {
subject: "Neue Episode",
body: "S01E01",
dedupeKey: "episode:test-1",
};
const first = await notifyUser(userId, message);
assert.equal(first.sent, 0);
assert.equal(first.skipped, 1, "ohne Kanal gibt es nichts zu tun");
await prisma.notificationChannel.create({
data: {
userId,
kind: "WEBHOOK",
name: "Test",
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
},
});
const second = await notifyUser(userId, message);
assert.equal(second.failed, 1, "der Kanal ist nicht erreichbar");
const third = await notifyUser(userId, message);
assert.equal(
third.skipped,
1,
"auch ein Fehlversuch wird protokolliert — sonst versucht es jeder Lauf erneut",
);
const logs = await prisma.notificationLog.count({ where: { userId, dedupeKey: message.dedupeKey } });
assert.equal(logs, 1);
await prisma.notificationChannel.deleteMany({ where: { userId } });
});
test("erschienene Watchlist-Filme werden gemeldet — und nur einmal", async () => {
// Der Film liegt auf der Watchlist und ist gestern erschienen.
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await prisma.title.update({
where: { id: movieTitleId },
data: { releaseDate: new Date(Date.now() - 86_400_000) },
});
await prisma.user.update({ where: { id: userId }, data: { notifyRelease: true } });
await prisma.notificationChannel.create({
data: {
userId,
kind: "WEBHOOK",
name: "Test",
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
},
});
await runSync({ limit: 0 });
const first = await prisma.notificationLog.findMany({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(first.length, 1, "die Meldung wird angelegt");
assert.match(first[0]?.subject ?? "", /Jetzt erschienen/);
// Ein zweiter Lauf meldet dasselbe nicht noch einmal.
await runSync({ limit: 0 });
const second = await prisma.notificationLog.count({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(second, 1);
await prisma.notificationChannel.deleteMany({ where: { userId } });
await prisma.libraryEntry.delete({ where: { id: entry.id } });
});