Files
Webklar-Kundenbereich/test/shopRechtstexte.test.js
T
ClaudeandClaude Opus 5.5 03a7f1d877 Webshop: amtliche Gewährleistungsmitteilung (VO (EU) 2025/1960 Anhang I) als Grafik und wörtlicher Text
Grafik aus dem Amtsblatt (ABl. L vom 2.10.2025, S. 5) ausgeschnitten; Text der Grafik wörtlich für Alternativtext
und Bestellbestätigung.

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

313 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from 'node:fs'
import {
musterWiderrufsbelehrung,
musterWiderrufsformular,
musterAgb,
versandUndZahlung,
gewaehrleistungsHinweis,
zahlungsartenText,
normalisiereRechtstexte,
vollstaendig,
rechtstexteHash,
BESTAETIGUNG_TEXT,
} from '../server/services/shop/rechtstexte.js'
import { STANDARD_EINSTELLUNGEN } from '../server/services/shop/einstellungen.js'
const NB = '\u00a0'
const basis = () => {
const e = JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN))
e.anbieter = { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123456', ustId: 'DE123456789' }
return e
}
const WIDERRUF_URL = 'https://brot.example/shop#widerruf'
test('Widerrufsbelehrung: gesetzliche Kernsaetze und eingesetzte Anbieterdaten', () => {
const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL })
for (const s of [
'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.',
'die letzte Ware in Besitz genommen haben bzw. hat.',
'mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail)',
'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
'unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist.',
'in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
'Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.',
'Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.',
]) {
assert.ok(t.includes(s), `fehlt: ${s}`)
}
assert.ok(t.includes('(Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, Telefon: 0631 123456, E-Mail: shop@brot.example)'))
assert.ok(t.includes('an uns (Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern) zurückzusenden oder zu übergeben'))
assert.ok(!t.includes('['), 'keine offenen Platzhalter bei vollen Angaben')
assert.ok(t.split('\n\n').length > 10)
})
test('Widerrufsbelehrung: Hinweis auf die Widerrufsfunktion mit Adresse', () => {
const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes(`Sie können Ihr Widerrufsrecht auch online unter ${WIDERRUF_URL} ausüben.`))
assert.ok(
t.includes(
'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.'
)
)
// ohne Adresse: sichtbarer Platzhalter statt leerer Stelle
assert.ok(musterWiderrufsbelehrung(basis()).includes('[Internetadresse der Widerrufsfunktion]'))
})
test('Widerrufsbelehrung: Variante Ruecksendekosten', () => {
const e = basis()
assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.'))
e.ruecksendekosten = 'haendler'
const t = musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes('Wir tragen die Kosten der Rücksendung der Waren.'))
assert.ok(!t.includes('Sie tragen die unmittelbaren Kosten'))
})
test('Widerrufsbelehrung: Abholung nur wenn angeboten', () => {
const e = basis()
assert.ok(!musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('abgeholt'))
e.abholung = { aktiv: true, adresse: 'Lager, Bahnhofstraße 1, 67655 Kaiserslautern', hinweis: '' }
assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: Lager, Bahnhofstraße 1, 67655 Kaiserslautern.'))
})
test('Widerrufsbelehrung: Ausschluss und vorzeitiges Erlöschen nach § 312g Abs. 2 Nr. 1, 2, 3', () => {
const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes('Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts'))
assert.ok(t.includes('die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB)'))
assert.ok(t.includes('Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB)'))
assert.ok(t.includes('Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB)'))
})
test('Widerrufsbelehrung: leere Anbieterdaten ergeben Platzhalter, kein Absturz', () => {
const t = musterWiderrufsbelehrung(STANDARD_EINSTELLUNGEN, { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes('[Name]'))
assert.ok(t.includes('[Anschrift]'))
assert.ok(t.includes('[E-Mail-Adresse]'))
assert.doesNotThrow(() => musterWiderrufsbelehrung(undefined))
assert.doesNotThrow(() => musterAgb({}))
assert.doesNotThrow(() => versandUndZahlung(null))
})
test('Widerrufsformular nach Anlage 2 mit Anschrift', () => {
const t = musterWiderrufsformular(basis())
assert.ok(t.includes('An Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, E-Mail: shop@brot.example:'))
assert.ok(t.includes('Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)'))
assert.ok(t.includes('– Bestellt am (*)/erhalten am (*)'))
assert.ok(t.includes('– Name des/der Verbraucher(s)'))
assert.ok(t.includes('– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)'))
assert.ok(t.includes('(*) Unzutreffendes streichen.'))
assert.ok(t.startsWith('Muster-Widerrufsformular'))
})
test('AGB: Kopf, Paragraphen, Vertragsschluss, Zahlung, Streitbeilegung ohne OS-Plattform', () => {
const e = basis()
e.zahlungsarten = ['card', 'klarna', 'sepa_debit']
e.versand.laender = ['DE', 'AT']
e.kleinunternehmer = false
const t = musterAgb(e)
assert.ok(t.startsWith('Muster – bitte prüfen und anpassen'))
for (let i = 1; i <= 10; i += 1) assert.ok(new RegExp(`^§ ${i} `, 'm').test(t), `§ ${i} fehlt`)
assert.ok(t.includes('Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.'))
assert.ok(t.includes('Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden.'))
assert.ok(t.includes('Die Zahlung erfolgt über den Zahlungsdienstleister Stripe.'))
assert.ok(t.includes('Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift'))
assert.ok(t.includes('Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.'))
assert.ok(t.includes('Wir liefern in folgende Länder: Deutschland, Österreich.'))
assert.ok(t.includes('Die Lieferzeit beträgt 2–4 Werktage'))
assert.ok(t.includes('Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.'))
assert.ok(t.includes('Es gelten die gesetzlichen Mängelhaftungsrechte.'))
assert.ok(t.includes('Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar.'))
assert.ok(t.includes('Die Vertragssprache ist Deutsch.'))
assert.ok(t.includes('ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)'))
assert.ok(t.includes('Alle Preise enthalten die gesetzliche Umsatzsteuer.'))
assert.ok(t.includes('USt-IdNr.: DE123456789'))
assert.doesNotMatch(t, /ec\.europa\.eu|OS-Plattform|ODR/i)
})
test('AGB: Varianten Kleinunternehmer, nur Karte, Abholung, kein Versand', () => {
const e = basis()
e.kleinunternehmer = true
e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' }
e.versand.aktiv = false
const t = musterAgb(e)
assert.ok(t.includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.'))
assert.ok(!t.includes('SEPA'), 'SEPA-Satz nur bei SEPA')
assert.ok(!t.includes('Wir liefern in folgende Länder'))
assert.ok(t.includes('Sie können die Waren auch bei uns abholen: Laden, Hauptstraße 5. Di–Fr 9–17 Uhr'))
})
test('versandUndZahlung: Liefergebiet, Kosten je Land, frei ab, Abholung, Zahlungsarten, Mindestbestellwert', () => {
const e = basis()
e.versand = { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 }
e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' }
e.zahlungsarten = ['card', 'klarna']
e.mindestbestellCent = 1000
e.kleinunternehmer = false
const t = versandUndZahlung(e)
assert.ok(t.includes('Liefergebiet: Deutschland, Österreich'))
assert.ok(t.includes(`Deutschland: 4,90${NB}€`))
assert.ok(t.includes(`Österreich: 9,90${NB}€`))
assert.ok(t.includes(`Versandkostenfrei ab einem Bestellwert von 50,00${NB}€.`))
assert.ok(t.includes('Abholung: Laden, Hauptstraße 5\nDi–Fr 9–17 Uhr'))
assert.ok(t.includes('Lieferzeit: 2–4 Werktage'))
assert.ok(t.includes('Zahlungsarten: Kreditkarte, Apple Pay, Google Pay, Klarna'))
assert.ok(t.includes(`Mindestbestellwert: 10,00${NB}€`))
assert.ok(t.includes('Alle Preise inkl. gesetzlicher USt.'))
e.kleinunternehmer = true
e.mindestbestellCent = null
e.versand.freiAbCent = null
e.abholung.aktiv = false
const k = versandUndZahlung(e)
assert.ok(k.includes('§ 19 UStG'))
assert.ok(!k.includes('Mindestbestellwert'))
assert.ok(!k.includes('Versandkostenfrei'))
assert.ok(!k.includes('Abholung'))
e.versand.aktiv = false
assert.ok(versandUndZahlung(e).includes('Wir versenden derzeit nicht.'))
})
test('zahlungsartenText: feste Reihenfolge, nur aktive Arten', () => {
assert.equal(zahlungsartenText({ zahlungsarten: ['card'] }), 'Kreditkarte, Apple Pay, Google Pay')
assert.equal(zahlungsartenText({ zahlungsarten: ['klarna', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay, Klarna')
assert.equal(zahlungsartenText({ zahlungsarten: ['sepa_debit', 'klarna'] }), 'Klarna, SEPA-Lastschrift')
assert.equal(zahlungsartenText({ zahlungsarten: ['paypal', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay')
assert.equal(zahlungsartenText({}), '')
assert.equal(zahlungsartenText(undefined), '')
})
test('Gewaehrleistungshinweis: zwei Jahre, stabil, ohne Platzhalter', () => {
const t = gewaehrleistungsHinweis()
assert.ok(t.startsWith('GESETZLICHE GEWÄHRLEISTUNG'))
assert.ok(t.includes('Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit'))
assert.ok(t.includes('europa.eu/youreurope/garantien'))
assert.equal(gewaehrleistungsHinweis(), t)
})
test('BESTAETIGUNG_TEXT genau nach Spec 4.6', () => {
assert.equal(BESTAETIGUNG_TEXT, '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.')
})
test('normalisiereRechtstexte: gueltig, Entwurf mit leeren Texten, Bereinigung', () => {
const r = normalisiereRechtstexte({
agb: ' Zeile 1\r\nZeile 2​ \n\n\n\nZeile 3 ',
widerrufsbelehrung: 'B',
widerrufsformular: 'F',
impressumUrl: ' https://brot.example/impressum ',
datenschutzUrl: 'https://brot.example/datenschutz?x=1#a',
unbekannt: 'wird ignoriert',
})
assert.deepEqual(r, {
rechtstexte: {
agb: 'Zeile 1\nZeile 2\n\nZeile 3',
widerrufsbelehrung: 'B',
widerrufsformular: 'F',
impressumUrl: 'https://brot.example/impressum',
datenschutzUrl: 'https://brot.example/datenschutz?x=1#a',
},
})
const leer = normalisiereRechtstexte({})
assert.deepEqual(leer, { rechtstexte: { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' } })
assert.equal(vollstaendig(leer.rechtstexte), false)
})
test('normalisiereRechtstexte: Laengen', () => {
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30001) }).fehler.agb)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20001) }).fehler.widerrufsbelehrung)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5001) }).fehler.widerrufsformular)
})
test('normalisiereRechtstexte: URLs nur https ohne Zugangsdaten und Leerraum', () => {
for (const u of ['http://x.example/a', 'javascript:alert(1)', 'ftp://x.example', '//x.example/a', 'x.example/impressum', 'https://user:pw@x.example/a', 'https://user@x.example/a', 'https://x.example/a b', 'https://', 'data:text/html,hi']) {
const r = normalisiereRechtstexte({ impressumUrl: u })
assert.ok(r.fehler?.impressumUrl, `muss abgelehnt werden: ${u}`)
}
assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://' + 'a'.repeat(500) + '.example' }).fehler.datenschutzUrl)
assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://x.example/datenschutz' }).rechtstexte)
assert.equal(normalisiereRechtstexte({ impressumUrl: '' }).rechtstexte.impressumUrl, '')
})
test('normalisiereRechtstexte: falsche Typen und Nicht-Objekte', () => {
for (const v of [null, undefined, 'x', 5, [], true]) assert.ok(normalisiereRechtstexte(v).fehler.rechtstexte)
const r = normalisiereRechtstexte({ agb: 5, widerrufsbelehrung: {}, impressumUrl: 7 })
assert.ok(r.fehler.agb && r.fehler.widerrufsbelehrung && r.fehler.impressumUrl)
assert.ok(!('rechtstexte' in r))
// Prototyp-Schluessel werden nie gelesen
const p = normalisiereRechtstexte(JSON.parse('{"__proto__":{"agb":"x"}}'))
assert.equal(p.rechtstexte.agb, '')
})
test('vollstaendig: alle drei Texte und beide URLs', () => {
const voll = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' }
assert.equal(vollstaendig(voll), true)
for (const k of Object.keys(voll)) {
assert.equal(vollstaendig({ ...voll, [k]: '' }), false, k)
assert.equal(vollstaendig({ ...voll, [k]: ' ' }), false, `${k} nur Leerraum`)
}
assert.equal(vollstaendig(null), false)
assert.equal(vollstaendig(undefined), false)
})
test('rechtstexteHash: stabil, sha256 hex, reagiert auf jedes Feld und die Einstellungen', () => {
const e = basis()
const r = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' }
const h = rechtstexteHash(r, e)
assert.match(h, /^[0-9a-f]{64}$/)
assert.equal(rechtstexteHash({ ...r }, JSON.parse(JSON.stringify(e))), h)
// Reihenfolge der Schluessel spielt keine Rolle
assert.equal(rechtstexteHash({ datenschutzUrl: r.datenschutzUrl, impressumUrl: r.impressumUrl, widerrufsformular: 'c', widerrufsbelehrung: 'b', agb: 'a' }, e), h)
// zusaetzliche Felder zaehlen nicht
assert.equal(rechtstexteHash({ ...r, extra: 'x' }, e), h)
for (const k of Object.keys(r)) assert.notEqual(rechtstexteHash({ ...r, [k]: `${r[k]}x` }, e), h, k)
// Versand-/Zahlungstext fliesst ein
const e2 = JSON.parse(JSON.stringify(e))
e2.versand.kostenCent.DE = 590
assert.notEqual(rechtstexteHash(r, e2), h)
const e3 = JSON.parse(JSON.stringify(e))
e3.zahlungsarten = ['card', 'klarna']
assert.notEqual(rechtstexteHash(r, e3), h)
// Felder ohne Einfluss auf den erzeugten Text aendern den Hash nicht
const e4 = JSON.parse(JSON.stringify(e))
e4.akzent = '#ff0000'
assert.equal(rechtstexteHash(r, e4), h)
// fehlende Felder wie leere Texte
assert.equal(rechtstexteHash({}, e), rechtstexteHash({ agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }, e))
})
test('Markdown-Muster und Code teilen die Kernsaetze', () => {
const md = readFileSync(new URL('../docs/funktionen/shop-muster-rechtstexte.md', import.meta.url), 'utf8')
assert.ok(md.includes('Muster – keine Rechtsberatung'))
const e = basis()
e.zahlungsarten = ['card', 'klarna', 'sepa_debit']
e.kleinunternehmer = false
// Absaetze ohne Haendlerdaten muessen wortgleich im Markdown stehen
const absaetze = [
...musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).split('\n\n'),
...musterAgb(e).split('\n\n'),
...musterWiderrufsformular(e).split('\n\n'),
...gewaehrleistungsHinweis().split('\n\n'),
]
const ohneDaten = absaetze.filter((a) => !/Brot & Mehr|brot\.example|Hauptstra|Kaiserslautern|2–4 Werktage|0631|DE123456789/.test(a))
assert.ok(ohneDaten.length >= 25, `zu wenige vergleichbare Absaetze: ${ohneDaten.length}`)
const mdFlach = md.replace(/ {2}\n/g, '\n')
for (const a of ohneDaten) assert.ok(mdFlach.includes(a), `im Markdown fehlt: ${a.slice(0, 80)}`)
// Absaetze mit Haendlerdaten: Kernsaetze davon
for (const s of [
'Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.',
'Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden.',
'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung',
'ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)',
'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag',
]) {
assert.ok(md.includes(s), `Markdown: ${s}`)
}
assert.ok(md.includes('Wir tragen die Kosten der Rücksendung der Waren.'))
assert.doesNotMatch(md, /ec\.europa\.eu\/consumers\/odr/)
})