Shop G6: Reiter Einstellungen und Rechtliches (Felder, Versand, Zahlungsarten, Muster einsetzen, Entwurf, Bestätigung mit Hash)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:12:46 +00:00
1 parent 3ed96a5838
commit 648d0e07fe
5 files changed
+1148 -10

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { ANBIETER_HINWEIS, PAYPAL_HINWEIS, SEPA_WARNUNG, STRIPE_HINWEIS, render } from '../public/shop-tab-einstellungen.js'
// Task G6: Reiter „Einstellungen“ mit Fake-DOM und Fake-fetch. Die Antworten des Servers sind von Hand gebaut
// (Form: server/routes/shop.js und server/services/shop/einstellungen.js).
const STANDARD = {
shopname: '',
anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' },
kleinunternehmer: true,
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
abholung: { aktiv: false, adresse: '', hinweis: '' },
lieferzeit: '2–4 Werktage',
zahlungsarten: ['card'],
akzent: '#111111',
mindestbestellCent: null,
ruecksendekosten: 'kaeufer',
}
const GEFUELLT = {
shopname: 'Mützenlädchen',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'erika@example.com', telefon: '0631 123456', ustId: 'DE123456789' },
kleinunternehmer: false,
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
abholung: { aktiv: true, adresse: 'Hinterhof 2\n67655 Kaiserslautern', hinweis: 'Mo bis Fr 10 bis 18 Uhr' },
lieferzeit: '1–2 Werktage',
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
akzent: '#ff8800',
mindestbestellCent: 1500,
ruecksendekosten: 'haendler',
}
const ANTWORT = (einstellungen, version = 3) => ({ einstellungen, version, vollstaendig: true })
/** Fake-Server: GET /api/shop/einstellungen antwortet aus `welt.get()`, PUT aus `welt.put(aufruf)` (Standard: Echo mit Version + 1). */
function startFx(welt) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/einstellungen?projectId=')) return { body: typeof welt.get === 'function' ? welt.get() : welt.get }
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/einstellungen') {
if (welt.put) return welt.put(aufruf)
return { body: ANTWORT(aufruf.body.einstellungen, aufruf.body.version + 1) }
}
return { status: 404, body: { error: 'unbekannt' } }
}
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
}
const mitDom = (welt, fn) => async () => {
const fx = startFx(welt)
try {
await fn(fx)
} finally {
fx.restore()
}
}
function ctxVon(extra = {}) {
const log = { meldungen: [], neuGeladen: 0 }
const ctx = {
projectId: 'p1',
nurLesen: false,
request,
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
neuLaden: async () => { log.neuGeladen += 1 },
...extra,
}
return { ctx, log }
}
async function oeffne(fx, ctxExtra) {
const root = h('div', { id: 'panel' })
fx.document.body.append(root)
const { ctx, log } = ctxVon(ctxExtra)
await render(root, ctx)
await fx.ruhe()
return { root, ctx, log }
}
const feldVon = (root, name) => root.querySelector(`[data-feld="${name}"]`)
const fehlerVon = (root, name) => root.querySelector(`[data-fehler="${name}"]`)
const speichernKnopf = (root) => root.querySelector('[data-aktion="einstellungen-speichern"]')
const puts = (fx) => fx.fetchLog.filter((a) => a.method === 'PUT')
/* --------------------------------------------------------------- Anzeige --- */
test('Standardwerte: Felder gefüllt, Länder nur DE, Karte fest, Hinweise zu Klarna, Lastschrift und PayPal', mitDom({ get: ANTWORT(STANDARD, 0) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(fx.fetchLog[0].url, '/api/shop/einstellungen?projectId=p1')
for (const titel of ['Shop', 'Anbieter', 'Steuer', 'Versand', 'Abholung', 'Zahlungsarten']) {
assert.ok(fx.finde(root, (el) => el.tagName === 'H3' && el.textContent === titel), titel)
}
assert.equal(feldVon(root, 'shopname').value, '')
assert.equal(feldVon(root, 'akzent').type, 'color')
assert.equal(feldVon(root, 'akzent').value, '#111111')
assert.equal(feldVon(root, 'anbieter.land').value, 'DE')
assert.equal(feldVon(root, 'kleinunternehmer').checked, true)
assert.equal(feldVon(root, 'versand.aktiv').checked, true)
assert.equal(feldVon(root, 'versand.laender').value, 'DE')
assert.equal(feldVon(root, 'versand.kostenCent.DE').value, '4,90')
assert.equal(feldVon(root, 'versand.kostenCent.DE').type, 'text', 'Betrag als Text, Komma bleibt erlaubt')
assert.equal(feldVon(root, 'versand.freiAbCent').value, '')
assert.equal(feldVon(root, 'lieferzeit').value, '2–4 Werktage')
assert.equal(feldVon(root, 'mindestbestellCent').value, '')
assert.equal(feldVon(root, 'ruecksendekosten').value, 'kaeufer')
assert.equal(fx.text(root).includes(ANBIETER_HINWEIS), true)
assert.equal(fx.text(root).includes(SEPA_WARNUNG), true)
assert.equal(fx.text(root).includes(STRIPE_HINWEIS), true)
assert.equal(fx.text(root).includes(PAYPAL_HINWEIS), true)
assert.match(SEPA_WARNUNG, /^Bei Lastschrift ist das Geld erst nach einigen Tagen sicher\. Versende erst, wenn die Bestellung als bezahlt markiert ist\.$/)
assert.match(fx.text(root), /Kleinunternehmer nach § 19 UStG/)
// Österreich und Abholung sind zu: ihre Felder sind versteckt
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, true)
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, true)
}))
test('Zahlungsarten: Karte immer gesetzt und gesperrt, Klarna und Lastschrift frei wählbar', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
const karte = feldVon(root, 'zahlung.card')
assert.equal(karte.checked, true)
assert.equal(karte.disabled, true)
assert.match(fx.text(root), /inklusive Apple Pay und Google Pay/)
assert.equal(feldVon(root, 'zahlung.klarna').checked, false)
assert.equal(feldVon(root, 'zahlung.klarna').disabled, false)
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, false)
await fx.klick(karte)
assert.equal(karte.checked, true, 'ein Klick ändert nichts')
}))
test('gespeicherte Werte werden angezeigt, Österreich und Abholung sind sichtbar', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen')
assert.equal(feldVon(root, 'akzent').value, '#ff8800')
assert.equal(feldVon(root, 'anbieter.plz').value, '67655')
assert.equal(feldVon(root, 'anbieter.ustId').value, 'DE123456789')
assert.equal(feldVon(root, 'kleinunternehmer').checked, false)
assert.equal(feldVon(root, 'versand.laender').value, 'DE,AT')
assert.equal(feldVon(root, 'versand.kostenCent.AT').value, '9,90')
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, false)
assert.equal(feldVon(root, 'versand.freiAbCent').value, '50,00')
assert.equal(feldVon(root, 'abholung.aktiv').checked, true)
assert.equal(feldVon(root, 'abholung.adresse').value, 'Hinterhof 2\n67655 Kaiserslautern')
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, false)
assert.equal(feldVon(root, 'mindestbestellCent').value, '15,00')
assert.equal(feldVon(root, 'ruecksendekosten').value, 'haendler')
assert.equal(feldVon(root, 'zahlung.klarna').checked, true)
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, true)
}))
test('Schalter blenden Österreich, Versand- und Abholfelder ein und aus', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
const at = feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at')
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
assert.equal(at.hidden, false)
await fx.setze(feldVon(root, 'versand.laender'), 'DE')
assert.equal(at.hidden, true)
const versandDetails = feldVon(root, 'versand.laender').closest('.sh-versand-details')
await fx.klick(feldVon(root, 'versand.aktiv'))
assert.equal(versandDetails.hidden, true)
const abhol = feldVon(root, 'abholung.adresse').closest('.sh-abhol-details')
await fx.klick(feldVon(root, 'abholung.aktiv'))
assert.equal(abhol.hidden, false)
}))
/* ------------------------------------------------------------- Speichern --- */
test('Speichern: Körper mit ganzen Cent, Version, Meldung und neuLaden; der Stand des Servers kommt zurück ins Formular', mitDom({
get: ANTWORT(STANDARD, 2),
put: (aufruf) => ({ body: ANTWORT({ ...aufruf.body.einstellungen, anbieter: { ...aufruf.body.einstellungen.anbieter, email: 'erika@example.com' } }, 3) }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(feldVon(root, 'shopname'), ' Mützenlädchen ')
await fx.setze(feldVon(root, 'akzent'), '#ff8800')
await fx.setze(feldVon(root, 'anbieter.name'), 'Erika Muster')
await fx.setze(feldVon(root, 'anbieter.strasse'), 'Hauptstraße 1')
await fx.setze(feldVon(root, 'anbieter.plz'), '67655')
await fx.setze(feldVon(root, 'anbieter.ort'), 'Kaiserslautern')
await fx.setze(feldVon(root, 'anbieter.email'), 'Erika@Example.com')
await fx.setze(feldVon(root, 'anbieter.ustId'), 'DE123456789')
await fx.klick(feldVon(root, 'kleinunternehmer'))
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '4,9')
await fx.setze(feldVon(root, 'versand.kostenCent.AT'), '9,90')
await fx.setze(feldVon(root, 'versand.freiAbCent'), '50')
await fx.klick(feldVon(root, 'abholung.aktiv'))
await fx.setze(feldVon(root, 'abholung.adresse'), 'Hinterhof 2')
await fx.setze(feldVon(root, 'abholung.hinweis'), 'Mo bis Fr')
await fx.setze(feldVon(root, 'lieferzeit'), '1–2 Werktage')
await fx.setze(feldVon(root, 'mindestbestellCent'), '15')
await fx.setze(feldVon(root, 'ruecksendekosten'), 'haendler')
await fx.klick(feldVon(root, 'zahlung.klarna'))
await fx.klick(feldVon(root, 'zahlung.sepa_debit'))
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 1)
assert.deepEqual(puts(fx)[0].body, {
projectId: 'p1',
version: 2,
einstellungen: {
shopname: 'Mützenlädchen',
akzent: '#ff8800',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'Erika@Example.com', telefon: '', ustId: 'DE123456789' },
kleinunternehmer: false,
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
abholung: { aktiv: true, adresse: 'Hinterhof 2', hinweis: 'Mo bis Fr' },
lieferzeit: '1–2 Werktage',
mindestbestellCent: 1500,
ruecksendekosten: 'haendler',
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
},
})
assert.deepEqual(log.meldungen, [['Die Einstellungen sind gespeichert.', 'ok']])
assert.equal(log.neuGeladen, 1)
assert.equal(feldVon(root, 'anbieter.email').value, 'erika@example.com', 'normalisierter Stand des Servers')
// zweites Speichern schickt die neue Version
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx)[1].body.version, 3)
}))
test('Speichern ohne Österreich schickt nur Deutschland; ohne Versand dürfen die Kosten leer sein', mitDom({ get: ANTWORT({ ...STANDARD, versand: { ...STANDARD.versand, aktiv: false }, abholung: { aktiv: true, adresse: 'Laden', hinweis: '' } }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
await fx.klick(speichernKnopf(root))
const e = puts(fx)[0].body.einstellungen
assert.deepEqual(e.versand, { aktiv: false, laender: ['DE'], kostenCent: { DE: null }, freiAbCent: null })
assert.deepEqual(e.zahlungsarten, ['card'])
assert.equal(e.mindestbestellCent, null)
}))
test('unlesbare oder fehlende Beträge werden im Browser gemeldet, es geht nichts an den Server', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), 'viel')
await fx.setze(feldVon(root, 'mindestbestellCent'), '1,234')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 0)
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /Betrag wie 4,90/)
assert.match(fehlerVon(root, 'mindestbestellCent').textContent, /Betrag wie 4,90/)
assert.equal(feldVon(root, 'versand.kostenCent.DE').getAttribute('aria-invalid'), 'true')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
await fx.setze(feldVon(root, 'mindestbestellCent'), '')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 0)
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /kostenlosen Versand trage 0,00 ein/)
assert.equal(fehlerVon(root, 'mindestbestellCent').textContent, '')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '0')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 1)
assert.equal(puts(fx)[0].body.einstellungen.versand.kostenCent.DE, 0)
}))
test('Feldfehler des Servers stehen am Feld, Unbekanntes allgemein; der Knopf ist danach wieder frei', mitDom({
get: ANTWORT(STANDARD),
put: () => ({ status: 400, body: { error: 'Die Einstellungen sind nicht gültig.', fehler: { 'anbieter.plz': 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.', 'versand.aktiv': 'Bitte biete Versand oder Abholung an.', 'dings.unbekannt': 'Etwas stimmt nicht.' } } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
assert.equal(fehlerVon(root, 'anbieter.plz').textContent, 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.')
assert.equal(feldVon(root, 'anbieter.plz').getAttribute('aria-invalid'), 'true')
assert.equal(fehlerVon(root, 'versand.aktiv').textContent, 'Bitte biete Versand oder Abholung an.')
assert.equal(fehlerVon(root, '_').textContent, 'Etwas stimmt nicht.')
assert.equal(speichernKnopf(root).disabled, false)
assert.deepEqual(log.meldungen, [])
assert.equal(log.neuGeladen, 0)
}))
test('409 (Einstellungen veraltet): Hinweis mit „Neu laden“, das den neuen Stand holt', async () => {
let stand = ANTWORT(STANDARD, 1)
const fx = startFx({
get: () => stand,
put: () => ({ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.', code: 'EINSTELLUNG_VERALTET' } }),
})
try {
const { root } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
const konflikt = root.querySelector('[data-konflikt]')
assert.ok(konflikt)
assert.match(konflikt.textContent, /inzwischen geändert/)
assert.equal(fehlerVon(root, '_').textContent, '')
stand = ANTWORT({ ...STANDARD, shopname: 'Von anderer Seite' }, 2)
await fx.klick(root.querySelector('[data-aktion="neu-laden"]'))
await fx.ruhe()
assert.equal(feldVon(root, 'shopname').value, 'Von anderer Seite')
assert.equal(root.querySelector('[data-konflikt]'), null)
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).at(-1).body.version, 2)
} finally {
fx.restore()
}
})
test('Netzwerkfehler beim Speichern: Meldung allgemein, nichts gespeichert', mitDom({
get: ANTWORT(STANDARD),
put: () => ({ status: 500, body: { error: 'Der Server hat Probleme.' } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
assert.equal(fehlerVon(root, '_').textContent, 'Der Server hat Probleme.')
assert.deepEqual(log.meldungen, [])
}))
/* -------------------------------------------------------------- nur lesen --- */
test('nurLesen: alle Felder gesperrt, kein Speichern-Knopf, kein Versand', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
const { root } = await oeffne(fx, { nurLesen: true })
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
assert.ok(steuerungen.length > 20)
assert.deepEqual(steuerungen.filter((el) => !el.disabled).map((el) => el.dataset.feld), [])
assert.equal(speichernKnopf(root), null)
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').filter((b) => b.getAttribute('type') === 'submit').length, 0)
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen', 'Werte bleiben sichtbar')
// auch ein abgesendetes Formular schreibt nichts
await fx.sende(root.querySelector('form'), 'submit')
assert.equal(puts(fx).length, 0)
}))
test('Ladefehler wird geworfen (die Hülle zeigt ihn mit „Noch einmal versuchen“)', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
try {
const root = h('div')
await assert.rejects(render(root, ctxVon().ctx), /Kaputt\./)
} finally {
fx.restore()
}
})
/* ---------------------------------------------------------------- statisch --- */
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write und kein Import aus termine*.js', () => {
const quelle = readFileSync(new URL('../public/shop-tab-einstellungen.js', import.meta.url), 'utf8')
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
})
+335
View File
@@ -0,0 +1,335 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { GEWAEHRLEISTUNG_NOTIZ, INTRO, NICHT_BESTAETIGT, render } from '../public/shop-tab-rechtliches.js'
// Task G6: Reiter „Rechtliches“ mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut (Form: rechtstexteAntwort in server/routes/shop.js).
const BESTAETIGUNG = 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.'
const MUSTER = { agb: 'MUSTER-AGB\nZeile zwei', widerrufsbelehrung: 'MUSTER-WIDERRUF', widerrufsformular: 'MUSTER-FORMULAR' }
const VOLL = {
agb: 'Meine AGB', widerrufsbelehrung: 'Meine Belehrung', widerrufsformular: 'Mein Formular',
impressumUrl: 'https://example.com/impressum', datenschutzUrl: 'https://example.com/datenschutz',
}
const LEER = { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }
const antwort = (entwurf, extra = {}) => ({
entwurf,
bestaetigt: null,
muster: MUSTER,
versandUndZahlung: 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.',
gewaehrleistung: { bild: '/img/gewaehrleistung-eu.webp', text: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre.' },
hash: 'hash-1',
bestaetigungText: BESTAETIGUNG,
vollstaendig: Object.values(entwurf).every((w) => w.trim() !== ''),
...extra,
})
const bestaetigt = (aktuell, extra = {}) => ({ texte: VOLL, at: '2026-10-05T12:30:00.000Z', von: 'Erika Muster', aktuell, ...extra })
/** Fake-Server: GET/PUT /api/shop/rechtstexte und POST …/bestaetigen aus `welt` (get(), put(aufruf), post(aufruf)). */
function startFx(welt) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/rechtstexte?projectId=')) return { body: welt.get() }
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/rechtstexte') return welt.put(aufruf)
if (aufruf.method === 'POST' && aufruf.url === '/api/shop/rechtstexte/bestaetigen') return welt.post(aufruf)
return { status: 404, body: { error: 'unbekannt' } }
}
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
}
const mitDom = (welt, fn) => async () => {
const fx = startFx(welt)
try {
await fn(fx)
} finally {
fx.restore()
}
}
async function oeffne(fx, ctxExtra = {}) {
const root = h('div', { id: 'panel' })
fx.document.body.append(root)
const log = { meldungen: [], neuGeladen: 0 }
const ctx = {
projectId: 'p1',
nurLesen: false,
request,
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
neuLaden: async () => { log.neuGeladen += 1 },
...ctxExtra,
}
await render(root, ctx)
await fx.ruhe()
return { root, log }
}
const text = (root, key) => root.querySelector(`[data-text="${key}"]`)
const knopf = (root, aktion) => root.querySelector(`[data-aktion="${aktion}"]`)
const haken = (root) => root.querySelector('[data-feld="bestaetigung"]')
const sendet = (fx, art) => fx.fetchLog.filter((a) => a.method === art)
/* --------------------------------------------------------------- Anzeige --- */
test('Anzeige: Hinweis, Texte aus dem Entwurf, erzeugte Texte nur lesbar, Gewährleistungsbild mit Hinweis', mitDom({ get: () => antwort(VOLL) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(fx.fetchLog[0].url, '/api/shop/rechtstexte?projectId=p1')
assert.equal(fx.text(root.querySelector('[data-hinweis="intro"]')), INTRO)
assert.match(INTRO, /^Du bist Verkäufer und für deine Rechtstexte verantwortlich\. Wir stellen Muster bereit, die du prüfen und anpassen musst – das ist keine Rechtsberatung\. Lass deine Texte am besten von einer Kanzlei oder einem Händlerbund prüfen\.$/)
assert.equal(text(root, 'agb').value, 'Meine AGB')
assert.equal(text(root, 'widerrufsbelehrung').value, 'Meine Belehrung')
assert.equal(text(root, 'widerrufsformular').value, 'Mein Formular')
assert.equal(text(root, 'impressumUrl').value, 'https://example.com/impressum')
assert.equal(text(root, 'datenschutzUrl').value, 'https://example.com/datenschutz')
for (const titel of ['Versand und Zahlung', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular']) assert.ok(fx.text(root).includes(titel), titel)
const versand = root.querySelector('[data-lesetext="versand-zahlung"]')
assert.equal(versand.textContent, 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.')
assert.equal(versand.tagName === 'TEXTAREA' || versand.tagName === 'INPUT', false, 'nur lesbar, kein Eingabefeld')
const bild = root.querySelector('img')
assert.equal(bild.getAttribute('src'), '/img/gewaehrleistung-eu.webp')
assert.match(bild.getAttribute('alt'), /GESETZLICHE GEWÄHRLEISTUNG/)
assert.equal(root.querySelector('[data-lesetext="gewaehrleistung"]').textContent.startsWith('GESETZLICHE GEWÄHRLEISTUNG'), true)
assert.equal(fx.text(root).includes(GEWAEHRLEISTUNG_NOTIZ), true)
assert.match(GEWAEHRLEISTUNG_NOTIZ, /automatisch vor dem Bestell-Knopf/)
assert.equal(fx.alle(root, (el) => el.tagName === 'TEXTAREA').length, 3)
}))
test('ein Gewährleistungsbild von fremder Adresse wird nicht geladen', mitDom({ get: () => antwort(VOLL, { gewaehrleistung: { bild: '//boese.example/x.webp', text: 'Text' } }) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('img'), null)
}))
/* ------------------------------------------------------------ Status, Banner --- */
test('Status: noch nie bestätigt, aktuell bestätigt (Datum und Person) und Banner bei unbestätigten Änderungen', async () => {
const fall = [
[null, 'offen', /Noch nicht bestätigt/],
[bestaetigt(true), 'bestaetigt', /Bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster\./],
[bestaetigt(false), 'unbestaetigt', /Änderungen noch nicht bestätigt.*zuletzt bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster/],
]
for (const [b, art, muster] of fall) {
const fx = startFx({ get: () => antwort(VOLL, { bestaetigt: b }) })
try {
const { root } = await oeffne(fx)
const status = root.querySelector('[data-status]')
assert.equal(status.dataset.status, art)
assert.match(status.textContent, muster)
assert.equal(root.querySelector('[data-status="unbestaetigt"]') !== null, art === 'unbestaetigt')
// schon bestätigt und nichts geändert: kein Bestätigungsblock
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, art === 'bestaetigt')
} finally {
fx.restore()
}
}
assert.equal(NICHT_BESTAETIGT, 'Änderungen noch nicht bestätigt')
})
/* -------------------------------------------------------- Muster einsetzen --- */
test('Muster einsetzen: leeres Feld wird sofort gefüllt, ein vorhandener Text erst nach Rückfrage', mitDom({ get: () => antwort({ ...LEER, widerrufsbelehrung: 'Mein eigener Text' }) }, async (fx) => {
const { root } = await oeffne(fx)
// leer: sofort
await fx.klick(knopf(root, 'muster-agb'))
assert.equal(text(root, 'agb').value, 'MUSTER-AGB\nZeile zwei')
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
// vorhandener Text: Rückfrage, Abbrechen lässt ihn stehen
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
const platz = root.querySelector('[data-frage-platz="widerrufsbelehrung"]')
assert.match(platz.textContent, /Widerrufsbelehrung durch das Muster ersetzen\?/)
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
await fx.klick(knopf(root, 'muster-nein'))
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
assert.equal(platz.children.length, 0)
// und mit „Ja“ wird ersetzt
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
await fx.klick(knopf(root, 'muster-ja'))
assert.equal(text(root, 'widerrufsbelehrung').value, 'MUSTER-WIDERRUF')
assert.equal(platz.children.length, 0)
// Formular: leer ⇒ Muster
await fx.klick(knopf(root, 'muster-widerrufsformular'))
assert.equal(text(root, 'widerrufsformular').value, 'MUSTER-FORMULAR')
assert.equal(sendet(fx, 'PUT').length, 0, 'Muster einsetzen speichert nichts')
}))
test('Muster einsetzen: ist der Text schon das Muster, braucht es keine Rückfrage', mitDom({ get: () => antwort({ ...LEER, agb: 'MUSTER-AGB\nZeile zwei' }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(knopf(root, 'muster-agb'))
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
}))
/* ------------------------------------------------------------ Entwurf speichern --- */
test('Entwurf speichern: Körper mit fünf Feldern, Antwort füllt die Felder, Meldung', mitDom({
get: () => antwort(LEER),
put: (aufruf) => ({ body: antwort({ ...aufruf.body.rechtstexte, agb: 'Serverfassung der AGB' }, { hash: 'hash-2' }) }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(text(root, 'agb'), ' Meine AGB ')
await fx.setze(text(root, 'widerrufsbelehrung'), 'Meine Belehrung')
await fx.setze(text(root, 'widerrufsformular'), 'Mein Formular')
await fx.setze(text(root, 'impressumUrl'), 'https://example.com/impressum')
await fx.setze(text(root, 'datenschutzUrl'), 'https://example.com/datenschutz')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.deepEqual(sendet(fx, 'PUT').map((a) => a.body), [{ projectId: 'p1', rechtstexte: { ...VOLL, agb: 'Meine AGB' } }])
assert.equal(text(root, 'agb').value, 'Serverfassung der AGB')
assert.deepEqual(log.meldungen, [['Der Entwurf ist gespeichert.', 'ok']])
}))
test('Entwurf speichern: Feldfehler des Servers stehen am Feld', mitDom({
get: () => antwort(VOLL),
put: () => ({ status: 400, body: { error: 'Ungültig.', fehler: { impressumUrl: 'Impressum: Die Adresse muss mit https:// beginnen.', agb: 'Die AGB darf höchstens 30000 Zeichen lang sein.' } } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(text(root, 'impressumUrl'), 'http://example.com/impressum')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.equal(root.querySelector('[data-fehler="impressumUrl"]').textContent, 'Impressum: Die Adresse muss mit https:// beginnen.')
assert.equal(text(root, 'impressumUrl').getAttribute('aria-invalid'), 'true')
assert.match(root.querySelector('[data-fehler="agb"]').textContent, /höchstens 30000/)
assert.deepEqual(log.meldungen, [])
assert.equal(knopf(root, 'entwurf-speichern').disabled, false)
}))
/* ---------------------------------------------------------------- Bestätigen --- */
test('Bestätigen: Haken nötig, dann POST mit dem Hash aus der letzten Antwort; danach Status bestätigt', async () => {
let aktuell = antwort(VOLL, { hash: 'hash-abc' })
const fx = startFx({
get: () => aktuell,
post: () => {
aktuell = antwort(VOLL, { hash: 'hash-abc', bestaetigt: bestaetigt(true) })
return { body: aktuell }
},
})
try {
const { root, log } = await oeffne(fx)
assert.equal(root.querySelector('[data-block="bestaetigen"]').textContent.includes(BESTAETIGUNG), true)
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
assert.equal(knopf(root, 'bestaetigen').disabled, true, 'ohne Haken gesperrt')
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, false)
await fx.klick(knopf(root, 'bestaetigen'))
assert.deepEqual(sendet(fx, 'POST').map((a) => [a.url, a.body]), [['/api/shop/rechtstexte/bestaetigen', { projectId: 'p1', hash: 'hash-abc' }]])
assert.equal(root.querySelector('[data-status]').dataset.status, 'bestaetigt')
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
assert.deepEqual(log.meldungen, [['Die Texte sind bestätigt.', 'ok']])
assert.equal(log.neuGeladen, 1)
} finally {
fx.restore()
}
})
test('ungespeicherte Änderungen sperren das Bestätigen: „Erst speichern“, nach dem Speichern gilt der neue Hash', async () => {
const fx = startFx({
get: () => antwort(VOLL, { hash: 'alt', bestaetigt: bestaetigt(true) }),
put: (aufruf) => ({ body: antwort(aufruf.body.rechtstexte, { hash: 'neu', bestaetigt: bestaetigt(false) }) }),
post: () => ({ body: antwort(VOLL, { hash: 'neu', bestaetigt: bestaetigt(true) }) }),
})
try {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, false)
assert.equal(knopf(root, 'bestaetigen').textContent, 'Erst speichern')
assert.equal(knopf(root, 'bestaetigen').disabled, true)
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /noch nicht gespeichert/)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
// zurückändern: wieder sauber
await fx.setze(text(root, 'agb'), 'Meine AGB')
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
assert.equal(root.querySelector('[data-status]').dataset.status, 'unbestaetigt')
assert.equal(haken(root).checked, false, 'nach neuen Texten muss der Haken neu gesetzt werden')
assert.equal(knopf(root, 'bestaetigen').disabled, true)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.deepEqual(sendet(fx, 'POST').map((a) => a.body), [{ projectId: 'p1', hash: 'neu' }])
} finally {
fx.restore()
}
})
test('unvollständige Texte lassen sich nicht bestätigen und sagen warum', mitDom({ get: () => antwort({ ...VOLL, datenschutzUrl: '' }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, true)
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /ausgefüllt und gespeichert/)
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
}))
test('409 beim Bestätigen (Texte inzwischen geändert): Meldung, neuer Stand, Haken wieder frei', async () => {
let stand = antwort(VOLL, { hash: 'alt' })
const fx = startFx({
get: () => stand,
post: () => {
stand = antwort({ ...VOLL, agb: 'Von anderer Seite geändert' }, { hash: 'neu' })
return { status: 409, body: { error: 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', code: 'texte_geaendert' } }
},
})
try {
const { root } = await oeffne(fx)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.match(root.querySelector('[data-fehler="bestaetigung"]').textContent, /inzwischen geändert/)
assert.equal(text(root, 'agb').value, 'Von anderer Seite geändert')
assert.equal(haken(root).checked, false)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').at(-1).body.hash, 'neu')
} finally {
fx.restore()
}
})
test('Fehler beim Bestätigen (zum Beispiel Kundenansicht, 403) steht am Block', mitDom({
get: () => antwort(VOLL),
post: () => ({ status: 403, body: { error: 'In der Kundenansicht kannst Du nichts bestätigen.' } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(root.querySelector('[data-fehler="bestaetigung"]').textContent, 'In der Kundenansicht kannst Du nichts bestätigen.')
assert.deepEqual(log.meldungen, [])
assert.equal(sendet(fx, 'GET').length, 1, 'bei 403 wird nicht neu geladen')
}))
/* -------------------------------------------------------------- nur lesen --- */
test('nurLesen: Felder gesperrt, keine Knöpfe, kein Bestätigungsblock; Status und Lesetexte bleiben', mitDom({ get: () => antwort(VOLL, { bestaetigt: bestaetigt(false) }) }, async (fx) => {
const { root } = await oeffne(fx, { nurLesen: true })
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
assert.equal(steuerungen.length, 5)
assert.deepEqual(steuerungen.filter((el) => !el.disabled), [])
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').length, 0)
assert.equal(haken(root), null)
assert.equal(text(root, 'agb').value, 'Meine AGB')
assert.ok(root.querySelector('[data-status="unbestaetigt"]'))
assert.equal(root.querySelector('[data-lesetext="versand-zahlung"]').textContent.includes('Zahlung per Karte'), true)
await fx.sende(root.querySelector('form'), 'submit')
assert.equal(sendet(fx, 'PUT').length, 0)
}))
test('Ladefehler wird geworfen', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
try {
await assert.rejects(render(h('div'), { projectId: 'p1', request }), /Kaputt\./)
} finally {
fx.restore()
}
})
/* ---------------------------------------------------------------- statisch --- */
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, confirm() und kein Import aus termine*.js', () => {
const quelle = readFileSync(new URL('../public/shop-tab-rechtliches.js', import.meta.url), 'utf8')
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
})