Files

589 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, 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\./)
},
))
// Wartet, bis ein dynamisch geladenes Reiter-Modul sein Panel gezeichnet hat (Node 20 laedt import() langsamer als Node 24)
async function panelText(fx, root, sel) {
for (let i = 0; i < 100; i += 1) {
const t = fx.text(fx.finde(root, sel))
if (t !== '') return t
await fx.ruhe()
await new Promise((r) => setTimeout(r, 5))
}
return fx.text(fx.finde(root, sel))
}
/* ------------------------------------------------------------------ Reiter --- */
test('Zustand active: fünf Reiter in fester Reihenfolge, Übersicht offen; 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.notEqual(await panelText(fx, root, '[role="tabpanel"]'), '', 'die Übersicht zeichnet ihr Panel')
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', 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.notEqual(await panelText(fx, root, '#sh-panel'), '', `${key} zeichnet sein Panel (Platzhalter oder echter Inhalt)`)
}
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, bearbeiten, erstatten und exportieren.')
assert.equal(gesehen[0].nurLesen, true)
assert.equal(gesehen[0].abwickeln, true, 'Bestellungen und Widerrufe lassen sich auch bei abgeschaltetem Shop abwickeln (M2)')
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'/)
})