Shop Review B I-5: Bestaetigen nur ohne Platzhalter, Musterkopf und mit Adresse der Widerrufsfunktion

pruefeBestaetigbar listet die Probleme in Du-Form; POST /api/shop/rechtstexte/bestaetigen
antwortet 400 nicht_bestaetigbar mit der Liste, GET und PUT liefern sie als probleme mit.
Der Reiter Rechtliches zeigt die Punkte im Bestaetigungsblock und sperrt den Knopf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:53:17 +00:00
1 parent 1f56b86791
commit 2f4f360a21
6 files changed
+169 -15

No files matched your search

+34
View File
@@ -12,6 +12,8 @@ import {
vollstaendig,
rechtstexteHash,
BESTAETIGUNG_TEXT,
pruefeBestaetigbar,
MUSTER_KOPFZEILE,
} from '../server/services/shop/rechtstexte.js'
import { STANDARD_EINSTELLUNGEN } from '../server/services/shop/einstellungen.js'
@@ -340,3 +342,35 @@ test('Markdown-Muster und Code teilen die Kernsaetze', () => {
assert.ok(md.includes('Wir tragen die Kosten der Rücksendung der Waren.'))
assert.doesNotMatch(md, /ec\.europa\.eu\/consumers\/odr/)
})
test('pruefeBestaetigbar I-5: Muster mit Platzhaltern, Kopfzeile oder ohne Adresse der Widerrufsfunktion lässt sich nicht bestätigen', () => {
const leer = JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN))
const roh = { agb: musterAgb(leer), widerrufsbelehrung: musterWiderrufsbelehrung(leer, {}), widerrufsformular: musterWiderrufsformular(leer) }
const p = pruefeBestaetigbar(roh, { widerrufUrl: WIDERRUF_URL })
const alle = p.join('\n')
for (const s of ['[Name]', '[Anschrift]', '[E-Mail-Adresse]', '[Internetadresse der Widerrufsfunktion]', '[Lieferzeit]', MUSTER_KOPFZEILE, WIDERRUF_URL]) assert.ok(alle.includes(s), s)
assert.match(alle, /^In den AGB stehen noch Platzhalter/m)
assert.match(alle, /dem Muster-Widerrufsformular/)
// ausgefuellt, ohne Kopfzeile, mit Adresse: keine Probleme
const e = basis()
const gut = {
agb: musterAgb(e).replace(`${MUSTER_KOPFZEILE}\n\n`, ''),
widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }),
widerrufsformular: musterWiderrufsformular(e),
}
assert.deepEqual(pruefeBestaetigbar(gut, { widerrufUrl: WIDERRUF_URL }), [])
// nur die Kopfzeile
assert.equal(pruefeBestaetigbar({ ...gut, agb: musterAgb(e) }, { widerrufUrl: WIDERRUF_URL }).length, 1)
// Belehrung ohne die Adresse
const ohneUrl = pruefeBestaetigbar({ ...gut, widerrufsbelehrung: gut.widerrufsbelehrung.replace(WIDERRUF_URL, 'https://anders.example/') }, { widerrufUrl: WIDERRUF_URL })
assert.equal(ohneUrl.length, 1)
assert.match(ohneUrl[0], /Adresse deiner Widerrufsfunktion enthalten: https:\/\/brot\.example\/shop#widerruf/)
// ohne bekannte Adresse wird sie nicht verlangt; beliebige Angabe in eckigen Klammern zaehlt, einzelne Zeichen nicht
assert.deepEqual(pruefeBestaetigbar(gut, {}), [])
assert.equal(pruefeBestaetigbar({ ...gut, agb: `${gut.agb}\n[Zahlungsarten]` }, { widerrufUrl: WIDERRUF_URL }).length, 1)
assert.deepEqual(pruefeBestaetigbar({ ...gut, agb: `${gut.agb}\nSiehe Anlage [1]` }, { widerrufUrl: WIDERRUF_URL }), [])
// fehlende Texte stoeren hier nicht (das prueft vollstaendig)
assert.doesNotThrow(() => pruefeBestaetigbar(null))
assert.deepEqual(pruefeBestaetigbar({}, { widerrufUrl: WIDERRUF_URL }), [])
})
+37 -11
View File
@@ -61,6 +61,9 @@ const TEXTE = {
datenschutzUrl: 'https://toepferei.example/datenschutz',
}
const ARTIKEL = { name: 'Tasse', preisCent: 1800 }
/** Bestaetigungsfaehige Texte: die Belehrung nennt die Adresse der Widerrufsfunktion dieses Shops (I-5). */
const WIDERRUF = (feature) => `${config.publicBaseUrl}/shop/${feature.siteKey}/widerruf`
const texteFuer = (feature) => ({ ...TEXTE, widerrufsbelehrung: `Unsere Widerrufsbelehrung. Widerruf online: ${WIDERRUF(feature)}` })
const q = (projectId = 'pt1') => `projectId=${projectId}`
const lesen = (pfad, projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}${q(projectId)}`, opts)
@@ -98,7 +101,7 @@ async function machBereit({ mitArtikel = true } = {}) {
const feature = await aktiviere()
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200)
if (mitArtikel) assert.equal((await schreiben('POST', 'produkte', { produkt: ARTIKEL })).status, 201)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })).status, 200)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })).status, 200)
const hash = (await lesen('rechtstexte')).body.hash
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash })).status, 200)
await updateDocument(config.collections.shopConfig, feature.$id, {
@@ -447,10 +450,11 @@ test('Rechtstexte GET: Entwurf leer, nichts bestaetigt, Muster, erzeugte Texte,
const res = await lesen('rechtstexte')
assert.equal(res.status, 200)
const b = res.body
assert.deepEqual(Object.keys(b).sort(), ['bestaetigt', 'bestaetigungText', 'entwurf', 'gewaehrleistung', 'hash', 'muster', 'versandUndZahlung', 'vollstaendig'])
assert.deepEqual(Object.keys(b).sort(), ['bestaetigt', 'bestaetigungText', 'entwurf', 'gewaehrleistung', 'hash', 'muster', 'probleme', 'versandUndZahlung', 'vollstaendig'])
assert.deepEqual(b.entwurf, { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' })
assert.equal(b.bestaetigt, null)
assert.equal(b.vollstaendig, false)
assert.deepEqual(b.probleme, [], 'leerer Entwurf: nur vollstaendig meldet etwas')
assert.match(b.hash, /^[0-9a-f]{64}$/)
assert.equal(b.hash, rechtstexteHash(b.entwurf, (await lesen('einstellungen')).body.einstellungen))
for (const k of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) assert.ok(b.muster[k].length > 100, k)
@@ -485,13 +489,13 @@ test('Rechtstexte PUT: speichert den Entwurf, normalisiert, Fehler 400; Hash aen
test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400, sonst Kopie mit Zeit, Person und Hash', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const unvollstaendig = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, datenschutzUrl: '' } })
const unvollstaendig = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...texteFuer(feature), datenschutzUrl: '' } })
const hashUnvollstaendig = unvollstaendig.body.hash
const leer = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })
assert.equal(leer.status, 400)
assert.equal(shopConfigDoc(feature).rechtstexteHash || '', '')
const voll = await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })
const voll = await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })
const falsch = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: 'abc' })
assert.deepEqual([falsch.status, falsch.body.code], [409, 'texte_geaendert'])
assert.match(falsch.body.error, /^Die Texte wurden inzwischen geändert/)
@@ -500,7 +504,7 @@ test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400,
const ok = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })
assert.equal(ok.status, 200)
assert.deepEqual(ok.body.bestaetigt.texte, TEXTE)
assert.deepEqual(ok.body.bestaetigt.texte, texteFuer(feature))
assert.equal(ok.body.bestaetigt.von, `${customers.c1.name} <${customers.c1.email}>`)
assert.equal(ok.body.bestaetigt.aktuell, true)
assert.ok(Math.abs(Date.now() - Date.parse(ok.body.bestaetigt.at)) < 10_000)
@@ -509,13 +513,13 @@ test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400,
assert.equal(doc.rechtstexteBestaetigtVon, `${customers.c1.name} <${customers.c1.email}>`)
assert.equal(new Date(doc.rechtstexteBestaetigtAt).toISOString(), new Date(ok.body.bestaetigt.at).toISOString())
const gespeichert = JSON.parse(doc.rechtstexte)
assert.deepEqual(gespeichert.entwurf, TEXTE)
assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [TEXTE, voll.body.hash])
assert.deepEqual(gespeichert.entwurf, texteFuer(feature))
assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [texteFuer(feature), voll.body.hash])
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.rechtstexte.bestaetigt.hash, voll.body.hash, 'Speicher wurde geleert')
// Entwurf aendern: die bestaetigte Fassung bleibt, ist aber nicht mehr aktuell; Bereitschaft verlangt neue Bestaetigung
const geaendert = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'Geaenderte AGB' } })
assert.deepEqual(geaendert.body.bestaetigt.texte, TEXTE)
const geaendert = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...texteFuer(feature), agb: 'Geaenderte AGB' } })
assert.deepEqual(geaendert.body.bestaetigt.texte, texteFuer(feature))
assert.equal(geaendert.body.bestaetigt.aktuell, false)
assert.equal(geaendert.body.entwurf.agb, 'Geaenderte AGB')
const stand = (await lesen('status')).body.bereitschaft.punkte.find((p) => p.id === 'rechtstexte')
@@ -524,9 +528,9 @@ test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400,
})
test('Rechtstexte: Hash haengt auch an den Einstellungen (Versand und Zahlung), eine Aenderung dort verlangt neue Bestaetigung', async () => {
await aktiviere()
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })
await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })
const alt = (await lesen('rechtstexte')).body.hash
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, versand: { ...EINSTELLUNGEN.versand, kostenCent: { DE: 690 } } } })
const neu = (await lesen('rechtstexte')).body.hash
@@ -535,6 +539,28 @@ test('Rechtstexte: Hash haengt auch an den Einstellungen (Versand und Zahlung),
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: neu })).status, 200)
})
test('Rechtstexte bestaetigen I-5: Platzhalter, Musterkopf und fehlende Adresse der Widerrufsfunktion ⇒ 400 nicht_bestaetigbar mit Liste; GET zeigt dieselben Probleme', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const schlecht = { ...texteFuer(feature), agb: 'Muster – bitte prüfen und anpassen\n\nAGB von [Name], [Anschrift]', widerrufsbelehrung: 'Unsere Widerrufsbelehrung ohne Adresse.' }
const put = await schreiben('PUT', 'rechtstexte', { rechtstexte: schlecht })
assert.equal(put.status, 200)
assert.equal(put.body.probleme.length, 3)
assert.deepEqual((await lesen('rechtstexte')).body.probleme, put.body.probleme)
const res = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: put.body.hash })
assert.equal(res.status, 400)
assert.equal(res.body.code, 'nicht_bestaetigbar')
assert.deepEqual(res.body.probleme, put.body.probleme)
assert.match(res.body.probleme.join('\n'), /„\[Name\]“, „\[Anschrift\]“/)
assert.match(res.body.probleme.join('\n'), /Muster – bitte prüfen und anpassen/)
assert.ok(res.body.probleme.join('\n').includes(WIDERRUF(feature)))
assert.equal(shopConfigDoc(feature).rechtstexteHash || '', '', 'nichts bestaetigt')
// behoben ⇒ ohne Probleme und bestaetigbar
const gut = await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })
assert.deepEqual(gut.body.probleme, [])
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: gut.body.hash })).status, 200)
})
// ---------------------------------------------------------------- Oeffnen
test('Oeffnen: nur wenn bereit (sonst 409 mit Punkten), Schliessen immer, Speicher geleert, Eingabe geprueft', async () => {
+36
View File
@@ -257,6 +257,42 @@ test('ungespeicherte Änderungen sperren das Bestätigen: „Erst speichern“,
}
})
test('I-5: Probleme des Servers (Platzhalter, Musterkopf, Adresse der Widerrufsfunktion) stehen im Bestätigungsblock und sperren den Knopf', async () => {
const probleme = ['In den AGB stehen noch Platzhalter in eckigen Klammern: „[Name]“.', 'Die Widerrufsbelehrung muss die Adresse deiner Widerrufsfunktion enthalten: https://x.example/w.']
const fx = startFx({ get: () => antwort(VOLL, { hash: 'h1', probleme }), post: () => ({ body: antwort(VOLL) }) })
try {
const { root } = await oeffne(fx)
const box = root.querySelector('[data-probleme]')
assert.ok(box, 'Liste sichtbar')
assert.deepEqual([...box.querySelectorAll('li')].map((li) => li.textContent), probleme)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, true, 'mit Problemen gesperrt')
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
} finally {
fx.restore()
}
})
test('I-5: lehnt der Server mit nicht_bestaetigbar ab, zeigt der Reiter dessen Liste', async () => {
const probleme = ['In den AGB steht noch die Zeile „Muster – bitte prüfen und anpassen“. Prüfe den Text, passe ihn an und lösche die Zeile.']
const fx = startFx({
get: () => antwort(VOLL, { hash: 'h1' }),
post: () => ({ status: 400, body: { error: 'Die Texte können noch nicht bestätigt werden. Bitte behebe zuerst die genannten Punkte.', code: 'nicht_bestaetigbar', probleme } }),
})
try {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('[data-probleme]'), null)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 1)
assert.deepEqual([...root.querySelector('[data-probleme]').querySelectorAll('li')].map((li) => li.textContent), probleme)
assert.equal(knopf(root, 'bestaetigen').disabled, true)
} 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))