290 lines
10 KiB
JavaScript
290 lines
10 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 = {
|
|
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 <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",
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|