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 { createApp } from "../server/index.js"; import { createContactFormEmailService } from "../server/services/contact-form-email.js"; import { createContactFormStorage } from "../server/services/contact-form-storage.js"; const validInput = { consent: true, name: "Test Person", email: "person@example.test", subject: "Eine Frage", message: "Das ist eine ausreichend lange Nachricht.", }; // Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand. async function fixture(t, options = {}) { const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-")); const storage = options.storage ?? createContactFormStorage(join(directory, "contact-requests.csv")); const sent = []; const app = createApp({ sendConfirmation: async () => {}, sendContact: async () => {}, contactFormStorage: storage, sendContactForm: async (data) => sent.push(data), ...options, }); 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 base = `http://127.0.0.1:${app.address().port}`; async function post(input = validInput, headers = {}) { const response = await fetch(`${base}/api/nutrition-contact`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(input), }); return { status: response.status, body: await response.json() }; } return { app, base, post, sent, storage, directory }; } // Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit. test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => { const f = await fixture(t); const result = await f.post(); assert.equal(result.status, 201); assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" }); assert.equal(f.sent.length, 1); assert.deepEqual( { consent: f.sent[0].consent, name: f.sent[0].name, email: f.sent[0].email, subject: f.sent[0].subject, message: f.sent[0].message, }, validInput, ); assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt))); assert.deepEqual(await f.storage.readAll(), f.sent); }); // Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden. test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => { const f = await fixture(t); const invalidInputs = [ ...[false, "true", undefined].map((consent) => ({ ...validInput, consent, })), { ...validInput, name: " " }, { ...validInput, name: "Test\nBcc: fremd" }, { ...validInput, name: "x".repeat(121) }, { ...validInput, email: "keine-adresse" }, { ...validInput, email: "x".repeat(255) }, { ...validInput, subject: "x".repeat(161) }, { ...validInput, message: "zu kurz" }, { ...validInput, message: "x".repeat(5001) }, { ...validInput, message: "Nachricht\u0000ungültig" }, { ...validInput, message: undefined }, null, [], ]; for (const input of invalidInputs) { const result = await f.post(input); assert.equal(result.status, 400, JSON.stringify(input)); } assert.deepEqual(await f.storage.readAll(), []); assert.equal(f.sent.length, 0); }); // Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts. test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => { const f = await fixture(t); assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405); const wrongType = await fetch(`${f.base}/api/nutrition-contact`, { method: "POST", body: "{}", }); assert.equal(wrongType.status, 415); const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{", }); assert.equal(invalidJson.status, 400); const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "x".repeat(9000) }), }); assert.equal(tooLarge.status, 413); assert.deepEqual(await f.storage.readAll(), []); }); // Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden. test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => { let sendCount = 0; const f = await fixture(t, { storage: { async append() { throw new Error("Datenträger nicht verfügbar"); }, }, sendContactForm: async () => sendCount++, }); const result = await f.post(); assert.equal(result.status, 503); assert.equal(result.body.error.code, "CONTACT_NOT_SAVED"); assert.equal(sendCount, 0); }); // Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten. test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => { const f = await fixture(t, { sendContactForm: async () => { throw new Error("Mailpit nicht erreichbar"); }, }); const result = await f.post(); assert.equal(result.status, 502); assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED"); assert.equal(result.body.error.message.includes("gespeichert"), true); assert.equal((await f.storage.readAll()).length, 1); }); // Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen. test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => { const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-")); const file = join(directory, "contact-requests.csv"); const storage = createContactFormStorage(file); t.after(() => rm(directory, { recursive: true, force: true })); const names = [ '=HYPERLINK("evil")', 'Test, "Person"', "Zeile 1\nZeile 2", "'Original", ...Array.from({ length: 8 }, (_, index) => `Person ${index}`), ]; await Promise.all( names.map((name, index) => storage.append({ createdAt: `2026-01-01T00:00:0${index}.000Z`, name, email: `person${index}@example.test`, subject: "CSV Test", message: "Eine Nachricht mit mehr als zehn Zeichen.", }), ), ); const records = await storage.readAll(); assert.equal(records.length, names.length); assert.deepEqual( records.map((record) => record.name).sort(), [...names].sort(), ); const csv = await readFile(file, "utf8"); assert.match(csv, /'=HYPERLINK/); assert.match(csv, /"Test, ""Person"""/); }); // Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben. test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => { const directory = await mkdtemp( join(tmpdir(), "trihub-contact-csv-invalid-"), ); const file = join(directory, "contact-requests.csv"); const storage = createContactFormStorage(file); const corrupt = "wrong,header\n1,2\n"; await writeFile(file, corrupt); t.after(() => rm(directory, { recursive: true, force: true })); await assert.rejects( storage.append({ ...validInput, createdAt: "2026-01-01" }), ); assert.equal(await readFile(file, "utf8"), corrupt); }); // Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt. test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => { const config = []; const messages = []; const sendContactForm = createContactFormEmailService( {}, (transportConfig) => { config.push(transportConfig); return { async sendMail(message) { messages.push(message); return { accepted: [message.to] }; }, }; }, ); await sendContactForm({ ...validInput, createdAt: "2026-01-01T00:00:00.000Z", }); assert.equal(config[0].host, "127.0.0.1"); assert.equal(config[0].port, 1025); assert.deepEqual( messages.map((message) => message.to).sort(), [validInput.email, "ernaehrung@tri-hub.de"].sort(), ); assert.ok( messages.every( (message) => message.from === "Tri-Hub Ernährung ", ), ); assert.ok( messages.every((message) => message.text.includes(validInput.message)), ); const request = messages.find( (message) => message.to === "ernaehrung@tri-hub.de", ); assert.equal(request.replyTo, validInput.email); }); // SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden. test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => { const sendContactForm = createContactFormEmailService({}, () => ({ async sendMail(message) { return { accepted: message.to === validInput.email ? [message.to] : [], }; }, })); await assert.rejects( sendContactForm(validInput), (error) => error.status === 502 && error.code === "CONTACT_EMAIL_FAILED", ); }); test("Alte Kontakt-CSV bleibt lesbar; neue Zustimmung wird separat gespeichert", async (t) => { const f = await fixture(t); await writeFile( join(f.directory, "contact-requests.csv"), "createdAt,name,email,subject,message\n2026-01-01,Alt,alt@example.test,Frage,Alte Nachricht\n", ); assert.equal((await f.post()).status, 201); const records = await f.storage.readAll(); assert.equal(records.length, 2); assert.notEqual(records[0].consent, true); assert.equal(records[0].message, "Alte Nachricht"); assert.equal(records[1].consent, true); });