Files
Webklar-Kundenbereich/test/shopTabRechtliches.test.js
T
ClaudeandClaude Opus 5.5 2f4f360a21 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>
2026-10-05 09:53:17 +00:00

372 lines
19 KiB
JavaScript
Raw 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 } 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('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))
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/)
})