diff --git a/tests/transfer.test.ts b/tests/transfer.test.ts new file mode 100644 index 0000000..0357b0d --- /dev/null +++ b/tests/transfer.test.ts @@ -0,0 +1,241 @@ +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, 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 } }); +});