From 43473c0879e33a30c112bd2802732442d1fe306a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:26:56 +0000 Subject: [PATCH] =?UTF-8?q?Webshop:=20IP-Hash=20entf=C3=A4llt=20mit=20Zahl?= =?UTF-8?q?ung=20oder=20Storno,=20keine=20K=C3=A4ufer-Telefonnummer=20mehr?= =?UTF-8?q?=20gespeichert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist (Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus. Co-Authored-By: Claude Opus 5.5 --- public/shop-tab-bestellungen.js | 1 - server/services/shop/bestellungen.js | 16 ++++++++---- server/services/shop/jobs.js | 2 +- server/services/shop/mails.js | 2 +- test/shopAbschluss.test.js | 37 ++++++++++++++++++++++++++-- test/shopBestellAktionen.test.js | 12 +++++---- test/shopBestellungen.test.js | 19 ++++++++++++++ test/shopJobs.test.js | 4 +-- test/shopTabBestellungen.test.js | 5 ++-- 9 files changed, 79 insertions(+), 19 deletions(-) diff --git a/public/shop-tab-bestellungen.js b/public/shop-tab-bestellungen.js index da88d91..1493627 100644 --- a/public/shop-tab-bestellungen.js +++ b/public/shop-tab-bestellungen.js @@ -290,7 +290,6 @@ export function render(panel, ctx) { teile.push(abschnitt('Käufer und Lieferung', null, fakten( ['Name', zeichen(kaeufer.name)], ['E-Mail', mailLink(zeichen(kaeufer.email))], - zeichen(kaeufer.telefon) ? ['Telefon', kaeufer.telefon] : null, ['Lieferart', `${lieferartText(b.lieferart)}${b.lieferart === 'versand' && zeichen(b.land) ? ` (${b.land})` : ''}`], lieferung, b.rechnungsadresse && adresseText(b.rechnungsadresse) !== adresseText(b.lieferadresse) ? ['Rechnungsadresse', adresseText(b.rechnungsadresse)] : null, diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js index 157aa6b..96e7a89 100644 --- a/server/services/shop/bestellungen.js +++ b/server/services/shop/bestellungen.js @@ -20,8 +20,9 @@ import { holeProdukt, listeProdukte, parseProdukt } from './produkte.js' * bestellung (geparst: bestellDaten(doc), auch als doc.daten aus bestellungLesen): * { positionen, summen: { zwischensummeCent, versandCent, gesamtCent, steuer, texte }, lieferart, land, anbieter, shopname, * lieferzeit, zahlungsarten, abholung?, rechtstexteHash, verlauf: [{ at, was, ... }] } - * E2 ergaenzt im selben JSON: kaeufer { name, email, telefon }, lieferadresse / rechnungsadresse { name?, line1, line2, postal_code, + * E2 ergaenzt im selben JSON: kaeufer { name, email } (keine Telefonnummer), lieferadresse / rechnungsadresse { name?, line1, line2, postal_code, * city, country }, zahlungsart ('card' | 'klarna' | 'sepa_debit', nur wenn eindeutig), ueberverkauft, spaet; E3 erstattungen[]. Status offen reserviert Bestand bis ablaufAt. + * ipHash steht nur, solange die Bestellung offen ist (Begrenzung offener Bestellungen je IP); schliesseAb und verwirf entfernen ihn. * Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message. */ @@ -565,9 +566,11 @@ export async function schliesseAb({ accountId, session, now = new Date() }) { const at = jetzt.toISOString() let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) }) if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null }) + // Datenminimierung: Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt mit der Zahlung; eine Telefonnummer wird nie gespeichert + const { ipHash: _ipHash, ...datenOhneIp } = daten const neueDaten = { - ...daten, - kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) }, + ...datenOhneIp, + kaeufer: { name: kurz(kunde.name, 200), email }, ...(lieferadresse ? { lieferadresse } : {}), ...(rechnungsadresse ? { rechnungsadresse } : {}), ...(zahlungsart ? { zahlungsart } : {}), @@ -663,9 +666,11 @@ export async function verwirf(orderId, grund, { now = new Date(), mail = false, if (!shop || shop.stripeAccountId !== accountId) return { order: null, verworfen: false } } if (order.status !== 'offen') return { order, verworfen: false } + // Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt, sobald die Bestellung nicht mehr offen ist + const { ipHash: _ipHash, ...datenOhneIp } = order.daten const doc = await updateDocument(col(), orderId, { status: 'storniert', - bestellung: JSON.stringify({ ...order.daten, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }), + bestellung: JSON.stringify({ ...datenOhneIp, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }), }) return { order: { ...doc, daten: bestellDaten(doc) }, verworfen: true } }) @@ -1037,7 +1042,8 @@ export function bestellungKunde(order) { erstattetText: euro(erstattet), restCent: Math.max(0, summeCent - erstattet), restText: euro(Math.max(0, summeCent - erstattet)), - kaeufer: istObjekt(daten.kaeufer) ? daten.kaeufer : { name: '', email: order?.kaeuferEmail ?? '', telefon: '' }, + // Alte Datensaetze koennen noch eine Telefonnummer enthalten: nie ausgeben + kaeufer: istObjekt(daten.kaeufer) ? { name: daten.kaeufer.name ?? '', email: daten.kaeufer.email ?? '' } : { name: '', email: order?.kaeuferEmail ?? '' }, lieferart: daten.lieferart ?? '', land: daten.land ?? '', lieferadresse: istObjekt(daten.lieferadresse) ? daten.lieferadresse : null, diff --git a/server/services/shop/jobs.js b/server/services/shop/jobs.js index 653b1eb..02a1d79 100644 --- a/server/services/shop/jobs.js +++ b/server/services/shop/jobs.js @@ -161,7 +161,7 @@ async function anonymisiereBestellungen(lauf, shopId, grenze) { delete daten.lieferadresse delete daten.rechnungsadresse delete daten.ipHash - daten.kaeufer = { name: '', email: '', telefon: '' } + daten.kaeufer = { name: '', email: '' } await updateDocument(sammlung, doc.$id, { kaeuferEmail: '', bestellung: JSON.stringify(daten), diff --git a/server/services/shop/mails.js b/server/services/shop/mails.js index 1a5442d..aa873ab 100644 --- a/server/services/shop/mails.js +++ b/server/services/shop/mails.js @@ -30,7 +30,7 @@ import { gewaehrleistungsHinweis } from './rechtstexte.js' * snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') } * snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } } * snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT') - * snap.kaeufer { name, email, telefon } (oder snap.name) + * snap.kaeufer { name, email } (oder snap.name) * snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land } * snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text * snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops) diff --git a/test/shopAbschluss.test.js b/test/shopAbschluss.test.js index dd82910..7516f73 100644 --- a/test/shopAbschluss.test.js +++ b/test/shopAbschluss.test.js @@ -159,7 +159,8 @@ test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an assert.equal(doc.paymentIntentId, 'pi_test1') assert.equal(doc.kaeuferEmail, 'max@example.com') const daten = JSON.parse(doc.bestellung) - assert.deepEqual(daten.kaeufer, { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }) + assert.deepEqual(daten.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'Stripe liefert eine Telefonnummer, gespeichert wird sie nicht') + assert.ok(!doc.bestellung.includes('1234567'), 'keine Telefonnummer irgendwo im Bestell-JSON') assert.deepEqual(daten.lieferadresse, { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' }) assert.equal(daten.rechnungsadresse.line1, 'Rechnungsweg 1') assert.equal(daten.zahlungsart, 'card') @@ -397,7 +398,39 @@ test('schliesseAb: Abholung ohne Lieferadresse, Telefon und Namen fehlen', async const daten = JSON.parse(order(b.orderId).bestellung) assert.equal(daten.lieferadresse, undefined) assert.equal(daten.rechnungsadresse, undefined) - assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com', telefon: '' }) + assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com' }) +}) + +test('IP-Hash: nach der Zahlung weg (auch bei später Zahlung einer verworfenen Bestellung), vorher da', async () => { + await aufbau() + const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' }) + const b = { ...r, doc: order(r.orderId), sessionId: order(r.orderId).stripeSessionId } + assert.equal(JSON.parse(b.doc.bestellung).ipHash, 'abcdef0123456789', 'solange offen, steht der IP-Hash im Datensatz') + await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) + assert.equal(order(b.orderId).status, 'bezahlt') + assert.equal(JSON.parse(order(b.orderId).bestellung).ipHash, undefined, 'bezahlt: kein IP-Hash mehr') + assert.ok(!order(b.orderId).bestellung.includes('ipHash')) + + // spaete Zahlung: verworfen (IP-Hash weg), danach bezahlt - bleibt ohne IP-Hash + const r2 = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' }) + const b2 = { ...r2, doc: order(r2.orderId), sessionId: order(r2.orderId).stripeSessionId } + await B.verwirf(r2.orderId, 'abgelaufen', { now: nach(1900) }) + await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b2), now: nach(2000) }) + assert.equal(order(r2.orderId).status, 'bezahlt') + assert.ok(!order(r2.orderId).bestellung.includes('ipHash')) +}) + +test('IP-Hash: verwirf entfernt ihn, Verlauf und übrige Daten bleiben', async () => { + await aufbau() + const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' }) + assert.equal(JSON.parse(order(r.orderId).bestellung).ipHash, 'abcdef0123456789') + const erg = await B.verwirf(r.orderId, 'abgelaufen', { now: nach(1900) }) + assert.equal(erg.verworfen, true) + const daten = JSON.parse(order(r.orderId).bestellung) + assert.equal(daten.ipHash, undefined) + assert.equal(erg.order.daten.ipHash, undefined) + assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen']) + assert.equal(daten.positionen.length, 1) }) test('verwirf: nur aus offen; Verlauf, Reservierung frei; G14 nur mit mail und bekannter Adresse', async () => { diff --git a/test/shopBestellAktionen.test.js b/test/shopBestellAktionen.test.js index 006d981..912ff4f 100644 --- a/test/shopBestellAktionen.test.js +++ b/test/shopBestellAktionen.test.js @@ -145,7 +145,7 @@ function bestellung(over = {}, datenOver = {}) { shopname: 'Brot & Mehr', lieferzeit: '2–4 Werktage', zahlungsarten: ['card'], - kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }, + kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }, // alter Datensatz mit Telefonnummer: wird nie ausgegeben lieferadresse: lieferart === 'versand' ? { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: '', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } : undefined, zahlungsart: 'card', verlauf: [{ at: T0.toISOString(), was: 'angelegt' }, { at: nach(60).toISOString(), was: 'bezahlt' }], @@ -355,7 +355,7 @@ test('erstatte: Stripe-Fehler => 502 mit festem Text ohne Stripe-Wortlaut, nicht test('listeBestellungen: neueste zuerst, ohne Filter nur bezahlte, Statusfilter, Suche (Nummer-Anfang, Name, E-Mail), Blättern mit 50 je Seite', async () => { const ids = [] - for (let i = 0; i < 55; i += 1) ids.push(bestellung({ kaeuferEmail: `k${i}@example.com` }, { kaeufer: { name: i === 3 ? 'Erika Beispiel' : `Kunde ${i}`, email: `k${i}@example.com`, telefon: '' } })) + for (let i = 0; i < 55; i += 1) ids.push(bestellung({ kaeuferEmail: `k${i}@example.com` }, { kaeufer: { name: i === 3 ? 'Erika Beispiel' : `Kunde ${i}`, email: `k${i}@example.com` } })) const offen = bestellung({ status: 'offen' }) const storniert = bestellung({ status: 'storniert' }) const versendet = bestellung({ status: 'versendet' }) @@ -390,6 +390,8 @@ test('bestellungKunde: alle Händlerdaten, keine Token-/Stripe-Kennungen, mögli const id = bestellung({}, { erstattungen: [{ rid: 're_1', betragCent: 190, grund: 'Kulanz', at: nach(9).toISOString(), quelle: 'portal' }], ueberverkauft: true }) const ansicht = B.bestellungKunde(await B.bestellungLesen(id)) assert.equal(ansicht.kaeufer.email, 'max@example.com') + assert.deepEqual(ansicht.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'eine alte Telefonnummer im Datensatz wird nicht ausgegeben') + assert.ok(!JSON.stringify(ansicht).includes('1234567')) assert.equal(ansicht.lieferadresse.line1, 'Lieferweg 7') assert.equal(ansicht.erstattetCent, 190) assert.equal(ansicht.restCent, 1500) @@ -407,10 +409,10 @@ test('bestellungKunde: alle Händlerdaten, keine Token-/Stripe-Kennungen, mögli // ------------------------------------------------------------------------------------------------ CSV test('csv: BOM, Semikolon, Kopfzeile, Beträge ohne NBSP, Berliner Datum, Formel-Injektion entschärft, Anführungszeichen verdoppelt', async () => { - const a = bestellung({ bezahltAt: '2026-10-04T22:30:00.000Z', summeCent: 1250, kaeuferEmail: '+evil@example.com' }, { kaeufer: { name: '=HYPERLINK("http://x.example";"a")', email: '+evil@example.com', telefon: '' } }) + const a = bestellung({ bezahltAt: '2026-10-04T22:30:00.000Z', summeCent: 1250, kaeuferEmail: '+evil@example.com' }, { kaeufer: { name: '=HYPERLINK("http://x.example";"a")', email: '+evil@example.com' } }) const b = bestellung( { bezahltAt: '2026-10-06T08:00:00.000Z', status: 'erstattet', kaeuferEmail: 'anna@example.com' }, - { kaeufer: { name: 'Anna; "Annie" Schmidt', email: 'anna@example.com', telefon: '' }, erstattungen: [{ rid: 're_x', betragCent: 1690, grund: '', at: T0.toISOString(), quelle: 'portal' }] } + { kaeufer: { name: 'Anna; "Annie" Schmidt', email: 'anna@example.com' }, erstattungen: [{ rid: 're_x', betragCent: 1690, grund: '', at: T0.toISOString(), quelle: 'portal' }] } ) bestellung({ status: 'offen' }) bestellung({ status: 'storniert' }) @@ -551,7 +553,7 @@ test('Routen: Kundenansicht darf lesen (Liste, Detail, CSV, Widerrufe), aber wed }) test('Routen: CSV als Download mit BOM, Dateiname und Zeitraum; ungültiges Datum 400', async () => { - bestellung({ bezahltAt: '2026-10-05T10:00:00.000Z' }, { kaeufer: { name: '@SUM(A1)', email: 'a@example.com', telefon: '' } }) + bestellung({ bezahltAt: '2026-10-05T10:00:00.000Z' }, { kaeufer: { name: '@SUM(A1)', email: 'a@example.com' } }) bestellung({ bezahltAt: '2026-09-01T10:00:00.000Z' }) const res = await fetch(`${app.url}/api/shop/bestellungen.csv?projectId=${PROJEKT}&von=2026-10-01&bis=2026-10-31`, { headers: { cookie } }) assert.equal(res.status, 200) diff --git a/test/shopBestellungen.test.js b/test/shopBestellungen.test.js index c3cf583..6e6b6c8 100644 --- a/test/shopBestellungen.test.js +++ b/test/shopBestellungen.test.js @@ -323,6 +323,25 @@ test('Reservierung: der letzte Artikel geht nur einmal weg, abgelaufene Reservie assert.notEqual(zweite.orderId, erste.orderId) }) +test('Begrenzung offener Bestellungen je IP-Hash: drei offen in 30 Minuten, danach 429; wieder frei, sobald eine nicht mehr offen ist', async () => { + const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 50 })] }) + const mit = (ip, now) => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now, ipHash: ip }) + const ids = [] + for (let i = 0; i < 3; i += 1) ids.push((await mit('aaaaaaaaaaaaaaaa', nach(i))).orderId) + assert.ok(orders().every((o) => JSON.parse(o.bestellung).ipHash === 'aaaaaaaaaaaaaaaa'), 'offen: IP-Hash steht im Datensatz') + const err = await mit('aaaaaaaaaaaaaaaa', nach(10)).catch((e) => e) + assert.equal(err.status, 429) + assert.equal(err.code, 'zu_viele_offen') + assert.ok((await mit('bbbbbbbbbbbbbbbb', nach(11))).orderId, 'andere IP unberuehrt') + + // Eine verworfene Bestellung traegt keinen IP-Hash mehr und zaehlt nicht mehr + assert.equal((await B.verwirf(ids[0], 'zahlung_fehlgeschlagen', { now: nach(20) })).verworfen, true) + assert.equal(JSON.parse(orders().find((o) => o.$id === ids[0]).bestellung).ipHash, undefined) + assert.ok((await mit('aaaaaaaaaaaaaaaa', nach(21))).orderId, 'nach verwirf wieder eine offene moeglich') + const err2 = await mit('aaaaaaaaaaaaaaaa', nach(22)).catch((e) => e) + assert.equal(err2.code, 'zu_viele_offen') +}) + test('reserviert: nur offene Bestellungen dieses Shops, Varianten mit Doppelpunkt-Schluessel, Mengen summiert', async () => { await aufbau() const ablauf = nach(1800).toISOString() diff --git a/test/shopJobs.test.js b/test/shopJobs.test.js index 2f57c23..5747e1d 100644 --- a/test/shopJobs.test.js +++ b/test/shopJobs.test.js @@ -47,7 +47,7 @@ const shop = (shopId, over = {}) => aw.seed('shopConfig', { $id: shopId, custome const feature = (shopId, over = {}) => aw.seed('portalFeatures', { $id: shopId, customerId: 'cs1', projectId: 'p1', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true, ...over }) const PERSON = { - kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '0123' }, + kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '0123' }, // alter Datensatz: Telefonnummer aus der Zeit vor der Datenminimierung lieferadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' }, rechnungsadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' }, } @@ -123,7 +123,7 @@ test('Bestellung aus 2026 wird genau am 1. Januar 2030 (Berlin) anonymisiert, ni assert.ok(doc.anonymisiertAt) const d = daten(o) assert.equal(d.ipHash, undefined, 'der IP-Hash wird mit anonymisiert') - assert.deepEqual(d.kaeufer, { name: '', email: '', telefon: '' }) + assert.deepEqual(d.kaeufer, { name: '', email: '' }, 'auch eine alte Telefonnummer ist weg') assert.equal('lieferadresse' in d, false) assert.equal('rechnungsadresse' in d, false) assert.equal(d.positionen[0].name, 'Tasse', 'Positionen bleiben') diff --git a/test/shopTabBestellungen.test.js b/test/shopTabBestellungen.test.js index 219cb63..52ad71e 100644 --- a/test/shopTabBestellungen.test.js +++ b/test/shopTabBestellungen.test.js @@ -17,7 +17,7 @@ const liste = (zeilen, extra = {}) => ({ bestellungen: zeilen, seite: 1, mehr: f const detail = (extra = {}) => ({ id: 'o1', bestellnummer: 'WK-1', status: 'bezahlt', erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-05T10:05:00.000Z', summeCent: 4590, summeText: '45,90 €', erstattetCent: 0, erstattetText: '0,00 €', restCent: 4590, restText: '45,90 €', - kaeufer: { name: 'Anna Beispiel', email: 'anna@example.com', telefon: '0151 123' }, + kaeufer: { name: 'Anna Beispiel', email: 'anna@example.com', telefon: '0151 123' }, // alte API-Antwort: die Anzeige zeigt keine Telefonnummer lieferart: 'versand', land: 'DE', lieferadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' }, rechnungsadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' }, @@ -164,6 +164,7 @@ test('Detail: Positionen, Summen, Käufer, Adresse, Zahlung, Verlauf; Fremdtext assert.ok(text.includes(erwartet), erwartet) } assert.equal(fx.alle(panel, 'b').length, 0, 'Käufername ist Text, kein Markup') + assert.ok(!text.includes('0151 123') && !text.includes('Telefon'), 'keine Käufer-Telefonnummer in der Anzeige') const mail = mailto(fx, panel) assert.equal(mail.getAttribute('href'), 'mailto:anna@example.com') assert.equal(fx.finde(panel, '[data-hinweis="sepa"]'), null) @@ -173,7 +174,7 @@ test('Detail: Positionen, Summen, Käufer, Adresse, Zahlung, Verlauf; Fremdtext })) test('Detail: Abholung zeigt Adresse und Hinweis, keine Lieferadresse; E-Mail mit Sonderzeichen ist kein Link', mit(server({ - 'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ lieferart: 'abholung', lieferadresse: null, abholung: { adresse: 'Laden 5, 80331 München', hinweis: 'Mo–Fr 10–18 Uhr' }, kaeufer: { name: 'A', email: 'a@b.de"> ({ body: { bestellung: detail({ lieferart: 'abholung', lieferadresse: null, abholung: { adresse: 'Laden 5, 80331 München', hinweis: 'Mo–Fr 10–18 Uhr' }, kaeufer: { name: 'A', email: 'a@b.de"> { const { panel } = await oeffneDetail(fx) const text = fx.text(panel)