515 lines
28 KiB
JavaScript
515 lines
28 KiB
JavaScript
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' },
|
||
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 } = {}) {
|
||
const ctx = {
|
||
projectId: 'p1', status: { state: 'active' }, nurLesen, 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')
|
||
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', telefon: '' }, 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('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)
|
||
})
|