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/) })