trihub-ernaehrung-backseatDevs/src/tests/bookings.test.js

428 lines
15 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 { 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");
});