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 { packages } from "../shared/packages.js"; const input = { packageId: packages[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: packages, 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, packages[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: packages, 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: packages, 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: packages, 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: packages[0].name, }); assert.ok(message.text.includes("TH-000001")); assert.ok(message.text.includes(packages[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, packages.map(({ id, name, summary }) => ({ id, name, summary })), ); assert.equal((await fetch(`${url}/api/packages`)).status, 200); }); test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => { for (const catalog of [ [null], [{ ...packages[0], id: 123 }], [{ ...packages[0], id: undefined }], [packages[0], packages[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: packages, 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"); }); test("Detail-API und Vorschau liefern die gewählte Variante mit centgenauen Preisen", async (t) => { const { url, storage, sent } = await setup(t, { catalog: packages }); const details = await ( await fetch(`${url}/api/packages/ernaehrung-premium`) ).json(); assert.equal(details.variants.length, 2); assert.ok(details.includedFeatures.length > 0); assert.equal((await fetch(`${url}/api/packages/unbekannt`)).status, 404); const response = await fetch(`${url}/api/bookings/preview`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ packageId: details.id, variantId: "ernaehrung-premium-52", price: 1, }), }); assert.equal(response.status, 200); const preview = await response.json(); assert.equal(preview.durationWeeks, 52); assert.equal(preview.grossPrice, 1399); assert.equal(preview.netPrice, 1175.63); assert.equal(preview.taxAmount, 223.37); assert.equal(preview.taxRate, 19); assert.equal(preview.currency, "EUR"); assert.deepEqual(preview.includedFeatures, details.includedFeatures); assert.deepEqual(await storage.readAll(), []); assert.equal(sent.length, 0); }); test("Bestelldaten bleiben nach Katalogänderung und Neustart unverändert", async (t) => { const fixture = await setup(t, { catalog: packages }); const key = randomUUID(); const data = { ...input, packageId: "ernaehrung-premium", variantId: "ernaehrung-premium-52", price: 1, includedFeatures: ["Manipuliert"], }; const result = await fixture.post(data, key); assert.equal(result.status, 201); const order = result.body.bookedPackage; assert.equal(order.grossPrice, 1399); assert.equal(order.durationWeeks, 52); assert.equal(order.variantId, data.variantId); assert.ok(!order.includedFeatures.includes("Manipuliert")); assert.deepEqual((await fixture.storage.readAll())[0].bookedPackage, order); assert.deepEqual(fixture.sent[0].bookedPackage, order); const restarted = createBookingService({ storage: fixture.storage, packages: [], details: [], sendConfirmation: async () => assert.fail("Doppelversand"), }); assert.deepEqual( (await restarted.book(data, key)).body.bookedPackage, order, ); assert.equal( ( await fixture.post( { ...data, variantId: "ernaehrung-premium-24" }, key, ) ).status, 409, ); }); test("Unbekannte und paketfremde Varianten werden vor dem Speichern abgelehnt", async (t) => { const { post, storage, sent, url } = await setup(t, { catalog: packages }); for (const data of [ { packageId: "ernaehrung-premium", variantId: "unbekannt" }, { packageId: "ernaehrung-starter", variantId: "ernaehrung-premium-52" }, { packageId: "ernaehrung-premium", variantId: 52 }, ]) { assert.equal((await post({ ...input, ...data })).status, 400); assert.equal( ( await fetch(`${url}/api/bookings/preview`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }) ).status, 400, ); } assert.deepEqual(await storage.readAll(), []); assert.equal(sent.length, 0); }); test("Bestehende CSV ohne Bestelldaten bleibt lesbar und wird verlustfrei erweitert", async (t) => { const { post, file, storage } = await setup(t); const key = randomUUID(); await post(input, key); const [record] = await storage.readAll(); await writeFile( file, `bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash\nTH-000001,${record.createdAt},ernaehrung-starter,Altes Paket,Alte Person,person@example.test,accepted,alter-key,alter-hash\n`, ); assert.equal((await post()).status, 201); const records = await storage.readAll(); assert.equal(records.length, 2); assert.equal(records[0].packageName, "Altes Paket"); assert.equal(records[0].bookedPackage, null); assert.equal(records[1].bookedPackage.packageId, "ernaehrung-starter"); }); test("Bestätigungsmail enthält gespeicherte Leistungen, Variante, Preise und maskiertes HTML", async (t) => { const { post } = await setup(t, { catalog: packages }); const result = await post({ ...input, packageId: "ernaehrung-premium", variantId: "ernaehrung-premium-52", }); let message; const send = createEmailService({}, () => ({ sendMail: async (mail) => { message = mail; return { accepted: [input.email] }; }, })); await send({ ...input, name: "", ...result.body }); assert.match(message.subject, /Bestellbestätigung TH-/); for (const text of [ "52-Wochen-Variante", "52 Wochen", "1.399,00", "1.175,63", "223,37", "19 %", "einmalig", "Bestelldatum:", ...result.body.bookedPackage.includedFeatures, ]) { assert.ok(message.text.includes(text), text); } assert.ok(message.html.includes("<img src=x>")); assert.ok(!message.html.includes("")); }); test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeichert", async (t) => { const { post, storage, sent } = await setup(t, { catalog: packages }); const customer = { firstName: "Thomas", lastName: "Müller", email: "thomas@example.test", phone: "+49 170 1234567", ageGroup: "50-59", notes: "Vorbereitung auf die Mitteldistanz.", }; const request = { packageId: "ernaehrung-standard", customer, acceptedTerms: true, }; for (const body of [ { ...request, acceptedTerms: false }, { ...request, acceptedTerms: "true" }, { ...request, customer: [] }, { ...request, customer: { ...customer, firstName: "" } }, { ...request, customer: { ...customer, email: "ungültig" } }, { ...request, customer: { ...customer, phone: 123 } }, ]) assert.equal((await post(body)).status, 400); const key = randomUUID(); const result = await post(request, key); assert.equal(result.status, 201); assert.equal(result.body.customerEmail, customer.email); const [record] = await storage.readAll(); assert.equal(record.name, "Thomas Müller"); assert.deepEqual(record.customer, customer); assert.equal(record.acceptedTerms, true); assert.deepEqual(sent[0].customer, customer); assert.equal((await post(request, key)).status, 200); assert.equal( ( await post( { ...request, customer: { ...customer, notes: "Anderer Auftrag" }, }, key, ) ).status, 409, ); });