Merge branch 'feature/rezensionen-darstellen' into dev

# Conflicts:
#	.gitignore
pull/12/head
Emirhan Sahin 2026-09-30 14:33:02 +00:00
commit 787e7b61a6
10 changed files with 771 additions and 38 deletions

1
.gitignore vendored
View File

@ -11,5 +11,6 @@ Thumbs.db
src/server/data/*
!src/server/data/.gitkeep
!src/server/data/contact-requests.csv
!src/server/data/reviews.csv
playwright-report/
test-results/

View File

@ -579,7 +579,7 @@
.rating-input label:hover,
.rating-input label:hover ~ label,
.rating-input input:checked ~ label {
color: var(--brand);
color: #f7c230;
}
.consent {
@ -679,7 +679,7 @@
}
.stars {
color: var(--brand);
color: #f7c230;
letter-spacing: 0.08em;
white-space: nowrap;
}
@ -1017,39 +1017,61 @@
</a>
</div>
<div class="review-list">
<!-- Diese Beispielkarten sind bewusst als Platzhalter gekennzeichnet. -->
<article class="review-card">
<div class="review-top">
<div class="person">
<div class="avatar">T</div>
<div>
<strong>Deine Erfahrung</strong>
<small
>Hier kann eine Kundenstimme
erscheinen</small
>
<div
class="review-list"
id="reviewSlider"
role="region"
aria-roledescription="Karussell"
aria-label="Kundenrezensionen"
>
<div
class="review-stage"
aria-live="off"
tabindex="0"
role="group"
aria-label="Rezensionen durchblättern"
aria-describedby="reviewInstructions"
>
<article class="review-card is-active">
<div class="review-top">
<div class="person">
<div class="avatar">T</div>
<div>
<strong>Deine Erfahrung</strong>
<small
>Noch keine Rezension
vorhanden</small
>
</div>
</div>
<div
class="stars"
aria-label="Beispiel für eine Sternebewertung"
>
★★★★★
</div>
</div>
<div
class="stars"
aria-label="Beispiel für eine Sternebewertung"
<blockquote
style="
font-family:
&quot;Inter&quot;, sans-serif;
"
>
★★★★★
</div>
</div>
<blockquote
style="
font-family: &quot;Inter&quot;, sans-serif;
"
>
„Teile hier deine Erfahrung mit der
Ernährungsberatung – ehrlich, persönlich und so,
wie du sie erlebt hast.“
</blockquote>
</article>
„Teile hier deine Erfahrung mit der
Ernährungsberatung – ehrlich, persönlich und
so, wie du sie erlebt hast.“
</blockquote>
</article>
</div>
<span
class="review-instructions"
id="reviewInstructions"
>Mit der Maus ziehen oder mit den Pfeiltasten links
und rechts durch die Rezensionen wechseln.</span
>
<span class="review-announcement" role="status"></span>
<p class="review-feedback" role="status"></p>
<p class="review-note">
Nur Kunden mit einer vorhandenen Buchung können eine
Bewertung abgeben. Deine E-Mail-Adresse wird nicht
@ -1348,6 +1370,10 @@
type="module"
src="/src/features/contact/contact-form.js"
></script>
<script
type="module"
src="/src/features/reviews/review-slider.js"
></script>
<script
type="module"
src="/src/features/reviews/review-form.js"

View File

@ -39,6 +39,7 @@ form.addEventListener("submit", async (event) => {
}
status.textContent = "Danke! Deine Bewertung wurde gespeichert.";
form.reset();
document.dispatchEvent(new Event("review:saved"));
} catch {
status.textContent =
"Keine Bestätigung vom Server erhalten. Bitte versuche es später erneut.";

View File

@ -0,0 +1,139 @@
.review-list {
min-width: 0;
align-content: start;
}
.review-stage {
display: grid;
overflow: hidden;
touch-action: pan-y;
cursor: grab;
user-select: none;
}
.review-stage .review-card {
grid-area: 1 / 1;
min-width: 0;
opacity: 0;
visibility: hidden;
transform: translateX(32px);
transition:
opacity 600ms ease,
transform 600ms cubic-bezier(0.22, 1, 0.36, 1),
visibility 0s 600ms;
pointer-events: none;
overflow-wrap: anywhere;
}
.review-stage .review-card.is-before {
transform: translateX(-32px);
}
.review-stage .review-card.is-active {
opacity: 1;
visibility: visible;
transform: translateX(0);
transition-delay: 0s;
pointer-events: auto;
}
.review-stage .review-card h3 {
font-family: inherit;
margin: 0;
color: var(--white);
font-size: 1.1rem;
line-height: 1.4;
}
.review-stage .review-top {
flex-wrap: wrap;
}
.review-stage .review-card blockquote {
font-family: inherit;
font-size: 1rem;
white-space: pre-wrap;
}
.review-stage:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 4px;
}
.review-carousel {
position: relative;
padding-inline: 3.25rem;
}
.review-arrow {
position: absolute;
z-index: 2;
top: 50%;
display: grid;
width: 2rem;
height: 2.5rem;
padding: 0;
place-items: center;
transform: translateY(-50%);
border: 0;
color: #fff;
background: transparent;
font: inherit;
font-size: 2.5rem;
line-height: 1;
cursor: pointer;
transition: color 0.2s ease;
}
.review-arrow[hidden] {
display: none;
}
.review-arrow:hover {
color: #53d5cd;
}
.review-arrow--previous {
left: 0;
}
.review-arrow--next {
right: 0;
}
.review-arrow:focus-visible {
outline: 3px solid #53d5cd;
outline-offset: 3px;
}
.review-stage:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
.review-arrow:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
.review-stage.is-dragging {
cursor: grabbing;
}
.review-stage.is-dragging .review-card.is-active {
transition: none;
}
.review-feedback {
color: var(--muted);
font-size: 0.82rem;
}
.review-feedback:empty {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.review-stage .review-card {
transition: none;
transform: none;
}
}
.review-announcement,
.review-instructions {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 600px) {
.review-carousel {
padding-inline: 2.25rem;
}
.review-arrow {
width: 1.5rem;
font-size: 2rem;
}
}

View File

@ -0,0 +1,209 @@
import "./review-slider.css";
const slider = document.getElementById("reviewSlider");
const stage = slider.querySelector(".review-stage");
const feedback = slider.querySelector(".review-feedback");
const announcement = slider.querySelector(".review-announcement");
const carousel = document.createElement("div");
carousel.className = "review-carousel";
stage.before(carousel);
carousel.append(stage);
function arrow(direction, symbol, label) {
const button = document.createElement("button");
button.type = "button";
button.className = `review-arrow review-arrow--${direction}`;
button.setAttribute("aria-label", label);
button.textContent = symbol;
button.hidden = true;
button.addEventListener("click", () =>
show(index + (direction === "next" ? 1 : -1), true),
);
carousel.append(button);
return button;
}
const previousArrow = arrow("previous", "‹", "Vorherige Rezension");
const nextArrow = arrow("next", "›", "Nächste Rezension");
const interval = 10_000;
let reviews = [];
let index = 0;
let timer;
let drag = null;
let latestRequest = 0;
function schedule() {
clearTimeout(timer);
if (reviews.length > 1 && !drag && !document.hidden) {
timer = setTimeout(() => show(index + 1), interval);
}
}
function show(next, manual = false) {
if (!reviews.length) return;
const direction = next < index ? -1 : 1;
const previous = stage.children[index];
index = (next + reviews.length) % reviews.length;
const current = stage.children[index];
if (previous !== current) {
current.classList.toggle("is-before", direction < 0);
// Establish the starting position before transitioning into view.
void current.offsetWidth;
previous.classList.toggle("is-before", direction > 0);
}
[...stage.children].forEach((card, number) => {
card.classList.toggle("is-active", number === index);
card.inert = number !== index;
card.setAttribute("aria-hidden", String(number !== index));
});
if (manual)
announcement.textContent = `Rezension ${index + 1} von ${reviews.length}: ${reviews[index].title}`;
schedule();
}
function cardFor(review, number, total) {
const card = document.createElement("article");
card.className = "review-card";
card.setAttribute("aria-roledescription", "Folie");
card.setAttribute("aria-label", `${number + 1} von ${total}`);
const top = document.createElement("div");
top.className = "review-top";
const title = document.createElement("h3");
title.textContent = review.title;
const stars = document.createElement("div");
stars.className = "stars";
stars.setAttribute("aria-label", `${review.rating} von 5 Sternen`);
stars.textContent =
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
const text = document.createElement("blockquote");
text.textContent = review.review;
top.append(title, stars);
card.append(top, text);
return card;
}
async function refresh(showNewest = false) {
const request = ++latestRequest;
try {
const response = await fetch("/api/reviews", { cache: "no-store" });
if (!response.ok) throw new Error("Laden fehlgeschlagen");
const result = await response.json();
if (request !== latestRequest || drag) return;
const next = result.reviews;
if (
!Array.isArray(next) ||
next.some(
(review) =>
typeof review.title !== "string" ||
typeof review.review !== "string" ||
!Number.isInteger(review.rating) ||
review.rating < 1 ||
review.rating > 5,
)
)
throw new Error("Ungültige Bewertungen");
if (feedback.textContent.startsWith("Bewertungen können"))
feedback.textContent = "";
if (JSON.stringify(next) === JSON.stringify(reviews)) return;
const active = JSON.stringify(reviews[index]);
reviews = next;
if (!reviews.length) {
// Reloading also restores the empty placeholder after external deletion.
stage.replaceChildren(placeholder.cloneNode(true));
previousArrow.hidden = true;
nextArrow.hidden = true;
schedule();
return;
}
index = showNewest
? reviews.length - 1
: Math.max(
0,
reviews.findIndex(
(review) => JSON.stringify(review) === active,
),
);
stage.replaceChildren(
...reviews.map((review, number) =>
cardFor(review, number, reviews.length),
),
);
previousArrow.hidden = reviews.length < 2;
nextArrow.hidden = reviews.length < 2;
void stage.offsetWidth;
show(index);
feedback.textContent = "";
} catch {
if (request === latestRequest)
feedback.textContent =
"Bewertungen können gerade nicht aktualisiert werden. Wir versuchen es erneut.";
}
}
const placeholder = stage.firstElementChild.cloneNode(true);
stage.addEventListener("keydown", (event) => {
if (drag || event.altKey || event.ctrlKey || event.metaKey) return;
const targets = {
ArrowLeft: index - 1,
ArrowRight: index + 1,
Home: 0,
End: reviews.length - 1,
};
if (!(event.key in targets)) return;
event.preventDefault();
show(targets[event.key], true);
});
stage.addEventListener("pointerdown", (event) => {
if (!event.isPrimary || event.button !== 0 || reviews.length < 2 || drag)
return;
drag = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
dx: 0,
dy: 0,
};
clearTimeout(timer);
stage.setPointerCapture(event.pointerId);
stage.classList.add("is-dragging");
});
stage.addEventListener("pointermove", (event) => {
if (!drag || drag.id !== event.pointerId) return;
drag.dx = event.clientX - drag.x;
drag.dy = event.clientY - drag.y;
if (Math.abs(drag.dx) > Math.abs(drag.dy)) {
const offset = Math.max(
-stage.clientWidth / 2,
Math.min(stage.clientWidth / 2, drag.dx),
);
stage.children[index].style.transform = `translateX(${offset}px)`;
}
});
function finishDrag(event) {
if (!drag || drag.id !== event.pointerId) return;
const { dx, dy } = drag;
drag = null;
stage.classList.remove("is-dragging");
stage.children[index]?.style.removeProperty("transform");
if (stage.hasPointerCapture(event.pointerId))
stage.releasePointerCapture(event.pointerId);
if (
event.type === "pointerup" &&
Math.abs(dx) > 50 &&
Math.abs(dx) > Math.abs(dy)
) {
show(index + (dx < 0 ? 1 : -1), true);
} else {
schedule();
}
}
for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) {
stage.addEventListener(event, finishDrag);
}
document.addEventListener("review:saved", () => refresh(true));
document.addEventListener("visibilitychange", () => {
schedule();
if (!document.hidden) refresh();
});
setInterval(() => {
if (!document.hidden) refresh();
}, interval);
refresh();

View File

@ -0,0 +1,3 @@
createdAt,email,title,review,rating,consent
2026-09-30T12:06:34.079Z,marvin@web.de,Das Premium Paket hat mir sehr geholfen!,Ich hatte eine super Erfahrung mit der Lilli beim Premium Paket. Ich konnte schnell Termine buchen und Lilli konnte immer auf die Sonderwünsche der Wettkampfbegleitung eingehen. Ich hab sie einmal auf eine Allergie aufmerksam gemacht und sie hat meinen Plan umgehend angepasst. Gerne wieder!,5,true
2026-09-30T12:10:41.673Z,test@web.de,"Tolle Beratung, aber etwas zu teuer!",Ich bin insgesamt sehr zufrieden gewesen und konnte meine Ernährung erfolgreich umstellen. Für mich ist der Preis aber etwas zu hoch gewesen.,4,true
1 createdAt email title review rating consent
2 2026-09-30T12:06:34.079Z marvin@web.de Das Premium Paket hat mir sehr geholfen! Ich hatte eine super Erfahrung mit der Lilli beim Premium Paket. Ich konnte schnell Termine buchen und Lilli konnte immer auf die Sonderwünsche der Wettkampfbegleitung eingehen. Ich hab sie einmal auf eine Allergie aufmerksam gemacht und sie hat meinen Plan umgehend angepasst. Gerne wieder! 5 true
3 2026-09-30T12:10:41.673Z test@web.de Tolle Beratung, aber etwas zu teuer! Ich bin insgesamt sehr zufrieden gewesen und konnte meine Ernährung erfolgreich umstellen. Für mich ist der Preis aber etwas zu hoch gewesen. 4 true

View File

@ -2,13 +2,26 @@ import { BookingError, emailPattern } from "../services/booking-service.js";
import { json, readJson } from "./bookings.js";
export async function handleReview(request, response, bookings, reviews) {
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
if (!["GET", "POST"].includes(request.method)) {
response.setHeader("Allow", "GET, POST");
return json(response, 405, {
error: { message: "Bitte POST verwenden." },
error: { message: "Bitte GET oder POST verwenden." },
});
}
try {
if (request.method === "GET") {
const records = await reviews.readAll();
return json(response, 200, {
reviews: records
.filter((record) => record.consent === "true")
.map(({ createdAt, title, review, rating }) => ({
createdAt,
title,
review,
rating: Number(rating),
})),
});
}
const input = await readJson(request);
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new BookingError(
@ -87,9 +100,14 @@ export async function handleReview(request, response, bookings, reviews) {
}
return json(response, 503, {
error: {
code: "REVIEW_NOT_SAVED",
code:
request.method === "GET"
? "REVIEWS_UNAVAILABLE"
: "REVIEW_NOT_SAVED",
message:
"Die Bewertung konnte nicht gespeichert werden. Bitte versuche es später erneut.",
request.method === "GET"
? "Die Bewertungen können gerade nicht geladen werden."
: "Die Bewertung konnte nicht gespeichert werden. Bitte versuche es später erneut.",
},
});
}

View File

@ -18,6 +18,7 @@ const legacyColumns = [
const orderColumns = [...legacyColumns, "bookedPackage"];
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
const consentColumns = [...customerColumns, "consent"];
const phoneColumns = [...customerColumns, "phone"];
export const columns = [...phoneColumns, "consent"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
@ -51,6 +52,7 @@ export function createCsvStorage(filePath) {
header.join(",") !== columns.join(",") &&
header.join(",") !== customerColumns.join(",") &&
header.join(",") !== phoneColumns.join(",") &&
header.join(",") !== consentColumns.join(",") &&
header.join(",") !== legacyColumns.join(",") &&
header.join(",") !== orderColumns.join(",")
) {

View File

@ -0,0 +1,217 @@
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";
const reviews = [
{
createdAt: "2026-01-01",
title: "Hilfreiche Beratung",
review: "Die Beratung hat mir im Training sehr geholfen.",
rating: 4,
},
{
createdAt: "2026-01-02",
title: "Sehr guter Ernährungsplan",
review: "Der Plan lässt sich leicht in meinen Alltag integrieren.",
rating: 5,
},
{
createdAt: "2026-01-03",
title: "Gute Tipps für den Alltag",
review: "Ich konnte viele Tipps direkt im Alltag ausprobieren.",
rating: 3,
},
];
const active = (page) => page.locator("#reviewSlider .review-card.is-active");
async function mock(page, rows = reviews) {
await page.route("**/api/reviews", (route) =>
route.fulfill({ json: { reviews: rows } }),
);
await page.goto("/index.html");
await expect(active(page)).toContainText(
rows[0]?.title || "Deine Erfahrung",
);
}
test("10-Sekunden-Wechsel, Maus-Drag und Tastatur ohne Buttons", async ({
page,
}) => {
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
await mock(page);
const stage = page.locator(".review-stage");
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
const font = await page
.locator("body")
.evaluate((element) => getComputedStyle(element).fontFamily);
await expect(active(page).locator("h3")).toHaveCSS("font-family", font);
await expect(active(page).locator("blockquote")).toHaveCSS(
"font-family",
font,
);
await expect(active(page).locator(".stars")).toHaveAttribute(
"aria-label",
"4 von 5 Sternen",
);
await page.clock.runFor(9999);
await expect(active(page)).toContainText(reviews[0].title);
await page.clock.runFor(1);
await expect(active(page)).toContainText(reviews[1].title);
await stage.scrollIntoViewIfNeeded();
const box = await stage.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + 60);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 100, box.y + 60, {
steps: 6,
});
await expect(stage).toHaveClass(/is-dragging/);
await expect(active(page)).toHaveCSS(
"transform",
"matrix(1, 0, 0, 1, -100, 0)",
);
await page.mouse.up();
await expect(active(page)).toContainText(reviews[2].title);
await expect(stage).toBeFocused();
await page.clock.runFor(9999);
await expect(active(page)).toContainText(reviews[2].title);
await page.clock.runFor(1);
await expect(active(page)).toContainText(reviews[0].title);
await page.keyboard.press("ArrowLeft");
await expect(active(page)).toContainText(reviews[2].title);
await page.keyboard.press("Home");
await expect(active(page)).toContainText(reviews[0].title);
await page.keyboard.press("End");
await expect(active(page)).toContainText(reviews[2].title);
await page.keyboard.press("ArrowRight");
await expect(active(page)).toContainText(reviews[0].title);
await page.clock.runFor(10_000);
await expect(active(page)).toContainText(reviews[1].title);
});
test("Kurzes Ziehen springt zurück; Drag nach rechts zeigt vorherige Rezension", async ({
page,
}) => {
await mock(page);
const stage = page.locator(".review-stage");
await stage.scrollIntoViewIfNeeded();
const box = await stage.boundingBox();
for (const distance of [20, 100]) {
await page.mouse.move(box.x + box.width / 2, box.y + 60);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + distance, box.y + 60, {
steps: 5,
});
await page.mouse.up();
await expect(active(page)).toContainText(
reviews[distance === 20 ? 0 : 2].title,
);
await expect(stage).not.toHaveClass(/is-dragging/);
}
});
test("Leere Liste und einzelne Rezension brauchen keine Slider-Steuerung", async ({
page,
}) => {
await mock(page, []);
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
await page.unroute("**/api/reviews");
await mock(page, [reviews[0]]);
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
});
test("Mobile Anzeige bleibt stabil; HTML im Rezensionstext wird nicht ausgeführt", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 850 });
await page.emulateMedia({ reducedMotion: "reduce" });
const rows = [
reviews[0],
{
...reviews[1],
review:
'<img src=x onerror="window.reviewInjected=true">' +
"x".repeat(400),
},
];
await mock(page, rows);
const before = await page.locator(".review-stage").boundingBox();
await page.locator(".review-stage").focus();
await page.keyboard.press("ArrowRight");
const after = await page.locator(".review-stage").boundingBox();
expect(after.height).toBe(before.height);
await expect(active(page)).toHaveCSS("transition-duration", "0s");
await expect(active(page)).toContainText("<img src=x");
await expect(active(page).locator("img")).toHaveCount(0);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
test("Neue Rezension erscheint erst nach erfolgreicher Speicherung und bleibt nach Neuladen", async ({
page,
request,
}) => {
const email = `slider-${randomUUID()}@example.test`;
expect(
(
await request.post("/api/bookings", {
headers: { "Idempotency-Key": randomUUID() },
data: {
packageId: "ernaehrung-starter",
name: "Slider Test",
email,
consent: true,
},
})
).status(),
).toBe(201);
await page.goto("/index.html");
const title = `Neue Rezension ${randomUUID().slice(0, 8)}`;
const form = page.locator("#reviewForm");
await form.locator('[name="email"]').fill("unbekannt@example.test");
await form.locator('[name="title"]').fill(title);
await form
.locator('[name="review"]')
.fill("Eine neue und hilfreiche Erfahrung mit der Beratung.");
await form.locator('label[for="star4"]').click();
await form.locator('[name="consent"]').check();
await form.locator('[type="submit"]').click();
await expect(page.locator("#formStatus")).toContainText(
"keine Buchung gefunden",
);
await expect(page.locator("#reviewSlider")).not.toContainText(title);
await form.locator('[name="email"]').fill(email);
await form.locator('[type="submit"]').click();
await expect(page.locator("#formStatus")).toContainText("gespeichert");
await expect(active(page)).toContainText(title);
await expect(page.locator("#reviewSlider")).not.toContainText(email);
await page.reload();
await expect(page.locator("#reviewSlider")).toContainText(title);
});
test("Polling nimmt neue Backend-Rezensionen auf; Lesefehler erhalten vorhandene Karten", async ({
page,
}) => {
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
let rows = [reviews[0]];
let fail = false;
await page.route("**/api/reviews", (route) =>
route.fulfill(
fail ? { status: 503, json: {} } : { json: { reviews: rows } },
),
);
await page.goto("/index.html");
await expect(active(page)).toContainText(reviews[0].title);
rows = reviews;
await page.clock.runFor(10_000);
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
fail = true;
await page.clock.runFor(10_000);
await expect(page.locator(".review-feedback")).toContainText(
"nicht aktualisiert",
);
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
});

View File

@ -59,6 +59,33 @@ test("Kundenbewertung wird dauerhaft als CSV gespeichert; Buchungen bleiben unve
assert.equal(await readFile(bookingFile, "utf8"), before);
});
test("Bewertung funktioniert mit dem Buchungs-CSV-Format ohne Telefonnummer", async (t) => {
const { post, bookingFile, reviews } = await setup(t);
const header =
"bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent";
const booking = [
"TH-000001",
"2026-09-25T19:22:37.801Z",
"ernaehrung-starter",
"Paket",
"Kunde",
input.email,
"accepted",
"idempotency-key",
"request-hash",
"",
"",
"true",
"true",
].join(",");
await writeFile(bookingFile, `${header}\r\n${booking}\r\n`);
const response = await post();
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), { saved: true });
assert.equal((await reviews.readAll()).length, 1);
});
test("Unbekannte E-Mail, ungültige Felder und fehlende Zustimmung speichern nichts", async (t) => {
const { post, reviews } = await setup(t);
assert.equal(
@ -105,6 +132,55 @@ test("Grenzwerte und alle Sterne sind erlaubt; paralleles Speichern verliert kei
);
});
test("Review-API weist falschen Inhaltstyp, ungültiges JSON und zu große Bodies zurück", async (t) => {
const { url, reviews } = await setup(t);
const send = (body, contentType) =>
fetch(`${url}/api/reviews`, {
method: "POST",
headers: { "Content-Type": contentType },
body,
});
const unsupported = await send(JSON.stringify(input), "text/plain");
assert.equal(unsupported.status, 415);
assert.equal(
(await unsupported.json()).error.code,
"UNSUPPORTED_CONTENT_TYPE",
);
const malformed = await send("{broken", "application/json");
assert.equal(malformed.status, 400);
assert.equal((await malformed.json()).error.code, "INVALID_JSON");
const oversized = await send(" ".repeat(8193), "application/json");
assert.equal(oversized.status, 413);
assert.equal((await oversized.json()).error.code, "BODY_TOO_LARGE");
assert.deepEqual(await reviews.readAll(), []);
});
test("Fehler beim Lesen der Buchungs-CSV speichern keine Bewertung", async (t) => {
let appended = false;
const { post, reviews } = await setup(t, {
storage: {
readAll: async () => {
throw new Error("Booking CSV unreadable");
},
},
reviewStorage: {
append: async () => {
appended = true;
},
readAll: () => reviews.readAll(),
},
});
const response = await post();
assert.equal(response.status, 503);
assert.equal((await response.json()).error.code, "REVIEW_NOT_SAVED");
assert.equal(appended, false);
assert.deepEqual(await reviews.readAll(), []);
});
test("Speicherfehler bestätigt keinen Erfolg; beschädigte CSV bleibt erhalten", async (t) => {
const { post, file } = await setup(t);
await writeFile(file, "falscher,header\n1,2\n");
@ -126,9 +202,50 @@ test("CSV maskiert Tabellenformeln und bleibt über HTTP privat", async (t) => {
assert.equal((await post({ ...input, title })).status, 201);
assert.equal((await reviews.readAll())[0].title, title);
assert.ok((await readFile(file, "utf8")).includes("'=SUM"));
assert.equal((await fetch(`${url}/api/reviews`)).status, 405);
assert.equal(
(await fetch(`${url}/api/reviews`, { method: "DELETE" })).status,
405,
);
assert.equal(
(await fetch(`${url}/src/server/data/reviews.csv`)).status,
404,
);
});
test("Öffentliche Rezensionen kommen aus CSV, ohne E-Mail oder Zustimmung", async (t) => {
const { post, url, reviews } = await setup(t);
assert.deepEqual(await (await fetch(`${url}/api/reviews`)).json(), {
reviews: [],
});
await reviews.append({ ...input, consent: false, createdAt: "2026-01-01" });
assert.equal((await post()).status, 201);
const response = await fetch(`${url}/api/reviews`);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.equal(body.reviews.length, 1);
const [review] = body.reviews;
assert.deepEqual(Object.keys(review).sort(), [
"createdAt",
"rating",
"review",
"title",
]);
assert.equal(review.title, input.title);
assert.equal(review.review, input.review);
assert.equal(review.rating, 5);
assert.ok(!JSON.stringify(body).includes(input.email));
});
test("Lesefehler bei Rezensionen liefern einen kontrollierten Fehler", async (t) => {
const { url } = await setup(t, {
reviewStorage: {
readAll: async () => {
throw new Error("Disk error");
},
},
});
const response = await fetch(`${url}/api/reviews`);
assert.equal(response.status, 503);
assert.equal((await response.json()).error.code, "REVIEWS_UNAVAILABLE");
});