test: Import, Export, Papierkorb und Doppelversandschutz absichern
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.
This commit is contained in:
@@ -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 } });
|
||||
});
|
||||
Reference in New Issue
Block a user