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>
577 lines
30 KiB
JavaScript
577 lines
30 KiB
JavaScript
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'/)
|
||
})
|