Files
Webklar-Kundenbereich/test/shopTabBestellungen.test.js
ClaudeandClaude Opus 5.5 43473c0879 Webshop: IP-Hash entfällt mit Zahlung oder Storno, keine Käufer-Telefonnummer mehr gespeichert
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 <noreply@anthropic.com>
2026-10-05 10:26:56 +00:00

524 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { euroText, h, request } from '../public/shop-dom.js'
import { render } from '../public/shop-tab-bestellungen.js'
// Task G5: Reiter „Bestellungen“ (public/shop-tab-bestellungen.js) mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut
// (Form: server/routes/shop.js und server/services/shop/bestellungen.js listenZeile/bestellungKunde, widerrufe.js ansicht).
const zeile = (nr, extra = {}) => ({
id: `o${nr}`, bestellnummer: `WK-${nr}`, status: 'bezahlt', erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-05T10:05:00.000Z',
summeCent: 4590, summeText: '45,90 €', erstattetCent: 0, kaeuferName: `Kunde ${nr}`, kaeuferEmail: `k${nr}@example.com`, lieferart: 'versand',
anzahlArtikel: 2, ueberverkauft: false, spaet: false, ...extra,
})
const liste = (zeilen, extra = {}) => ({ bestellungen: zeilen, seite: 1, mehr: false, abgeschnitten: false, ...extra })
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 <b>Beispiel</b>', 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' },
abholung: null, zahlungsart: 'card', lieferzeit: '2–4 Werktage',
positionen: [
{ name: 'Mütze', varianteText: 'Blau', menge: 2, summeCent: 3000, summeText: '30,00 €' },
{ name: 'Schal', varianteText: '', menge: 1, summeCent: 1590, summeText: '15,90 €' },
],
summen: { zwischensummeCent: 4590, versandCent: 0, gesamtCent: 4590, steuer: [], texte: { zwischensumme: '45,90 €', versand: 'kostenlos', gesamt: '45,90 €', steuerHinweis: 'Kein Steuerausweis nach § 19 UStG' } },
versand: null, ueberverkauft: false, spaet: false,
verlauf: [{ at: '2026-10-05T10:00:00.000Z', was: 'angelegt' }, { at: '2026-10-05T10:05:00.000Z', was: 'bezahlt' }],
erstattungen: [], aktionen: ['versendet', 'abgeschlossen'], kannErstatten: true, ...extra,
})
const widerruf = (nr, extra = {}) => ({
id: `w${nr}`, bestellnummer: `WK-${nr}`, name: `Wida ${nr}`, email: `w${nr}@example.com`, nachricht: 'Ich widerrufe <script>alert(1)</script>',
eingangAt: '2026-10-05T12:30:00.000Z', bestaetigungAt: '2026-10-05T12:30:05.000Z', bearbeitetAt: null, orderId: `o${nr}`, ...extra,
})
/** Fake-Server: jede Anfrage geht an route(aufruf) ⇒ { status, body }; Standard 200 {}. */
const starte = (route) => installFakeDom({ antworten: Array.from({ length: 60 }, () => route) })
function baueCtx({ nurLesen = false, abwickeln } = {}) {
const ctx = {
projectId: 'p1', status: { state: 'active' }, nurLesen, ...(abwickeln ? { abwickeln } : {}), meldungen: [], aktuell: () => true, request,
zeigeMeldung: (text, art) => ctx.meldungen.push({ text, art }),
neuLaden: async () => ctx.status,
}
return ctx
}
async function zeige(fx, optionen = {}) {
const ctx = baueCtx(optionen)
const panel = h('div', { id: 'sh-panel' })
fx.document.body.append(panel)
await render(panel, ctx)
await fx.ruhe()
return { panel, ctx }
}
const mit = (route, fn) => async () => {
const fx = starte(route)
try {
await fn(fx)
} finally {
fx.restore()
}
}
const aktion = (fx, el, name) => fx.finde(el, `[data-aktion="${name}"]`)
const gets = (fx) => fx.fetchLog.filter((a) => a.method === 'GET').map((a) => a.url)
const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const letzteMeldung = (ctx) => ctx.meldungen.at(-1)
const mailto = (fx, el) => fx.finde(el, (n) => n.tagName === 'A' && (n.getAttribute('href') || '').startsWith('mailto:'))
/** Standard-Server: Liste mit zwei Zeilen, Detail, Aktionen und Erstattung. */
const server = (ueberschreibe = {}) => (a) => {
const u = a.url
if (ueberschreibe[`${a.method} ${u.split('?')[0]}`]) return ueberschreibe[`${a.method} ${u.split('?')[0]}`](a)
if (a.method === 'GET' && u.startsWith('/api/shop/bestellungen?')) return { body: liste([zeile(1), zeile(2, { status: 'versendet', lieferart: 'abholung' })]) }
if (a.method === 'GET' && u.startsWith('/api/shop/bestellungen/')) return { body: { bestellung: detail() } }
if (a.method === 'GET' && u.startsWith('/api/shop/widerrufe?')) return { body: { widerrufe: [widerruf(1), widerruf(2, { bearbeitetAt: '2026-10-06T08:00:00.000Z' })], seite: 1, mehr: false } }
return { body: {} }
}
async function oeffneDetail(fx, optionen) {
const r = await zeige(fx, optionen)
await fx.klick(fx.finde(r.panel, '[data-bestellung="o1"]'))
await fx.ruhe()
return r
}
/* ----------------------------------------------------------------- Liste --- */
test('Liste: Standard ist „bezahlt“, Zeilen zeigen Nummer, Datum, Name, Lieferart, Gesamt und Status', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
assert.deepEqual(gets(fx), ['/api/shop/bestellungen?projectId=p1&status=bezahlt&seite=1'])
const zeilen = fx.alle(panel, '[data-bestellung]')
assert.equal(zeilen.length, 2)
const text = fx.text(zeilen[0])
assert.match(text, /WK-1/)
assert.match(text, /05\.10\.2026/)
assert.match(text, /Kunde 1/)
assert.match(text, /Versand/)
assert.match(text, /45,90 €/)
assert.match(text, /Bezahlt/)
assert.match(fx.text(zeilen[1]), /Abholung/)
assert.match(fx.text(zeilen[1]), /Versendet/)
assert.equal(aktion(fx, panel, 'weiter'), null, 'ohne weitere Seite keine Blätterleiste')
}))
test('Filter und Suche setzen die Abfrage (Status, Suche kodiert, zurück auf Seite 1)', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'versendet')
await fx.ruhe()
assert.equal(gets(fx).at(-1), '/api/shop/bestellungen?projectId=p1&status=versendet&seite=1')
await fx.setze(fx.finde(panel, '#sh-b-suche'), ' Anna Müller & Co ')
await fx.klick(aktion(fx, panel, 'suchen'))
await fx.ruhe()
assert.equal(gets(fx).at(-1), `/api/shop/bestellungen?projectId=p1&status=versendet&suche=${encodeURIComponent('Anna Müller & Co')}&seite=1`)
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'alle')
await fx.ruhe()
assert.match(gets(fx).at(-1), /status=alle&suche=Anna/, 'die Suche bleibt beim Wechsel des Filters')
}))
test('Blättern: Weiter und Zurück fragen die nächste Seite, die Leiste folgt dem Server', mit((a) => {
if (a.url.startsWith('/api/shop/bestellungen?')) {
const seite = Number(/seite=(\d+)/.exec(a.url)[1])
return { body: liste([zeile(seite)], { seite, mehr: seite < 2 }) }
}
return { body: {} }
}, async (fx) => {
const { panel } = await zeige(fx)
assert.equal(aktion(fx, panel, 'zurueck').hasAttribute('disabled'), true)
await fx.klick(aktion(fx, panel, 'weiter'))
await fx.ruhe()
assert.match(gets(fx).at(-1), /seite=2$/)
assert.match(fx.text(panel), /Seite 2/)
assert.equal(aktion(fx, panel, 'weiter').hasAttribute('disabled'), true)
await fx.klick(aktion(fx, panel, 'zurueck'))
await fx.ruhe()
assert.match(gets(fx).at(-1), /seite=1$/)
assert.match(fx.text(panel), /WK-1/)
}))
test('Leere Liste, Ladefehler und Hinweis bei abgeschnittener Suche', mit((a) => {
if (a.url.includes('status=erstattet')) return { status: 500, body: { error: 'Server kaputt.' } }
if (a.url.includes('status=alle')) return { body: liste([zeile(1)], { abgeschnitten: true }) }
return { body: liste([]) }
}, async (fx) => {
const { panel } = await zeige(fx)
assert.ok(fx.finde(panel, '[data-leer]'))
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'erstattet')
await fx.ruhe()
assert.match(fx.text(panel), /Server kaputt\./)
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'alle')
await fx.ruhe()
assert.match(fx.text(panel), /nur die neuesten Bestellungen durchsucht/)
}))
/* ---------------------------------------------------------------- Detail --- */
test('Detail: Positionen, Summen, Käufer, Adresse, Zahlung, Verlauf; Fremdtext bleibt Text', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx)
assert.equal(gets(fx).at(-1), '/api/shop/bestellungen/o1?projectId=p1')
const text = fx.text(panel)
for (const erwartet of ['Bestellung WK-1', '2 × Mütze (Blau)', '30,00 €', '1 × Schal', '15,90 €', 'kostenlos', 'Kein Steuerausweis', 'Anna <b>Beispiel</b>', 'Hauptstr. 1', '10115 Berlin', 'Karte', '2–4 Werktage', 'Bestellung angelegt', 'Bezahlt']) {
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)
await fx.klick(aktion(fx, panel, 'liste'))
await fx.ruhe()
assert.ok(fx.finde(panel, '#sh-b-filter'), 'zurück in der Liste')
}))
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"><x' }, aktionen: ['abholbereit', 'abgeschlossen'] }) } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
const text = fx.text(panel)
assert.ok(text.includes('Laden 5, 80331 München'))
assert.ok(text.includes('Mo–Fr 10–18 Uhr'))
assert.ok(!text.includes('Lieferadresse'))
assert.equal(mailto(fx, panel), null)
assert.ok(aktion(fx, panel, 'abholbereit'))
assert.equal(aktion(fx, panel, 'versendet'), null)
}))
test('Detail: SEPA-Hinweis nur bei Lastschrift und Status bezahlt', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ zahlungsart: 'sepa_debit' }) } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
assert.equal(fx.text(fx.finde(panel, '[data-hinweis="sepa"]')), 'Lastschrift – versende erst, wenn das Geld sicher ist (nach einigen Tagen im Stripe-Dashboard sichtbar).')
assert.ok(fx.text(panel).includes('SEPA-Lastschrift'))
}))
test('Detail: SEPA ohne Hinweis, wenn schon versendet', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ zahlungsart: 'sepa_debit', status: 'versendet', aktionen: ['abgeschlossen'] }) } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
assert.equal(fx.finde(panel, '[data-hinweis="sepa"]'), null)
}))
test('Detail: Ladefehler führt zurück in die Liste mit Meldung', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ status: 404, body: { error: 'Diese Bestellung gibt es nicht.' } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
assert.ok(fx.finde(panel, '#sh-b-filter'))
assert.deepEqual(letzteMeldung(ctx), { text: 'Diese Bestellung gibt es nicht.', art: 'fehler' })
}))
/* -------------------------------------------------------------- Aktionen --- */
test('Versendet: Formular mit Versanddienst und Sendungsnummer, Body, Meldung, Detail zeigt neuen Stand', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'versendet', aktionen: ['abgeschlossen'], kannErstatten: true, versand: { versanddienst: 'DHL', sendungsnummer: 'JJD123' } }), mailOk: true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'versendet'))
assert.equal(posts(fx).length, 0, 'erst das Formular')
const form = fx.finde(panel, 'form[aria-label="Als versendet markieren"]')
await fx.setze(fx.finde(form, '[data-feld="versanddienst"]'), ' DHL ')
await fx.setze(fx.finde(form, '[data-feld="sendungsnummer"]'), 'JJD123')
await fx.klick(aktion(fx, form, 'versendet-speichern'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/bestellungen/o1/aktion', { projectId: 'p1', aktion: 'versendet', versanddienst: 'DHL', sendungsnummer: 'JJD123' }]])
assert.match(letzteMeldung(ctx).text, /Als versendet gespeichert/)
assert.equal(letzteMeldung(ctx).art, 'ok')
assert.equal(aktion(fx, panel, 'versendet'), null)
assert.ok(fx.text(panel).includes('DHL, JJD123'))
}))
test('Versendet ohne Angaben schickt leere Texte; Abholbereit und Abgeschlossen gehen direkt', mit(server({
'POST /api/shop/bestellungen/o1/aktion': (a) => ({ body: { bestellung: detail({ aktionen: ['abholbereit', 'abgeschlossen', 'versendet'] }), mailOk: a.body.aktion !== 'abholbereit' || true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'versendet'))
await fx.klick(aktion(fx, panel, 'versendet-speichern'))
await fx.ruhe()
assert.deepEqual(posts(fx)[0].body, { projectId: 'p1', aktion: 'versendet', versanddienst: '', sendungsnummer: '' })
await fx.klick(aktion(fx, panel, 'abholbereit'))
await fx.ruhe()
assert.deepEqual(posts(fx)[1].body, { projectId: 'p1', aktion: 'abholbereit' })
assert.match(letzteMeldung(ctx).text, /abholbereit/)
await fx.klick(aktion(fx, panel, 'abgeschlossen'))
await fx.ruhe()
assert.deepEqual(posts(fx)[2].body, { projectId: 'p1', aktion: 'abgeschlossen' })
}))
test('mailOk false: Meldung „Gespeichert, aber die Mail an den Käufer ging nicht raus.“', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'versendet', aktionen: ['abgeschlossen'] }), mailOk: false } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'abgeschlossen'))
await fx.ruhe()
assert.deepEqual(letzteMeldung(ctx), { text: 'Gespeichert, aber die Mail an den Käufer ging nicht raus.', art: 'fehler' })
assert.match(fx.text(panel), /Versendet/)
}))
test('Stornieren: nur wenn der Server es anbietet, erst nach Rückfrage', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ status: 'offen', aktionen: ['storniert'], kannErstatten: false }) } }),
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'storniert', aktionen: [], kannErstatten: false }), mailOk: true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'storniert'))
assert.equal(posts(fx).length, 0)
assert.ok(fx.text(panel).includes('Bestellung wirklich stornieren?'))
await fx.klick(aktion(fx, panel, 'abbrechen'))
assert.equal(fx.finde(panel, 'form[aria-label="Bestellung wirklich stornieren?"]'), null)
await fx.klick(aktion(fx, panel, 'storniert'))
await fx.klick(aktion(fx, panel, 'stornieren-bestaetigen'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => p.body), [{ projectId: 'p1', aktion: 'storniert' }])
assert.match(letzteMeldung(ctx).text, /storniert/)
assert.equal(aktion(fx, panel, 'storniert'), null)
}))
test('Aktionsfehler (403 Kundenansicht) zeigt den Text des Servers', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ status: 403, body: { error: 'In der Kundenansicht kann das nicht geändert werden.' } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'abgeschlossen'))
await fx.ruhe()
assert.deepEqual(letzteMeldung(ctx), { text: 'In der Kundenansicht kann das nicht geändert werden.', art: 'fehler' })
assert.ok(aktion(fx, panel, 'abgeschlossen'), 'Detail bleibt stehen')
}))
/* -------------------------------------------------------------- Erstatten --- */
test('Erstatten: Betrag vorbelegt mit dem Rest, Rückfrage mit Betrag, Body in Cent und Grund', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: {
bestellung: detail({ erstattetCent: 1250, erstattetText: '12,50 €', restCent: 3340, restText: '33,40 €', erstattungen: [{ rid: 're_1', betragCent: 1250, grund: 'Kulanz', at: '2026-10-06T08:00:00.000Z' }] }),
erstattung: { rid: 're_1', betragCent: 1250, grund: 'Kulanz' }, voll: false, mailOk: true,
} }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
const form = fx.finde(panel, 'form[data-form="erstatten"]')
const betrag = fx.finde(form, '#sh-erstatten-betrag')
assert.equal(betrag.value, '45,90')
await fx.setze(betrag, '12,5')
await fx.setze(fx.finde(form, '[data-feld="grund"]'), ' Kulanz ')
await fx.klick(aktion(fx, form, 'erstatten'))
assert.equal(posts(fx).length, 0, 'erst die Rückfrage')
assert.ok(fx.text(panel).includes(`Wirklich ${euroText(1250)} an den Käufer zurückzahlen? Das geht nicht rückgängig.`))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/bestellungen/o1/erstatten', { projectId: 'p1', betragCent: 1250, grund: 'Kulanz' }]])
assert.ok(letzteMeldung(ctx).text.startsWith(`${euroText(1250)} erstattet`))
assert.ok(!letzteMeldung(ctx).text.includes('vollständig'))
const erst = fx.finde(panel, '[data-liste="erstattungen"]')
assert.ok(fx.text(erst).includes(`${euroText(1250)}: Kulanz`))
assert.equal(fx.finde(panel, '#sh-erstatten-betrag').value, '33,40', 'neuer Rest')
}))
test('Erstatten: ohne Grund wird kein Grund gesendet, Abbrechen sendet nichts', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: { bestellung: detail({ status: 'erstattet', aktionen: [], kannErstatten: false }), erstattung: { betragCent: 4590 }, voll: true, mailOk: true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'abbrechen'))
assert.equal(posts(fx).length, 0)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.deepEqual(posts(fx)[0].body, { projectId: 'p1', betragCent: 4590 })
assert.match(letzteMeldung(ctx).text, /vollständig erstattet/)
assert.equal(fx.finde(panel, 'form[data-form="erstatten"]'), null, 'nichts mehr zu erstatten')
}))
test('Erstatten: unlesbarer, zu hoher und leerer Betrag werden vor der Rückfrage abgelehnt', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx)
const form = fx.finde(panel, 'form[data-form="erstatten"]')
const betrag = fx.finde(form, '#sh-erstatten-betrag')
for (const [eingabe, muster] of [['zwölf', /Betrag in Euro an/], ['99,00', new RegExp(`höchstens ${euroText(4590)}`)], ['0', /größer als 0/], ['', /Betrag in Euro an/]]) {
await fx.setze(betrag, eingabe)
await fx.klick(aktion(fx, form, 'erstatten'))
assert.match(fx.text(fx.finde(form, '[data-fehler="betrag"]')), muster, eingabe)
}
assert.equal(fx.finde(panel, '[data-aktion="erstatten-bestaetigen"]'), null)
assert.equal(posts(fx).length, 0)
}))
test('Erstatten: Stripe-Fehler 502 zeigt den Text am Formular, nichts wird als erstattet gezeigt', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ status: 502, body: { error: 'Die Erstattung ist bei Stripe fehlgeschlagen. Bitte versuch es später noch einmal.' } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
const form = fx.finde(panel, 'form[data-form="erstatten"]')
assert.match(fx.text(fx.finde(form, '[data-fehler="_"]')), /bei Stripe fehlgeschlagen/)
assert.equal(fx.finde(panel, '[data-aktion="erstatten-bestaetigen"]'), null, 'Rückfrage ist zu')
assert.equal(ctx.meldungen.filter((m) => m.art === 'ok' && m.text).length, 0)
assert.equal(fx.alle(panel, 'button').some((b) => b.hasAttribute('disabled')), false, 'Knöpfe sind wieder frei')
}))
test('Erstatten: Feldfehler des Servers (betrag) landet am Feld', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ status: 400, body: { error: 'x', fehler: { betrag: 'Bitte gib einen Betrag zwischen 0,01 € und 10,00 € an.' } } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.match(fx.text(fx.finde(panel, '[data-fehler="betrag"]')), /zwischen 0,01 €/)
}))
test('Erstatten mit mailOk false meldet die fehlende Mail', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: { bestellung: detail(), erstattung: { betragCent: 4590 }, voll: true, mailOk: false } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.deepEqual(letzteMeldung(ctx), { text: 'Gespeichert, aber die Mail an den Käufer ging nicht raus.', art: 'fehler' })
}))
test('Doppelklick auf eine Aktion schickt nur einen Aufruf', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'abgeschlossen', aktionen: [] }), mailOk: true } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
const knopf = aktion(fx, panel, 'abgeschlossen')
await Promise.all([fx.klick(knopf), fx.klick(knopf)])
await fx.ruhe()
assert.equal(posts(fx).length, 1)
}))
/* ------------------------------------------------------------------- CSV --- */
test('CSV: Link mit Projekt, Datum nach Eingabe, kodiert, nur Pfad auf dem eigenen Server', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
const link = fx.finde(panel, '[data-link="csv"]')
assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1')
assert.ok(link.hasAttribute('download'))
await fx.setze(fx.finde(panel, '#sh-csv-von'), '2026-10-01')
assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1&von=2026-10-01')
await fx.setze(fx.finde(panel, '#sh-csv-bis'), '2026-10-31')
assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1&von=2026-10-01&bis=2026-10-31')
assert.ok(link.getAttribute('href').startsWith('/api/'))
}))
test('CSV: die Projekt-ID wird kodiert', async () => {
const fx = starte(server())
try {
const ctx = { ...baueCtx(), projectId: 'a&b=c' }
const panel = h('div')
fx.document.body.append(panel)
await render(panel, ctx)
await fx.ruhe()
assert.equal(fx.finde(panel, '[data-link="csv"]').getAttribute('href'), `/api/shop/bestellungen.csv?projectId=${encodeURIComponent('a&b=c')}`)
} finally {
fx.restore()
}
})
/* -------------------------------------------------------------- Widerrufe --- */
test('Widerrufe: Liste mit Eingang, Name, E-Mail, Nummer, Erklärung und Stand; Hinweis', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.equal(gets(fx).at(-1), '/api/shop/widerrufe?projectId=p1&seite=1')
assert.ok(fx.text(panel).includes('Ein Widerruf ist erst erledigt, wenn du die Ware zurückhast oder erstattet hast (spätestens 14 Tage nach Eingang).'))
const w1 = fx.finde(panel, '[data-widerruf="w1"]')
const text = fx.text(w1)
for (const erwartet of ['05.10.2026', '14:30', 'Wida 1', 'w1@example.com', 'WK-1', 'Ich widerrufe <script>alert(1)</script>', 'offen']) assert.ok(text.includes(erwartet), erwartet)
assert.equal(fx.alle(panel, 'script').length, 0)
assert.equal(mailto(fx, w1).getAttribute('href'), 'mailto:w1@example.com')
assert.match(fx.text(fx.finde(panel, '[data-widerruf="w2"]')), /erledigt am 06\.10\.2026/)
assert.ok(aktion(fx, w1, 'erledigt'))
assert.equal(aktion(fx, fx.finde(panel, '[data-widerruf="w2"]'), 'erledigt'), null)
}))
test('Widerruf erledigt: POST, Meldung, Liste wird neu geladen', async () => {
let erledigt = false
const fx = starte((a) => {
if (a.method === 'POST') { erledigt = true; return { body: { widerruf: widerruf(1, { bearbeitetAt: '2026-10-06T08:00:00.000Z' }) } } }
if (a.url.startsWith('/api/shop/widerrufe?')) return { body: { widerrufe: [widerruf(1, erledigt ? { bearbeitetAt: '2026-10-06T08:00:00.000Z' } : {})], seite: 1, mehr: false } }
return { body: liste([]) }
})
try {
const { panel, ctx } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
await fx.klick(aktion(fx, panel, 'erledigt'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/widerrufe/w1/erledigt', { projectId: 'p1' }]])
assert.equal(letzteMeldung(ctx).text, 'Der Widerruf ist als erledigt markiert.')
assert.equal(gets(fx).filter((u) => u.startsWith('/api/shop/widerrufe?')).length, 2)
assert.match(fx.text(panel), /erledigt am 06\.10\.2026/)
assert.equal(aktion(fx, panel, 'erledigt'), null)
} finally {
fx.restore()
}
})
test('Widerruf: „Zur Bestellung“ öffnet das Detail; leere Liste und Blättern', mit((a) => {
if (a.url.startsWith('/api/shop/widerrufe?')) {
const seite = Number(/seite=(\d+)/.exec(a.url)[1])
return { body: { widerrufe: seite === 1 ? [widerruf(1)] : [], seite, mehr: seite === 1 } }
}
return server()(a)
}, async (fx) => {
const { panel } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
await fx.klick(aktion(fx, panel, 'weiter'))
await fx.ruhe()
assert.ok(fx.finde(panel, '[data-leer]'))
await fx.klick(aktion(fx, panel, 'zurueck'))
await fx.ruhe()
await fx.klick(aktion(fx, panel, 'zur-bestellung'))
await fx.ruhe()
assert.equal(gets(fx).at(-1), '/api/shop/bestellungen/o1?projectId=p1')
assert.ok(fx.text(panel).includes('Bestellung WK-1'))
}))
test('Wechsel zwischen den Ansichten: die Liste lädt neu und der Knopf zeigt die Ansicht', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.ok(fx.finde(panel, '[data-widerruf="w1"]'))
await fx.klick(fx.finde(panel, '[data-ansicht="bestellungen"]'))
await fx.ruhe()
assert.equal(fx.finde(panel, '[data-widerruf="w1"]'), null)
assert.ok(fx.finde(panel, '[data-bestellung="o1"]'))
assert.equal(fx.finde(panel, '[data-ansicht="bestellungen"]').getAttribute('aria-pressed'), 'true')
}))
/* ------------------------------------------------------------- nur lesen --- */
test('nurLesen: keine Aktionen, kein Erstatten, kein „erledigt“; Lesen und CSV bleiben', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx, { nurLesen: true })
assert.ok(fx.text(panel).includes('Bestellung WK-1'))
for (const name of ['versendet', 'abgeschlossen', 'erstatten', 'abholbereit', 'storniert']) assert.equal(aktion(fx, panel, name), null, name)
assert.equal(fx.finde(panel, 'form[data-form="erstatten"]'), null)
await fx.klick(aktion(fx, panel, 'liste'))
await fx.ruhe()
assert.ok(fx.finde(panel, '[data-link="csv"]'))
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.equal(aktion(fx, panel, 'erledigt'), null)
assert.ok(fx.finde(panel, '[data-widerruf="w1"]'))
assert.equal(posts(fx).length, 0)
}))
test('abwickeln (Shop abgeschaltet): Aktionen, Erstatten und „erledigt“ bleiben, obwohl nurLesen gilt', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx, { nurLesen: true, abwickeln: true })
assert.ok(aktion(fx, panel, 'versendet') || aktion(fx, panel, 'abgeschlossen') || fx.finde(panel, 'form[data-form="erstatten"]'))
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.ok(aktion(fx, panel, 'erledigt'))
}))
test('Antwort nach dem Wechsel des Reiters zeichnet nicht in das fremde Panel', mit(server(), async (fx) => {
const ctx = baueCtx()
const panel = h('div')
fx.document.body.append(panel)
const fertig = render(panel, ctx)
panel.replaceChildren(h('p', null, 'anderer Reiter'))
await fertig
await fx.ruhe()
assert.equal(fx.text(panel), 'anderer Reiter')
}))
/* ---------------------------------------------------------------- Quelle --- */
test('Quelltext: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, confirm()', () => {
const quelle = readFileSync(new URL('../public/shop-tab-bestellungen.js', import.meta.url), 'utf8')
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'confirm(']) assert.ok(!quelle.includes(verboten), verboten)
})