Files
Webklar-Kundenbereich/test/shopView.test.js
T
ClaudeandClaude Opus 5.5 738afa2d0e Shop G2: Hülle #webshop mit Anfrage, Bestellseite, Statuskarte und fünf Reitern
Eigene Helfer in shop-dom.js (inkl. euroEingabe, datumText), Anfrageformular und -karten
in shop-anfrage.js, Reiter als lazy geladene Platzhalter-Module für G3 bis G6.
Die Bestellseite zeigt den Hinweis zu „Ändern“ nur noch, wenn es Schritte gibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:05:01 +00:00

577 lines
30 KiB
JavaScript
Raw 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, readdirSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { centZuEuro, datumText, euroEingabe, euroText, euroZuCent, h } from '../public/shop-dom.js'
import { REITER, bestellungVon, initShop, vergissAuswahl } from '../public/shop.js'
// Task G2: die Hülle #webshop (public/shop.js, shop-anfrage.js, shop-dom.js) mit Fake-DOM und Fake-fetch: jeder Zustand zeigt das Richtige,
// die Anfrage schickt den richtigen Körper, die Bestellseite geht an /api/funktionen/shop/checkout, die Reiter laden ihre Module, die Rückkehr
// wird gelesen und aus der Adresse genommen. Die Server-Antworten sind von Hand gebaut (Form: server/routes/shop.js, funktionen.js).
const URL_ZAHLUNG = 'https://checkout.stripe.test/c/pay/cs_test_shop'
const CONSENT = {
version: 'v-shop',
knopf: 'Zahlungspflichtig bestellen · 299 €',
items: [{ id: 'unterlagen', text: 'Ich habe die Unterlagen gelesen.', haken: true }, { id: 'info', text: 'Hinweis ohne Haken.', haken: false }],
}
const FEATURE = {
key: 'shop',
name: 'Webshop',
priceLabel: '299 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consent: CONSENT,
leistungsbeschreibung: { version: '2026-10-05', url: '/rechtliches/webshop-leistungsbeschreibung-2026-10-05.pdf' },
avv: { version: '2026-10-05', url: '/rechtliches/webshop-avv-2026-10-05.pdf' },
widerruf: { version: '2026-10-05', url: '' },
}
const UST = 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG'
const ANFRAGE = { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' }
const projekt = (id, name, state = 'off', extra = {}) => ({ projectId: id, projectName: name, features: { shop: { state, ...extra } } })
const funktionen = (projects, { verfuegbar = true } = {}) => ({
projects,
features: { shop: FEATURE },
ustHinweis: UST,
legal: { shop: { version: '2026-10-05', links: {}, belehrung: 'Widerrufsbelehrung samt Muster-Widerrufsformular.' } },
shop: { verfuegbar },
})
const status = (state, extra = {}) => ({
verfuegbar: true, state, ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null, ...extra,
})
const anfrageAnsicht = (stand, extra = {}) => ({ status: stand, anfrage: ANFRAGE, at: '2026-10-05T10:30:00.000Z', entschiedenAt: null, begruendung: '', ...extra })
/**
* Fake-Server: GET /api/funktionen und GET /api/shop/status?projectId= antworten aus dem veränderlichen Zustand `welt`; alle anderen
* Aufrufe nimmt `sonst(aufruf, welt)` entgegen (Standard 200 {}). Jeder Aufruf steht in fx.fetchLog.
*/
function starteMitWelt(welt, sonst = () => ({})) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url === '/api/funktionen') return { body: welt.funktionen }
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/status?projectId=')) {
const id = decodeURIComponent(aufruf.url.split('=')[1])
return { body: welt.status[id] ?? status('off') }
}
return sonst(aufruf, welt) || { body: {} }
}
return installFakeDom({ antworten: Array.from({ length: 60 }, () => route) })
}
const mitDom = (welt, sonst, fn) => async () => {
const fx = starteMitWelt(welt, sonst)
try {
await fn(fx)
} finally {
fx.restore()
}
}
async function oeffne(fx, { hash = '', wunsch = '', projectId, behalten = false } = {}) {
if (!behalten) vergissAuswahl()
fx.window.location.hash = hash
const root = h('div', { id: 'webshop-body' })
if (wunsch) root.dataset.wunschProjekt = wunsch
fx.document.body.append(root)
await initShop(root, projectId ? { projectId } : {})
await fx.ruhe()
return root
}
const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const welt1 = (zustand, stand, extra) => ({ funktionen: funktionen([projekt('p1', 'Mein Laden', zustand, extra)]), status: { p1: stand } })
/* ------------------------------------------------------------------ Hilfen --- */
test('euroZuCent und centZuEuro: deutsche Schreibweise, nur Anzeige', () => {
assert.equal(euroZuCent('12,50'), 1250)
assert.equal(euroZuCent('12,5'), 1250)
assert.equal(euroZuCent('12.50 €'), 1250)
assert.equal(euroZuCent('1.234,50'), 123450)
assert.equal(euroZuCent('7'), 700)
for (const schlecht of ['', 'abc', '12,345', '1,2,3', '-5', null, undefined]) assert.equal(euroZuCent(schlecht), null, String(schlecht))
assert.equal(centZuEuro(1250), '12,50')
assert.equal(centZuEuro(5), '0,05')
assert.equal(centZuEuro(-1), '')
assert.equal(euroText(123450), '1.234,50 €')
assert.equal(euroText(null), '')
})
test('euroEingabe: Eingabe wird beim Verlassen in die Schreibweise 12,50 gebracht, inCent liefert Cent oder null', async () => {
const fx = installFakeDom()
try {
const feld = euroEingabe({ cent: 990 })
assert.equal(feld.value, '9,90')
assert.equal(feld.type, 'text', 'Text statt number: Komma bleibt erlaubt')
await fx.setze(feld, '12,5')
assert.equal(feld.value, '12,50')
assert.equal(feld.inCent(), 1250)
await fx.setze(feld, 'zwölf')
assert.equal(feld.value, 'zwölf', 'Unlesbares bleibt stehen, der Server meldet den Fehler')
assert.equal(feld.inCent(), null)
assert.equal(feld.istLesbar(), false)
await fx.setze(feld, '')
assert.equal(feld.istLeer(), true)
assert.equal(feld.istLesbar(), true)
assert.equal(feld.inCent(), null)
} finally {
fx.restore()
}
})
test('datumText: Berliner Zeit, Datum allein, ungültig leer', () => {
assert.match(datumText('2026-10-05T12:30:00.000Z'), /^05\.10\.2026,? 14:30$/)
assert.equal(datumText('2026-10-05T12:30:00.000Z', { zeit: false }), '05.10.2026')
assert.equal(datumText(''), '')
assert.equal(datumText('kein Datum'), '')
assert.equal(datumText(null), '')
})
/* ---------------------------------------------------------------- Zustände --- */
test('nicht verfügbar: Infokarte, keine Abfrage des Stands', mitDom({ funktionen: funktionen([projekt('p1', 'A')], { verfuegbar: false }), status: {} }, undefined, async (fx) => {
const root = await oeffne(fx)
assert.match(fx.text(root), /Der Webshop ist noch nicht verfügbar\./)
assert.deepEqual(fx.fetchLog.map((a) => a.url), ['/api/funktionen'])
}))
test('keine Projekte: leere Karte', mitDom({ funktionen: funktionen([]), status: {} }, undefined, async (fx) => {
const root = await oeffne(fx)
assert.match(fx.text(root), /Noch keine Website-Projekte zugewiesen\./)
assert.equal(fx.finde(root, 'select'), null)
}))
test('Fehler beim Laden: Fehlerkarte mit „Noch einmal versuchen“', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Interner Serverfehler' } }, { body: funktionen([]) }] })
try {
const root = h('div')
fx.document.body.append(root)
await initShop(root)
assert.match(fx.text(root), /Interner Serverfehler/)
await fx.klick(fx.finde(root, 'button'))
assert.match(fx.text(root), /Noch keine Website-Projekte|Der Webshop ist noch nicht verfügbar/)
} finally {
fx.restore()
}
})
test('keine Anfrage: Erklärung, Formular mit den Pflichthaken, Knopf „Anfrage senden“', mitDom(welt1('off', status('off')), undefined, async (fx) => {
const root = await oeffne(fx)
const text = fx.text(root)
assert.match(text, /299 €/)
assert.match(text, /eigenes Stripe-Konto/)
assert.match(text, /Karte, Apple Pay, Google Pay, Klarna oder SEPA/)
assert.match(text, /PayPal ist über Stripe leider nicht möglich/)
assert.match(text, /Ich verkaufe als Unternehmer \(nicht privat\)\./)
assert.match(text, /Ich verkaufe keine ausgeschlossenen Waren \(Alkohol, Tabak\/E-Zigaretten, Arzneimittel, Waffen\/Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine\)\./)
assert.equal(fx.text(fx.finde(root, '[data-aktion="anfrage-senden"]')), 'Anfrage senden')
for (const name of ['warenart', 'beschreibung', 'artikelzahl', 'versand', 'abholung', 'unternehmer', 'keineAusgeschlossenen', 'nachricht']) {
assert.ok(fx.finde(root, `[data-feld="${name}"]`), `Feld ${name}`)
}
assert.equal(fx.finde(root, '[data-feld="unternehmer"]').checked, false, 'Pflichthaken sind nie vorausgewählt')
assert.deepEqual(fx.fetchLog.map((a) => a.url), ['/api/funktionen', '/api/shop/status?projectId=p1'])
}))
test('Anfrage senden: Körper laut Schnittstelle, danach zeigt die Ansicht „wird geprüft“', mitDom(
welt1('off', status('off')),
(aufruf, welt) => {
if (aufruf.method === 'POST' && aufruf.url === '/api/shop/anfrage') {
welt.status.p1 = status('off', { anfrage: anfrageAnsicht('offen') })
return { body: { anfrage: anfrageAnsicht('offen') } }
}
return null
},
async (fx) => {
const root = await oeffne(fx)
await fx.setze(fx.finde(root, '[data-feld="warenart"]'), 'kleidung')
await fx.setze(fx.finde(root, '[data-feld="beschreibung"]'), ' Handgemachte Mützen und Schals. ')
await fx.setze(fx.finde(root, '[data-feld="artikelzahl"]'), 'bis20')
await fx.klick(fx.finde(root, '[data-feld="versand"]'))
await fx.klick(fx.finde(root, '[data-feld="unternehmer"]'))
await fx.klick(fx.finde(root, '[data-feld="keineAusgeschlossenen"]'))
await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/shop/anfrage',
body: { projectId: 'p1', anfrage: { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' } },
}])
assert.match(fx.text(root), /Deine Anfrage wird geprüft/)
assert.ok(fx.finde(root, '[data-aktion="zurueckziehen"]'))
},
))
test('Anfrage mit Feldfehlern vom Server: Fehler stehen am Feld, nichts wird neu gezeichnet, der Knopf ist wieder frei', mitDom(
welt1('off', status('off')),
(aufruf) => (aufruf.method === 'POST'
? { status: 400, body: { error: 'Bitte wähle, was du verkaufen möchtest.', fehler: { warenart: 'Bitte wähle, was du verkaufen möchtest.', unternehmer: 'Bitte bestätige, dass du als Unternehmer verkaufst.', _: 'Sonst etwas.' } } }
: null),
async (fx) => {
const root = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.equal(fx.text(fx.finde(root, '[data-fehler="warenart"]')), 'Bitte wähle, was du verkaufen möchtest.')
assert.equal(fx.text(fx.finde(root, '[data-fehler="unternehmer"]')), 'Bitte bestätige, dass du als Unternehmer verkaufst.')
assert.equal(fx.text(fx.finde(root, '[data-fehler="_"]')), 'Sonst etwas.')
assert.equal(fx.finde(root, '[data-feld="warenart"]').getAttribute('aria-invalid'), 'true')
assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]').disabled, false)
assert.equal(fx.fetchLog.filter((a) => a.url.startsWith('/api/shop/status')).length, 1, 'kein Neuladen nach einem 400')
},
))
test('Anfrage offen: Karte mit Zusammenfassung; „Anfrage zurückziehen“ schickt POST und zeigt wieder das Formular, vorausgefüllt', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('offen') })),
(aufruf, welt) => {
if (aufruf.url === '/api/shop/anfrage/zurueckziehen') {
welt.status.p1 = status('off', { anfrage: anfrageAnsicht('zurueckgezogen') })
return { body: { anfrage: anfrageAnsicht('zurueckgezogen') } }
}
return null
},
async (fx) => {
const root = await oeffne(fx)
const text = fx.text(root)
assert.match(text, /Deine Anfrage wird geprüft/)
assert.match(text, /Kleidung/)
assert.match(text, /Handgemachte Mützen und Schals\./)
assert.match(text, /bis 20/)
assert.match(text, /Versand/)
assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]'), null)
await fx.klick(fx.finde(root, '[data-aktion="zurueckziehen"]'))
await fx.ruhe()
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/shop/anfrage/zurueckziehen', body: { projectId: 'p1' } }])
assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'), 'zurückgezogen: wieder das Formular')
assert.equal(fx.finde(root, '[data-feld="beschreibung"]').value, 'Handgemachte Mützen und Schals.')
assert.equal(fx.finde(root, '[data-feld="warenart"]').value, 'kleidung')
assert.equal(fx.finde(root, '[data-feld="versand"]').checked, true)
},
))
test('Zurückziehen scheitert mit 409 (inzwischen entschieden): Meldung, der neue Stand erscheint', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('offen') })),
(aufruf, welt) => {
if (aufruf.url === '/api/shop/anfrage/zurueckziehen') {
welt.status.p1 = status('off', { anfrage: anfrageAnsicht('abgelehnt', { begruendung: 'Passt nicht zu unserem Angebot.' }) })
return { status: 409, body: { error: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.' } }
}
return null
},
async (fx) => {
const root = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-aktion="zurueckziehen"]'))
await fx.ruhe()
assert.match(fx.text(root), /Passt nicht zu unserem Angebot\./)
},
))
test('Anfrage abgelehnt: Begründung als Text; „Neue Anfrage stellen“ zeigt das Formular mit den alten Angaben', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('abgelehnt', { begruendung: 'Wir verkaufen <b>keine</b> Mützen.' }) })),
undefined,
async (fx) => {
const root = await oeffne(fx)
assert.match(fx.text(root), /Wir verkaufen <b>keine<\/b> Mützen\./, 'Fremdtext bleibt Text')
assert.equal(fx.finde(root, 'blockquote b'), null, 'kein Element aus dem Text')
assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]'), null)
await fx.klick(fx.finde(root, '[data-aktion="neue-anfrage"]'))
assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'))
assert.equal(fx.finde(root, '[data-feld="beschreibung"]').value, 'Handgemachte Mützen und Schals.')
assert.equal(fx.finde(root, '[data-feld="unternehmer"]').checked, false, 'die Haken setzt Du neu')
},
))
test('Anfrage angenommen, Funktion aus: Bestellseite mit Preis, Unterlagen, Belehrung und Zusammenfassung; Bestellen geht an /api/funktionen/shop/checkout', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('angenommen') })),
(aufruf) => (aufruf.url === '/api/funktionen/shop/checkout' ? { body: { url: URL_ZAHLUNG } } : null),
async (fx) => {
const root = await oeffne(fx)
const text = fx.text(root)
assert.match(text, /Prüfen und bestellen/)
assert.match(text, /299 €/)
assert.match(text, new RegExp(UST))
assert.match(text, /Widerrufsbelehrung samt Muster-Widerrufsformular\./)
assert.match(text, /Deine Anfrage/)
assert.match(text, /Mützen und Schals/)
assert.equal(fx.finde(root, '[data-aktion="aendern"]'), null, 'ohne Schritte kein „Ändern“')
assert.doesNotMatch(text, /Mit „Ändern“/)
const links = fx.alle(root, 'a').map((a) => a.getAttribute('href'))
assert.deepEqual(links, ['/rechtliches/webshop-leistungsbeschreibung-2026-10-05.pdf', '/rechtliches/webshop-avv-2026-10-05.pdf'], 'nur Unterlagen mit Adresse')
const knopf = fx.finde(root, '[data-aktion="bestellen"]')
assert.equal(fx.text(knopf), 'Zahlungspflichtig bestellen · 299 €')
assert.equal(knopf.disabled, true, 'ohne Haken gesperrt')
await fx.klick(fx.finde(root, '[data-haken="unterlagen"]'))
await fx.klick(knopf)
await fx.ruhe()
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', zustimmung: { version: 'v-shop', haken: ['unterlagen'] } } }])
assert.equal(fx.window.location.href, URL_ZAHLUNG)
},
))
test('bestellungVon: ohne Register-Eintrag null, sonst Preis, Hinweis, Einrichtung, Zustimmungen, Unterlagen mit Adresse, Belehrung', () => {
assert.equal(bestellungVon({}), null)
assert.equal(bestellungVon({ features: { shop: {} } }), null)
const b = bestellungVon(funktionen([]))
assert.equal(b.preis, '299 €')
assert.equal(b.preisHinweis, UST)
assert.equal(b.einrichtung, 'meist innerhalb von 1–2 Wochen')
assert.equal(b.consent, CONSENT)
assert.deepEqual(b.dokumente.map((d) => d.id), ['leistungsbeschreibung', 'avv'])
assert.equal(b.belehrung, 'Widerrufsbelehrung samt Muster-Widerrufsformular.')
assert.equal(bestellungVon({ ...funktionen([]), legal: { shop: null } }).belehrung, null)
})
test('Zustand ordered: Statuskarte „Zahlung offen“; „Zahlung fortsetzen“ schickt fortsetzen: true; nach der Zahlung (bestellt=1) steht „Danke!“ da und die Adresse ist bereinigt', mitDom(
welt1('ordered', status('ordered', { anfrage: anfrageAnsicht('angenommen') }), { orderedAt: '2026-10-05T09:00:00.000Z' }),
(aufruf) => (aufruf.url === '/api/funktionen/shop/checkout' ? { body: { url: URL_ZAHLUNG } } : null),
async (fx) => {
const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p1' })
assert.equal(fx.window.location.hash, '#webshop', 'Rückkehr einmal gelesen und aus der Adresse genommen')
const text = fx.text(root)
assert.match(text, /Deine Bestellung wartet auf die Zahlung/)
assert.match(text, /Danke!/)
assert.match(text, /05\.10\.2026/)
await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]'))
await fx.ruhe()
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', fortsetzen: true } }])
assert.equal(fx.window.location.href, URL_ZAHLUNG)
},
))
test('ordered ohne Rückkehr: kein „Danke!“; scheitert „Zahlung fortsetzen“ mit zustimmung_fehlt, erscheint die Bestellseite mit der Meldung', mitDom(
welt1('ordered', status('ordered', { anfrage: anfrageAnsicht('angenommen') })),
(aufruf) => (aufruf.url === '/api/funktionen/shop/checkout'
? { status: 409, body: { error: 'Bitte bestätige die Unterlagen noch einmal.', code: 'zustimmung_fehlt' } }
: null),
async (fx) => {
const root = await oeffne(fx)
assert.doesNotMatch(fx.text(root), /Danke!/)
await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]'))
await fx.ruhe()
assert.ok(fx.finde(root, '[data-aktion="bestellen"]'), 'Bestellseite statt Karte')
assert.match(fx.text(root), /Bitte bestätige die Unterlagen noch einmal\./)
},
))
/* ------------------------------------------------------------------ Reiter --- */
test('Zustand active: fünf Reiter in fester Reihenfolge, Übersicht offen, Modul ist ein Platzhalter; kein Hinweis-Banner', mitDom(welt1('active', status('active')), undefined, async (fx) => {
const root = await oeffne(fx)
const knoepfe = fx.alle(root, '[role="tab"]')
assert.deepEqual(knoepfe.map((k) => fx.text(k)), ['Übersicht', 'Artikel', 'Bestellungen', 'Einstellungen', 'Rechtliches'])
assert.deepEqual(knoepfe.map((k) => k.dataset.reiter), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches'])
assert.deepEqual(knoepfe.map((k) => k.getAttribute('aria-selected')), ['true', 'false', 'false', 'false', 'false'])
assert.equal(fx.finde(root, '[role="tablist"]').getAttribute('aria-label'), 'Bereiche des Webshops')
assert.match(fx.text(fx.finde(root, '[role="tabpanel"]')), /Kommt gleich …/)
assert.equal(fx.finde(root, '[data-hinweis]'), null)
assert.deepEqual(REITER.map((r) => r.key), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches'])
}))
test('Reiterwechsel per Klick und Pfeiltasten lädt das Modul des Reiters mit ctx { projectId, status, neuLaden, zeigeMeldung, request }', mitDom(welt1('active', status('active', { siteKey: 'sk1' })), undefined, async (fx) => {
const gesehen = []
const alt = REITER.map((r) => r.laden)
REITER.forEach((r, i) => { r.laden = async () => ({ render: (panel, ctx) => { gesehen.push({ key: r.key, ctx }); panel.replaceChildren(h('p', null, `Inhalt ${r.key}`)) } }) })
try {
const root = await oeffne(fx)
assert.deepEqual(gesehen.map((g) => g.key), ['uebersicht'])
const ctx = gesehen[0].ctx
assert.equal(ctx.projectId, 'p1')
assert.equal(ctx.status.siteKey, 'sk1')
assert.equal(ctx.nurLesen, false)
assert.equal(typeof ctx.neuLaden, 'function')
assert.equal(typeof ctx.zeigeMeldung, 'function')
assert.equal(typeof ctx.request, 'function')
await fx.klick(fx.finde(root, '#sh-tab-artikel'))
await fx.ruhe()
assert.deepEqual(gesehen.map((g) => g.key), ['uebersicht', 'artikel'])
assert.match(fx.text(fx.finde(root, '[role="tabpanel"]')), /Inhalt artikel/)
assert.equal(fx.finde(root, '#sh-tab-artikel').getAttribute('aria-selected'), 'true')
assert.equal(fx.finde(root, '#sh-tab-uebersicht').tabIndex, -1)
assert.equal(fx.finde(root, '#sh-panel').getAttribute('aria-labelledby'), 'sh-tab-artikel')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowRight' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'bestellungen')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'End' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'rechtliches')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowRight' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'uebersicht', 'am Ende springt es nach vorn')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'Home' })
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowLeft' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'rechtliches', 'vor dem ersten Reiter springt es ans Ende')
} finally {
REITER.forEach((r, i) => { r.laden = alt[i] })
}
}))
test('Die echten Reiter-Module werden beim Wählen geladen (Platzhalter „Kommt gleich …“)', mitDom(welt1('active', status('active')), undefined, async (fx) => {
const root = await oeffne(fx)
for (const key of ['artikel', 'bestellungen', 'einstellungen', 'rechtliches', 'uebersicht']) {
await fx.klick(fx.finde(root, `#sh-tab-${key}`))
await fx.ruhe()
assert.match(fx.text(fx.finde(root, '#sh-panel')), /Kommt gleich …/, key)
}
for (const r of REITER) assert.equal(typeof (await r.laden()).render, 'function', `${r.key} exportiert render`)
}))
test('Zustand setup: Hinweis „Wir bauen Deinen Shop gerade ein …“ über den Reitern, Reiter nicht schreibgeschützt', mitDom(welt1('setup', status('setup')), undefined, async (fx) => {
const gesehen = []
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, ctx) => { gesehen.push(ctx) } })
try {
const root = await oeffne(fx)
assert.equal(fx.text(fx.finde(root, '[data-hinweis="setup"]')), 'Wir bauen Deinen Shop gerade ein. Du kannst schon Artikel, Stripe und Rechtstexte vorbereiten.')
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
assert.equal(gesehen[0].nurLesen, false)
} finally {
REITER[0].laden = alt
}
}))
test('Zustand disabled: Banner der Abschaltung, ctx.nurLesen = true', mitDom(welt1('disabled', status('disabled')), undefined, async (fx) => {
const gesehen = []
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, ctx) => { gesehen.push(ctx) } })
try {
const root = await oeffne(fx)
assert.equal(fx.text(fx.finde(root, '[data-hinweis="disabled"]')), 'Dein Webshop ist abgeschaltet. Du kannst Bestellungen noch ansehen und exportieren.')
assert.equal(gesehen[0].nurLesen, true)
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
} finally {
REITER[0].laden = alt
}
}))
test('Stripe-Rückkehr (#webshop?stripe=zurueck): Adresse bereinigt, Übersicht gewählt, ctx.stripeRueckkehr gesetzt; ungültiger Wert ignoriert', mitDom(welt1('active', status('active')), undefined, async (fx) => {
const gesehen = []
const alt = REITER.map((r) => r.laden)
REITER.forEach((r) => { r.laden = async () => ({ render: (panel, ctx) => { gesehen.push({ key: r.key, rueck: ctx.stripeRueckkehr }) } }) })
try {
await oeffne(fx, { hash: '#webshop?stripe=neu&projekt=p1' })
assert.equal(fx.window.location.hash, '#webshop')
assert.deepEqual(gesehen, [{ key: 'uebersicht', rueck: 'neu' }])
gesehen.length = 0
await oeffne(fx, { hash: '#webshop?stripe=%3Cscript%3E' })
assert.deepEqual(gesehen, [{ key: 'uebersicht', rueck: '' }])
} finally {
REITER.forEach((r, i) => { r.laden = alt[i] })
}
}))
test('Danke nach der Zahlung, wenn der Webhook schon durch ist (setup): Meldung „Danke!“ in der Live-Region', mitDom(welt1('setup', status('setup')), undefined, async (fx) => {
const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p1' })
assert.equal(fx.window.location.hash, '#webshop')
assert.match(fx.text(fx.finde(root, '.sh-meldung')), /Danke! Die Zahlung ist bei uns angekommen\./)
}))
test('ctx.neuLaden: holt den Stand und hält ctx.status aktuell; bei neuem Zustand zeichnet die Hülle die Ansicht neu', mitDom(welt1('setup', status('setup')), undefined, async (fx) => {
let ctx = null
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, c) => { ctx = c } })
try {
const root = await oeffne(fx)
assert.ok(fx.finde(root, '[data-hinweis="setup"]'))
// gleicher Zustand: nur ctx.status ist neu, die Ansicht bleibt
const gleich = await ctx.neuLaden()
assert.equal(gleich.state, 'setup')
assert.equal(ctx.status, gleich)
assert.ok(fx.finde(root, '[data-hinweis="setup"]'))
// anderer Zustand: neu gezeichnet, ohne Banner
fx.antworten.length = 0
fx.antworten.push(() => ({ body: status('active', { siteKey: 'neu' }) }))
const aktiv = await ctx.neuLaden()
await fx.ruhe()
assert.equal(aktiv.state, 'active')
assert.equal(fx.finde(root, '[data-hinweis="setup"]'), null)
} finally {
REITER[0].laden = alt
}
}))
test('ctx.zeigeMeldung schreibt in die Live-Region (ok: status, fehler: alert)', mitDom(welt1('active', status('active')), undefined, async (fx) => {
let ctx = null
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, c) => { ctx = c } })
try {
const root = await oeffne(fx)
const live = fx.finde(root, '.sh-meldung')
ctx.zeigeMeldung('Gespeichert.', 'ok')
assert.equal(fx.text(live), 'Gespeichert.')
assert.equal(live.getAttribute('role'), 'status')
ctx.zeigeMeldung('Das ging schief.', 'fehler')
assert.equal(live.getAttribute('role'), 'alert')
assert.equal(live.getAttribute('aria-live'), 'assertive')
ctx.zeigeMeldung('')
assert.equal(fx.text(live), '')
} finally {
REITER[0].laden = alt
}
}))
/* ------------------------------------------------------------ Projektwahl --- */
test('Projektwahl: bei mehr als einem Projekt ein Auswahlfeld; das Wunschprojekt aus dem Behälter gilt, Wechsel lädt den Stand des anderen', mitDom({
funktionen: funktionen([projekt('p1', 'Laden Eins', 'active'), projekt('p2', 'Laden Zwei', 'off')]),
status: { p1: status('active'), p2: status('off', { anfrage: anfrageAnsicht('offen') }) },
}, undefined, async (fx) => {
const root = await oeffne(fx, { wunsch: 'p2' })
assert.equal(root.dataset.wunschProjekt, undefined, 'der Wunsch ist verbraucht')
const select = fx.finde(root, '#shop-project')
assert.equal(select.value, 'p2')
assert.match(fx.text(root), /Deine Anfrage wird geprüft/)
await fx.setze(select, 'p1')
await fx.ruhe()
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
assert.ok(fx.fetchLog.some((a) => a.url === '/api/shop/status?projectId=p1'))
}))
test('Ohne Wunsch wird das aktive Projekt gewählt; ein einziges Projekt hat kein Auswahlfeld', mitDom({
funktionen: funktionen([projekt('p1', 'Eins', 'off'), projekt('p2', 'Zwei', 'active')]),
status: { p1: status('off'), p2: status('active') },
}, undefined, async (fx) => {
const root = await oeffne(fx)
assert.equal(fx.finde(root, '#shop-project').value, 'p2')
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
}))
test('Rückkehr mit &projekt= wählt dieses Projekt', mitDom({
funktionen: funktionen([projekt('p1', 'Eins', 'active'), projekt('p2', 'Zwei', 'ordered')]),
status: { p1: status('active'), p2: status('ordered', { anfrage: anfrageAnsicht('angenommen') }) },
}, undefined, async (fx) => {
const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p2' })
assert.equal(fx.finde(root, '#shop-project').value, 'p2')
assert.match(fx.text(root), /Danke!/)
}))
test('Ein Projekt: kein Auswahlfeld', mitDom(welt1('off', status('off')), undefined, async (fx) => {
const root = await oeffne(fx)
assert.equal(fx.finde(root, 'select#shop-project'), null)
}))
/* ---------------------------------------------------------------- statisch --- */
const SHOP_DATEIEN = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^shop.*\.js$/.test(f))
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
test('statisch: shop*.js hat kein innerHTML, outerHTML, insertAdjacentHTML, document.write und keine Importe aus termine*.js', () => {
assert.ok(SHOP_DATEIEN.includes('shop.js') && SHOP_DATEIEN.includes('shop-dom.js') && SHOP_DATEIEN.includes('shop-anfrage.js'))
assert.equal(SHOP_DATEIEN.filter((f) => /^shop-tab-/.test(f)).length, 5)
for (const datei of SHOP_DATEIEN) {
const quelle = readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8')
assert.doesNotMatch(ohneKommentare(quelle), /innerHTML|outerHTML|insertAdjacentHTML|document\.write/, datei)
assert.doesNotMatch(ohneKommentare(quelle), /from '\.\/termine[^']*'/, `${datei}: eigene Helfer statt termine-dom.js`)
}
})
test('statisch: Du-Form in den Texten des Portals (kein „Sie“, kein „Ihre“ in shop*.js)', () => {
for (const datei of SHOP_DATEIEN) {
const quelle = ohneKommentare(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'))
assert.doesNotMatch(quelle, /\bSie\b/, datei)
assert.doesNotMatch(quelle, /\bIh(r|re|ren|rem|rer|res|nen)\b/, datei)
}
})
test('statisch: Die Hülle nutzt für den Stand GET /api/shop/status, für die Anfrage die zwei Routen und für die Bestellung den Schlüssel shop', () => {
const huelle = readFileSync(new URL('../public/shop.js', import.meta.url), 'utf8')
const anfrage = readFileSync(new URL('../public/shop-anfrage.js', import.meta.url), 'utf8')
assert.match(huelle, /\/api\/shop\/status\?projectId=/)
assert.match(huelle, /key: 'shop'/)
assert.match(anfrage, /'\/api\/shop\/anfrage'/)
assert.match(anfrage, /'\/api\/shop\/anfrage\/zurueckziehen'/)
})