trihub-ernaehrung-backseatDevs/src/tests/contact-form.test.js

270 lines
9.6 KiB
JavaScript

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 = {
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(
{
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 = [
{ ...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 <noreply@tri-hub.de>",
),
);
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",
);
});