import test from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { createApp } from "../server/index.js"; import { createCsvStorage } from "../server/services/csv-storage.js"; import { createBookingService } from "../server/services/booking-service.js"; import { createEmailService } from "../server/services/email-service.js"; import { demoPackages } from "../shared/packages.js"; const input = { packageId: demoPackages[0].id, name: 'Test, "Person"', email: "person@example.test", }; async function setup(t, options = {}) { const directory = await mkdtemp(join(tmpdir(), "trihub-booking-")); const file = join(directory, "bookings.csv"); const storage = createCsvStorage(file); const sent = []; const sendConfirmation = options.sendConfirmation ?? (async (booking) => { sent.push({ ...booking }); }); const dependencies = { catalog: demoPackages, storage, sendConfirmation, ...options, }; const app = createApp(dependencies); await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); t.after(async () => { app.closeAllConnections(); await new Promise((resolve) => app.close(resolve)); await rm(directory, { recursive: true, force: true }); }); const url = `http://127.0.0.1:${app.address().port}`; async function post(body = input, key = randomUUID()) { const response = await fetch(`${url}/api/bookings`, { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": key, }, body: JSON.stringify(body), }); return { status: response.status, body: await response.json() }; } return { post, url, file, storage, sent, dependencies }; } test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) => { const fixture = await setup(t); const result = await fixture.post({ ...input, packageName: "Manipuliert", price: 0, }); assert.equal(result.status, 201); assert.equal(result.body.emailStatus, "accepted"); assert.equal(result.body.packageName, demoPackages[0].name); assert.equal(result.body.bookingId, "TH-000001"); assert.ok(!Number.isNaN(Date.parse(result.body.createdAt))); const records = await fixture.storage.readAll(); assert.equal(records.length, 1); assert.equal(records[0].name, input.name); assert.equal(records[0].emailStatus, "accepted"); assert.equal(fixture.sent.length, 1); assert.equal(fixture.sent[0].emailStatus, "sending"); assert.ok(!("email" in result.body)); }); test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt", async (t) => { const { post, storage, sent } = await setup(t); for (const body of [ null, [], {}, { ...input, name: " " }, { ...input, name: 42 }, { ...input, name: "a".repeat(121) }, { ...input, name: "Test\nBcc: fremd" }, { ...input, email: "keine-adresse" }, { ...input, email: "a..b@example.test" }, { ...input, email: ".person@example.test" }, { ...input, email: "person.@example.test" }, { ...input, email: "person@example..test" }, { ...input, email: "person@-example.test" }, { ...input, email: `person@${"a".repeat(64)}.test` }, { ...input, email: "a\r\nb@example.test" }, { ...input, email: false }, { ...input, email: "a".repeat(255) }, { ...input, packageId: "unknown" }, { ...input, packageId: "../demo" }, { ...input, packageId: 1 }, { ...input, packageId: "a".repeat(81) }, ]) { const result = await post(body); assert.equal(result.status, 400, JSON.stringify(body)); } assert.equal((await post(input, "invalid")).status, 400); assert.deepEqual(await storage.readAll(), []); assert.equal(sent.length, 0); }); test("Parallele Wiederholungen und Neustart erzeugen nur eine Buchung und Mail", async (t) => { const { post, storage, sent, dependencies } = await setup(t); const key = randomUUID(); const results = await Promise.all( Array.from({ length: 8 }, () => post(input, key)), ); assert.equal(new Set(results.map((r) => r.body.bookingId)).size, 1); assert.equal(results.filter((r) => r.status === 201).length, 1); assert.equal(sent.length, 1); assert.equal((await storage.readAll()).length, 1); // Neue Serviceinstanz liest ausschließlich den persistenten CSV-Zustand. const restarted = createBookingService({ ...dependencies, packages: [] }); const replay = await restarted.book(input, key); assert.equal(replay.status, 200); assert.equal(replay.body.bookingId, results[0].body.bookingId); assert.equal(sent.length, 1); const conflict = await post({ ...input, name: "Andere Person" }, key); assert.equal(conflict.status, 409); }); test("Verschiedene gleichzeitige Buchungen behalten alle CSV-Zeilen", async (t) => { const { post, storage } = await setup(t); const results = await Promise.all( Array.from({ length: 12 }, (_, i) => post({ ...input, name: `Person ${i}` }), ), ); assert.ok(results.every((r) => r.status === 201)); const records = await storage.readAll(); assert.equal(records.length, 12); assert.deepEqual( records.map((record) => record.bookingId), Array.from( { length: 12 }, (_, i) => `TH-${String(i + 1).padStart(6, "0")}`, ), ); }); test("CSV schützt Formeln und erhält Kommas, Anführungszeichen, Apostrophe und Zeilenumbrüche", async (t) => { const { storage, file } = await setup(t); const values = [ '=HYPERLINK("evil")', "+1", "-1", "@SUM(A1)", "\t=1", "\r=1", "\n=1", " =1", "'Original", 'Test, "Name"\nzweite Zeile', ]; const rows = values.map((name) => ({ name, packageName: name })); await storage.writeAll(rows); assert.deepEqual( (await storage.readAll()).map((r) => r.name), values, ); const text = await readFile(file, "utf8"); assert.ok(text.includes("'=HYPERLINK")); assert.ok(text.includes("''Original")); assert.ok(text.includes('"Test, ""Name""\nzweite Zeile"')); await storage.writeAll(await storage.readAll()); assert.deepEqual( (await storage.readAll()).map((r) => r.name), values, ); }); test("Fehlgeschlagenes erstes Schreiben verhindert Versand", async (t) => { let sent = 0; const { post } = await setup(t, { storage: { readAll: async () => [], writeAll: async () => { throw new Error("Disk full"); }, }, sendConfirmation: async () => { sent++; }, }); const result = await post(); assert.equal(result.status, 503); assert.equal(result.body.error.code, "BOOKING_NOT_SAVED"); assert.equal(sent, 0); }); test("Beschädigte CSV wird nicht überschrieben", async (t) => { const { post, file, sent } = await setup(t); await writeFile(file, "falscher,header\n1,2\n"); const result = await post(); assert.equal(result.status, 503); assert.equal(result.body.error.code, "STORAGE_UNAVAILABLE"); assert.equal(await readFile(file, "utf8"), "falscher,header\n1,2\n"); assert.equal(sent.length, 0); }); test("Mailfehler speichert Buchung; Wiederholung sendet nicht erneut", async (t) => { let calls = 0; const { post, storage } = await setup(t, { sendConfirmation: async () => { calls++; throw new Error("SMTP rejected"); }, }); const key = randomUUID(); const first = await post(input, key); assert.equal(first.status, 202); assert.equal(first.body.saved, true); assert.equal(first.body.emailStatus, "failed"); assert.equal((await storage.readAll())[0].emailStatus, "failed"); assert.equal((await post(input, key)).body.bookingId, first.body.bookingId); assert.equal(calls, 1); }); test("Ausstehender Versand kann nach Schreibfehler sicher fortgesetzt werden", async (t) => { const { storage } = await setup(t); let writes = 0; let sends = 0; const service = createBookingService({ packages: demoPackages, storage: { readAll: () => storage.readAll(), writeAll: async (rows) => { if (++writes === 2) throw new Error("Disk full"); return storage.writeAll(rows); }, }, sendConfirmation: async () => { sends++; }, }); const key = randomUUID(); const first = await service.book(input, key); assert.equal(first.body.emailStatus, "pending"); assert.equal(sends, 0); const retry = await service.book(input, key); assert.equal(retry.body.emailStatus, "accepted"); assert.equal(retry.body.bookingId, first.body.bookingId); assert.equal(sends, 1); }); test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversand aus", async (t) => { const { storage } = await setup(t); let writes = 0; let sends = 0; const service = createBookingService({ packages: demoPackages, storage: { readAll: () => storage.readAll(), writeAll: async (rows) => { if (++writes === 3) throw new Error("Disk full"); return storage.writeAll(rows); }, }, sendConfirmation: async () => { sends++; }, }); const key = randomUUID(); assert.equal((await service.book(input, key)).body.emailStatus, "unknown"); assert.equal((await storage.readAll())[0].emailStatus, "sending"); const restarted = createBookingService({ storage, packages: demoPackages, sendConfirmation: async () => { sends++; }, }); assert.equal( (await restarted.book(input, key)).body.emailStatus, "unknown", ); assert.equal(sends, 1); }); test("SMTP-Nachricht enthält Buchungsnummer und Paket; externe Hosts sind standardmäßig gesperrt", async () => { let message; const send = createEmailService({}, () => ({ sendMail: async (mail) => { message = mail; return { accepted: [input.email] }; }, })); await send({ ...input, bookingId: "TH-000001", packageName: demoPackages[0].name, }); assert.ok(message.text.includes("TH-000001")); assert.ok(message.text.includes(demoPackages[0].name)); assert.equal(message.to.address, input.email); assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen")); assert.ok(message.text.includes("keine Abbuchung")); assert.throws( () => createEmailService({ SMTP_HOST: "smtp.example.org" }), /gesperrt/, ); const rejected = createEmailService({}, () => ({ sendMail: async () => ({ accepted: [] }), })); await assert.rejects(() => rejected(input)); }); test("SMTP-Verbindungsabbruch unterscheidet sich von expliziter Ablehnung", async () => { for (const [code, expected] of [ ["ETIMEDOUT", true], ["EAUTH", false], ]) { const send = createEmailService({}, () => ({ sendMail: async () => { throw Object.assign(new Error("SMTP"), { code }); }, })); await assert.rejects( () => send(input), (error) => error.deliveryUnknown === expected, ); } }); test("HTTP-Vertrag: JSON, Größenlimit, Methoden und private Dateien", async (t) => { const { url } = await setup(t); const response = await fetch(`${url}/api/bookings`); assert.equal(response.status, 405); for (const path of [ "/src/server/data/bookings.csv", "/.env", "/data/bookings.csv", ]) { assert.equal((await fetch(`${url}${path}`)).status, 404); } assert.equal( (await fetch(`${url}/api/bookings`, { method: "POST", body: "{}" })) .status, 415, ); assert.equal( ( await fetch(`${url}/api/bookings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{", }) ).status, 400, ); assert.equal( ( await fetch(`${url}/api/bookings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "a".repeat(9000) }), }) ).status, 413, ); const catalog = await (await fetch(`${url}/api/angebote`)).json(); assert.deepEqual(catalog.packages, demoPackages); assert.equal((await fetch(`${url}/api/packages`)).status, 404); }); test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => { for (const catalog of [ [null], [{ ...demoPackages[0], id: 123 }], [{ ...demoPackages[0], id: undefined }], [demoPackages[0], demoPackages[0]], ]) { assert.throws( () => createApp({ catalog, sendConfirmation: async () => {} }), /Paketdaten erfüllen/, ); } }); test("Fortlaufende Nummern bleiben nach Neustart erhalten und Wiederholungen verbrauchen keine Nummer", async (t) => { const { storage, post } = await setup(t); const key = randomUUID(); assert.equal((await post(input, key)).body.bookingId, "TH-000001"); assert.equal((await post(input, key)).body.bookingId, "TH-000001"); const restarted = createBookingService({ storage, packages: demoPackages, sendConfirmation: async () => {}, }); assert.equal( (await restarted.book(input, randomUUID())).body.bookingId, "TH-000002", ); assert.equal((await storage.readAll()).length, 2); }); test("Bestehende UUID-Buchungen bleiben unverändert und weiterhin abrufbar", async (t) => { const { storage, post } = await setup(t); const key = randomUUID(); await post(input, key); const records = await storage.readAll(); const legacyId = randomUUID(); records[0].bookingId = legacyId; await storage.writeAll(records); assert.equal((await post(input, key)).body.bookingId, legacyId); assert.equal((await post()).body.bookingId, "TH-000001"); assert.equal((await storage.readAll())[0].bookingId, legacyId); }); test("Nummernvergabe verwendet den höchsten CSV-Wert und wächst über sechs Stellen hinaus", async (t) => { const { storage, post } = await setup(t); await post(); await post(); const records = await storage.readAll(); records[0].bookingId = "TH-999999"; await storage.writeAll(records); assert.equal((await post()).body.bookingId, "TH-1000000"); });