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