From 036bf4b0b90d3fabae43f0543928ee2c3ca27577 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:24:31 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P7:=20K=C3=BCndigen=20und?= =?UTF-8?q?=20Widerrufen=20in=20du=20(Fassung=206,=20C14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/auftrag-widerruf.js | 7 +- public/legal-form.js | 16 ++++ public/neu/oeffentlich/kuendigen.html | 10 +-- public/neu/oeffentlich/widerrufen.html | 6 +- test/knAnsichtOeffentlich.test.js | 4 +- test/knOeffentlichDu.test.js | 113 +++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 test/knOeffentlichDu.test.js diff --git a/public/auftrag-widerruf.js b/public/auftrag-widerruf.js index 082c1ab..1b3d32a 100644 --- a/public/auftrag-widerruf.js +++ b/public/auftrag-widerruf.js @@ -2,7 +2,7 @@ Rechtsfunktion: dieses Modul darf die anderen Vertragsarten nie stören. Fällt es aus, funktioniert der Widerruf wie bisher. Das Feld ist nur bei „Projektauftrag“ sichtbar; eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden, ein leeres Feld lässt den Widerruf durch (der Widerruf bleibt formfrei). Die Meldung steht in auftrag-ansicht.js. */ -import { abNummerNormalisiert, texte } from './auftrag-ansicht.js' +import { ANREDE_DU, abNummerNormalisiert, texte } from './auftrag-ansicht.js' export function initProjektWiderruf(form) { const wrap = form?.querySelector('#ab-nummer-wrap') @@ -39,7 +39,10 @@ export function initProjektWiderruf(form) { const nummer = abNummerNormalisiert(eingabe) if (!nummer) { ev.preventDefault() - fehler.textContent = texte().abNummerUngueltig + // Neue Seite (data-anrede="du"): die Meldung in du; ohne das Attribut bleibt der Satz aus auftrag-ansicht.js + fehler.textContent = form.getAttribute('data-anrede') === ANREDE_DU + ? 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfe die Nummer oder lass das Feld leer.' + : texte().abNummerUngueltig fehler.classList.remove('hidden') eigeneMeldung = true feld.focus() diff --git a/public/legal-form.js b/public/legal-form.js index e272a49..f78a0fa 100644 --- a/public/legal-form.js +++ b/public/legal-form.js @@ -3,6 +3,13 @@ // Der Kunde waehlt den Vertrag (Kuendigung: Abo oder nur der Zusatzspeicher, ganz oder mit Paketzahl) bzw. die // Bestellung (Widerruf: Abschluss, Wechsel, Zusatzspeicher); die Auswahl geht in den Request und steht in der // Bestaetigung. Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar. +/** Paketzahl der Kündigung des Zusatzspeichers prüfen (nur die neue Seite mit data-anrede="du"): leer oder ganze Zahl von 1 bis max → '', sonst der du-Satz. */ +function legalFormPaketeFehler(wert, max) { + const text = String(wert ?? '').trim() + if (text === '') return '' + return /^\d+$/.test(text) && Number(text) >= 1 && Number(text) <= max ? '' : `Bitte gib 1 bis ${max} Pakete an oder lass das Feld leer (alle Pakete).` +} + function initLegalForm({ endpoint, fields }) { const form = document.getElementById('decl-form') const done = document.getElementById('done') @@ -51,6 +58,15 @@ function initLegalForm({ endpoint, fields }) { for (const f of fields) payload[f] = String(form.elements[f]?.value ?? '').trim() if (form.elements.when?.value === 'datum' && !storageOnly()) payload.effective = dateInput?.value || '' if (form.elements.contract && !storageOnly()) delete payload.packages + // Neue Seite (data-anrede="du"): die Paketzahl wird hier geprüft, der Sie-Satz des Servers erreicht sie nicht + if (form.getAttribute('data-anrede') === 'du' && storageOnly()) { + const paketeFehler = legalFormPaketeFehler(payload.packages, Number(form.elements.packages?.max) || 10) + if (paketeFehler) { + errEl.textContent = paketeFehler + errEl.classList.remove('hidden') + return + } + } const btn = form.querySelector('button[type="submit"]') btn.disabled = true try { diff --git a/public/neu/oeffentlich/kuendigen.html b/public/neu/oeffentlich/kuendigen.html index 1e812d5..43b2c51 100644 --- a/public/neu/oeffentlich/kuendigen.html +++ b/public/neu/oeffentlich/kuendigen.html @@ -15,7 +15,7 @@
WEBKLAR

Verträge hier kündigen

-
+
Vertrag @@ -23,9 +23,9 @@
@@ -60,10 +60,10 @@ diff --git a/public/neu/oeffentlich/widerrufen.html b/public/neu/oeffentlich/widerrufen.html index 47a937a..5c3e31d 100644 --- a/public/neu/oeffentlich/widerrufen.html +++ b/public/neu/oeffentlich/widerrufen.html @@ -15,7 +15,7 @@
WEBKLAR

Vertrag widerrufen

-
+
Vertrag @@ -49,10 +49,10 @@ diff --git a/test/knAnsichtOeffentlich.test.js b/test/knAnsichtOeffentlich.test.js index 67ffed7..a16361e 100644 --- a/test/knAnsichtOeffentlich.test.js +++ b/test/knAnsichtOeffentlich.test.js @@ -10,7 +10,9 @@ const attribute = (html) => [...koerper(html).matchAll(/\s(name|id|value|type|au const skripte = (html) => [...html.matchAll(/]*>[\s\S]*?<\/script>/g)].map((m) => m[0].replace(/\s+/g, ' ')) for (const [alt, neu] of PAARE) { test(`${neu}: gleicher sichtbarer Text, gleiche Felder, gleiche Skripte wie ${alt}`, () => { - assert.equal(sichtbarerText(lese(neu)), sichtbarerText(lese(alt))); assert.deepEqual(attribute(lese(neu)), attribute(lese(alt))); assert.deepEqual(skripte(lese(neu)), skripte(lese(alt))) + // Kündigen und Widerrufen sprechen seit P7 du (Fassung 6, C14): ihr Text steht in test/knOeffentlichDu.test.js; Felder und Skripte werden weiter verglichen + if (!['kuendigen.html', 'widerrufen.html'].includes(alt)) assert.equal(sichtbarerText(lese(neu)), sichtbarerText(lese(alt))) + assert.deepEqual(attribute(lese(neu)), attribute(lese(alt))); assert.deepEqual(skripte(lese(neu)), skripte(lese(alt))) }) test(`${neu}: neuer Stil, hell, ohne Verläufe, Lila und Dunkelmodus`, () => { const n = lese(neu) diff --git a/test/knOeffentlichDu.test.js b/test/knOeffentlichDu.test.js new file mode 100644 index 0000000..6339b4b --- /dev/null +++ b/test/knOeffentlichDu.test.js @@ -0,0 +1,113 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initProjektWiderruf } from '../public/auftrag-widerruf.js' + +// Kündigen und Widerrufen in du (E15, C14): die neuen Seiten sprechen du, die alten Seiten und Skripte bleiben wie sie sind +const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8') +const sichtbar = (html) => html.replace(/||/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ') +const SIE = /\b(Sie|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres|Ihnen)\b/ + +const mit = (fn) => async () => { + const fx = installFakeDom() + try { + await fn(fx) + } finally { + fx.restore() + } +} + +test('Kündigen und Widerrufen (neu): kein Sie im sichtbaren Text, Attribut gesetzt; die alten Seiten bleiben', () => { + for (const n of ['kuendigen', 'widerrufen']) { + const neu = lese(`neu/oeffentlich/${n}.html`) + assert.doesNotMatch(sichtbar(neu), SIE, n) + assert.match(neu, /
]*data-anrede="du"/, n) + if (existsSync(new URL(`../public/${n}.html`, import.meta.url))) { // P10 löscht die alten Seiten, dann entfällt dieser Teil + assert.doesNotMatch(lese(`${n}.html`), /data-anrede/, `${n}: alte Seite unverändert`) + assert.match(sichtbar(lese(`${n}.html`)), SIE, `${n}: alte Seite bleibt Sie`) + } + } +}) + +test('Text-Pins der du-Fassung', () => { + const k = sichtbar(lese('neu/oeffentlich/kuendigen.html')) + const w = sichtbar(lese('neu/oeffentlich/widerrufen.html')) + for (const s of ['Zahl der Pakete, die du kündigst (je 5 GB; leer = alle gebuchten Pakete)', 'Das geht auch, wenn dein Abo schon gekündigt ist.', 'Deine Kündigung ist eingegangen', + 'Eine Eingangsbestätigung mit diesen Angaben haben wir an deine E-Mail-Adresse geschickt. Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir dir per E-Mail.', 'Jetzt kündigen']) assert.ok(k.includes(s), s) + for (const s of ['Dein Widerruf ist bei uns eingegangen', 'Eine Eingangsbestätigung mit diesen Angaben haben wir an deine E-Mail-Adresse geschickt.', 'Widerruf bestätigen']) assert.ok(w.includes(s), s) +}) + +test('Die neuen Seiten ändern nichts außer dem Text: Felder, Werte und Skripte gleich den alten', () => { + for (const n of ['kuendigen', 'widerrufen']) { + if (!existsSync(new URL(`../public/${n}.html`, import.meta.url))) continue + const attribute = (html) => [...html.slice(html.search(/ `${m[1]}=${m[2] ?? ''}`).sort() + assert.deepEqual(attribute(lese(`neu/oeffentlich/${n}.html`)), attribute(lese(`${n}.html`)), n) + const skripte = (html) => [...html.matchAll(/]*>[\s\S]*?<\/script>/g)].map((m) => m[0].replace(/\s+/g, ' ')) + assert.deepEqual(skripte(lese(`neu/oeffentlich/${n}.html`)), skripte(lese(`${n}.html`)), n) + } +}) + +test('legalFormPaketeFehler: leer und 1 bis max passen, alles andere bekommt den du-Satz', () => { + const { legalFormPaketeFehler } = new Function(`${lese('legal-form.js')}\nreturn { legalFormPaketeFehler }`)() + for (const ok of ['', ' ', '1', '10']) assert.equal(legalFormPaketeFehler(ok, 10), '', JSON.stringify(ok)) + for (const schlecht of ['0', '11', '-1', '2,5', 'abc']) assert.equal(legalFormPaketeFehler(schlecht, 10), 'Bitte gib 1 bis 10 Pakete an oder lass das Feld leer (alle Pakete).', schlecht) + assert.equal(legalFormPaketeFehler(undefined, 10), '') + assert.equal(legalFormPaketeFehler('5', 3), 'Bitte gib 1 bis 3 Pakete an oder lass das Feld leer (alle Pakete).') + assert.match(lese('legal-form.js'), /getAttribute\('data-anrede'\) === 'du'/) +}) + +/** Kündigungsformular mit Zusatzspeicher und den Skripten legal-form.js (klassisch) im Fake-DOM; antwortet der Server, steht es in fx.fetchLog. */ +async function legalForm(fx, attribute, packages) { + const speicher = h('input', { type: 'radio', name: 'contract', value: 'zusatzspeicher', checked: true }) + const abo = h('input', { type: 'radio', name: 'contract', value: 'abo' }) + const pakete = h('input', { id: 'packages', name: 'packages', type: 'number', min: '1', max: '10' }) + const name = h('input', { id: 'name', name: 'name' }) + const fehler = h('p', { id: 'form-error', class: 'hidden' }) + const knopf = h('button', { type: 'submit' }, 'Jetzt kündigen') + const form = h('form', { id: 'decl-form', ...attribute }, speicher, abo, h('div', { id: 'packages-wrap' }, pakete), name, fehler, knopf) + // fakeDom kennt form.elements nicht; das Skript liest darüber Vertrag, Paketzahl und Name + Object.defineProperty(form, 'elements', { value: { contract: { get value() { return speicher.checked ? 'zusatzspeicher' : 'abo' } }, packages: pakete, name } }) + pakete.max = '10' + fx.document.body.append(form, h('div', { id: 'done', class: 'hidden' }), h('span', { id: 'done-at' }), h('dl', { id: 'done-list' })) + new Function(`${lese('legal-form.js')}\ninitLegalForm({ endpoint: '/api/abo/public/cancel', fields: ['contract', 'packages', 'name'] })`)() + await fx.setze(pakete, packages) + await fx.setze(name, 'Anna') + await fx.klick(knopf) + await fx.ruhe() + return { fehler, knopf } +} + +test('legal-form.js: mit data-anrede="du" prüft die Seite die Paketzahl vor dem Absenden, ohne das Attribut unverändert', mit(async (fx) => { + fx.antworten.push(() => ({ status: 200, body: {} })) // GET /api/auth/me + const du = await legalForm(fx, { 'data-anrede': 'du' }, '11') + assert.equal(du.fehler.textContent, 'Bitte gib 1 bis 10 Pakete an oder lass das Feld leer (alle Pakete).') + assert.equal(du.fehler.classList.contains('hidden'), false) + assert.equal(du.knopf.hasAttribute('disabled'), false) + assert.ok(!fx.fetchLog.some((a) => a.url === '/api/abo/public/cancel'), 'es wurde nichts gesendet') +})) + +test('legal-form.js: ohne das Attribut geht die Eingabe wie heute an den Server', mit(async (fx) => { + fx.antworten.push(() => ({ status: 200, body: {} }), () => ({ status: 400, body: { error: 'Bitte geben Sie 1 bis 10 Pakete an oder lassen Sie das Feld leer (alle Pakete).' } })) + const alt = await legalForm(fx, {}, '11') + assert.ok(fx.fetchLog.some((a) => a.url === '/api/abo/public/cancel'), 'alter Weg: der Server prüft') + assert.equal(alt.fehler.textContent, 'Bitte geben Sie 1 bis 10 Pakete an oder lassen Sie das Feld leer (alle Pakete).') +})) + +test('auftrag-widerruf.js: du-Meldung bei data-anrede, sonst der alte Satz', mit(async (fx) => { + const DU = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfe die Nummer oder lass das Feld leer.' + const ALT = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.' + for (const [attribute, erwartet] of [[{ 'data-anrede': 'du' }, DU], [{}, ALT]]) { + const nummer = h('input', { id: 'abNummer', name: 'abNummer' }) + const fehler = h('p', { id: 'form-error', class: 'error hidden' }) + const knopf = h('button', { type: 'submit' }, 'Widerruf bestätigen') + const form = h('form', { id: 'decl-form', ...attribute }, h('label', {}, h('input', { type: 'radio', name: 'order', value: 'projekt', checked: true })), h('div', { id: 'ab-nummer-wrap' }, nummer), fehler, knopf) + fx.document.body.append(form) + initProjektWiderruf(form) + await fx.setze(nummer, 'irgendwas') + await fx.klick(knopf) + assert.equal(fehler.textContent, erwartet) + assert.equal(fehler.classList.contains('hidden'), false) + } +}))