Neun Tests: CSV mit Komma und Semikolon, Anführungszeichen und Zeilenumbrüchen, Zuordnung zu vorhandenen Katalogtiteln, Trakt-Formen, Export in beiden Formaten, Aufbewahrungsfrist des Papierkorbs und der Schutz davor, dieselbe Meldung zweimal zu verschicken.
242 lines
8.3 KiB
TypeScript
242 lines
8.3 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, 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 } });
|
|
});
|