import "./setup"; import assert from "node:assert/strict"; import test, { after, before, beforeEach } from "node:test"; import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb"; import { installJellyfinStub, JELLYFIN_URL, JELLYFIN_TOKEN } from "./fixtures/jellyfin"; import { prisma } from "../src/lib/db"; import { importMovie, importSeries } from "../src/lib/catalog"; import { addToLibrary, markMovieWatched } from "../src/lib/library"; import { decryptSecret, encryptSecret } from "../src/lib/secrets"; import { connectJellyfin, deleteConnection, syncConnection, testConnection, } from "../src/lib/mediaserver"; /** * Prüft den Abgleich mit einem Medienserver — gegen nachgebildete Antworten, * ohne echten Jellyfin-Server. */ const tmdb = installTmdbStub(); const TEST_EMAIL = "test-mediaserver@flimmer.invalid"; let userId: string; let movieTitleId: string; let movie2TitleId: string; async function aufraeumen() { await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }); await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } }); } before(async () => { await aufraeumen(); userId = ( await prisma.user.create({ data: { email: TEST_EMAIL, name: "Medienserver" }, select: { id: true } }) ).id; await importSeries(SERIES_ID, { region: "DE" }); movieTitleId = (await importMovie(MOVIE_ID, "DE")).id; movie2TitleId = (await importMovie(MOVIE_2_ID, "DE")).id; }); beforeEach(async () => { await prisma.mediaServerConnection.deleteMany({ where: { userId } }); await prisma.watch.deleteMany({ where: { userId } }); await prisma.libraryEntry.deleteMany({ where: { userId } }); }); after(async () => { await aufraeumen(); tmdb.restore(); await prisma.$disconnect(); }); async function verbinden() { const { verbindung } = await connectJellyfin(userId, { baseUrl: JELLYFIN_URL, username: "florian", password: "geheim", }); return verbindung; } // --------------------------------------------------------------------------- test("Geheimnisse gehen verschlüsselt hinein und im Klartext heraus", () => { const klartext = "jf-token-abcdef"; const abgelegt = encryptSecret(klartext); assert.notEqual(abgelegt, klartext, "im Klartext hat der Token nichts in der Datenbank verloren"); assert.ok(!abgelegt.includes(klartext)); assert.equal(decryptSecret(abgelegt), klartext); // Zweimal verschlüsseln ergibt Verschiedenes — der Zufallswert wandert mit. assert.notEqual(encryptSecret(klartext), abgelegt); }); test("eine veränderte Ablage lässt sich nicht entschlüsseln", () => { const abgelegt = encryptSecret("geheim"); const teile = abgelegt.split("."); const verbogen = `${teile[0]}.${teile[1]}.${"A".repeat(teile[2]!.length)}`; assert.throws(() => decryptSecret(verbogen)); }); test("die Verbindung wird eingerichtet und der Token verschlüsselt abgelegt", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); assert.equal(verbindung.name, "Heimserver", "der Servername kommt vom Server"); assert.equal(verbindung.serverUserName, "florian"); const roh = await prisma.mediaServerConnection.findUniqueOrThrow({ where: { id: verbindung.id }, select: { token: true }, }); assert.notEqual(roh.token, JELLYFIN_TOKEN, "der Token steht nicht im Klartext in der Datenbank"); assert.equal(decryptSecret(roh.token), JELLYFIN_TOKEN); } finally { jf.restore(); } }); test("fehlt UserData, sagt die Prüfung das deutlich", async () => { // So verhält sich Jellyfin bei Anmeldung per API-Key: Bestand ja, // Gesehen-Status nein — und zwar ohne Fehlermeldung. const jf = installJellyfinStub({ ohneUserData: true }); try { const verbindung = await verbinden(); const pruefung = await testConnection(userId, verbindung.id); assert.equal(pruefung.hasUserData, false, "das Fehlen wird erkannt, statt stumm zu bleiben"); assert.ok(pruefung.itemCount > 0, "der Bestand kommt trotzdem an"); } finally { jf.restore(); } }); test("eine abgelehnte Anmeldung wird gemeldet", async () => { const jf = installJellyfinStub({ abweisen: true }); try { await assert.rejects(() => verbinden(), /abgelehnt/); } finally { jf.restore(); } }); test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); // Nur der eine Film ist in der Bibliothek — der Rest soll nicht ungefragt // hineinwandern. await addToLibrary(userId, movieTitleId, "WATCHLIST"); const bericht = await syncConnection(userId, verbindung.id); assert.equal(bericht.gelesen, 6, "alle Items wurden gelesen"); assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen"); assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“"); const eintrag = await prisma.libraryEntry.findFirstOrThrow({ where: { userId, titleId: movieTitleId }, select: { owned: true, ownedSource: true }, }); assert.equal(eintrag.owned, true); assert.equal(eintrag.ownedSource, "Jellyfin"); const gruende = bericht.nichtZugeordnet.map((eintrag) => eintrag.grund); assert.ok( gruende.some((grund) => grund.includes("keine TMDB")), "das Urlaubsvideo ohne Kennung steht im Bericht", ); assert.ok( gruende.some((grund) => grund.includes("nicht im Katalog")), "der fremde Film steht im Bericht", ); } finally { jf.restore(); } }); test("mit Erlaubnis werden fehlende Titel aufgenommen", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); const bericht = await syncConnection(userId, verbindung.id, { aufnehmen: true }); assert.equal(bericht.aufgenommen, 3, "Serie und beide Filme kommen dazu"); const eintraege = await prisma.libraryEntry.findMany({ where: { userId }, select: { titleId: true, status: true, owned: true, ownedSource: true }, }); assert.equal(eintraege.length, 3); assert.ok(eintraege.every((eintrag) => eintrag.status === "WATCHLIST")); assert.ok(eintraege.every((eintrag) => eintrag.owned && eintrag.ownedSource === "Jellyfin")); } finally { jf.restore(); } }); test("gesehene Filme und Episoden werden mit dem Termin aus Jellyfin übernommen", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); await syncConnection(userId, verbindung.id, { aufnehmen: true }); const sichtungen = await prisma.watch.findMany({ where: { userId }, select: { titleId: true, episodeId: true, watchedAt: true, source: true, minutes: true }, }); assert.equal(sichtungen.length, 2, "ein Film und eine Episode wurden gesehen"); assert.ok(sichtungen.every((sichtung) => sichtung.source === "JELLYFIN")); const film = sichtungen.find((sichtung) => sichtung.episodeId === null); assert.ok(film, "der gesehene Film ist dabei"); assert.equal( film.watchedAt.toISOString(), new Date("2026-07-14T20:15:00.000Z").toISOString(), "der Termin kommt aus Jellyfin, nicht von jetzt", ); assert.ok(film.minutes > 0, "die Laufzeit wird angerechnet"); const episode = sichtungen.find((sichtung) => sichtung.episodeId !== null); assert.ok(episode); assert.equal( episode.watchedAt.toISOString(), new Date("2026-08-01T21:00:00.000Z").toISOString(), ); // Der ungesehene Film bleibt ungesehen. const ungesehen = sichtungen.filter((sichtung) => sichtung.titleId === movie2TitleId); assert.equal(ungesehen.length, 0); } finally { jf.restore(); } }); test("ein zweiter Lauf legt nichts doppelt an", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); const erster = await syncConnection(userId, verbindung.id, { aufnehmen: true }); assert.equal(erster.sichtungenAngelegt, 2); const zweiter = await syncConnection(userId, verbindung.id, { aufnehmen: true }); assert.equal(zweiter.sichtungenAngelegt, 0, "beim zweiten Mal gibt es nichts Neues"); assert.equal(zweiter.aufgenommen, 0); assert.equal(await prisma.watch.count({ where: { userId } }), 2); assert.equal(await prisma.libraryEntry.count({ where: { userId } }), 3); } finally { jf.restore(); } }); test("der Abgleich löscht nichts, was in Flimmer steht", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); // In Flimmer als gesehen markiert — in Jellyfin steht dieser Film auf // ungesehen. Der Abgleich darf ihn nicht zurücksetzen. const eintrag = await addToLibrary(userId, movie2TitleId, "WATCHLIST"); await markMovieWatched(userId, eintrag.id); const vorher = await prisma.watch.count({ where: { userId } }); await syncConnection(userId, verbindung.id, { aufnehmen: true }); const eigene = await prisma.watch.findMany({ where: { userId, source: "FLIMMER" }, select: { titleId: true }, }); assert.equal(eigene.length, vorher, "die eigene Sichtung steht unverändert da"); assert.equal(eigene[0]!.titleId, movie2TitleId); const nachher = await prisma.libraryEntry.findFirstOrThrow({ where: { userId, titleId: movie2TitleId }, select: { status: true }, }); assert.notEqual(nachher.status, "WATCHLIST", "der Status aus Flimmer bleibt erhalten"); } finally { jf.restore(); } }); test("eine gelöschte Verbindung nimmt ihre Zuordnungen mit, nicht die Sichtungen", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); await syncConnection(userId, verbindung.id, { aufnehmen: true }); assert.ok((await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } })) > 0); const sichtungenVorher = await prisma.watch.count({ where: { userId } }); await deleteConnection(userId, verbindung.id); assert.equal(await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } }), 0); assert.equal( await prisma.watch.count({ where: { userId } }), sichtungenVorher, "die übernommenen Sichtungen bleiben — sie gehören jetzt dem Nutzer", ); } finally { jf.restore(); } }); test("fremde Verbindungen sind unerreichbar", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); const fremderId = ( await prisma.user.create({ data: { email: "test-mediaserver-fremd@flimmer.invalid", name: "Fremd" }, select: { id: true }, }) ).id; try { await assert.rejects(() => syncConnection(fremderId, verbindung.id), /gibt es nicht/); await assert.rejects(() => deleteConnection(fremderId, verbindung.id), /gibt es nicht/); } finally { await prisma.user.delete({ where: { id: fremderId } }); } } finally { jf.restore(); } });