import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { zeigeWechsel, gemerkteFunktion } from '../public/neu/ansicht-wechsel.js' import { nimmHinweis } from '../public/neu/ansicht-hilfen.js' import { mit, me, abo, aboAntwort, neueWurzel, VORSCHAU } from './support/knDaten.js' import { NEU, TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js' // Wechselseite im schlanken Muster (Spec 5.6, Fassung 6): Block, Linkzeile, Häkchen nur privat mit Sperrzeile, Knopf, Kundenart-Zeile const HOSTING = (o = {}) => aboNeu({ subscription: abo({ produkt: 'hosting', canSwitch: true, storage: { units: 2, pendingSetup: false, pendingReduction: null } }), ...o }) const SRV = { 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'https://pay.stripe.test/x', subscription: abo() } }) } const IDS = ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart', 'switch-einzelheiten'] const reihe = (fx, w) => fx.alle(fx.finde(w, '#switch-order'), (el) => IDS.includes(el.id)).map((el) => el.id) const ROUTE = { bereich: 'vertraege', etage: 'wechsel', query: '' } /** Seite zeigen; die gemeldeten Titel (kn:titel) sammelt fx.titel. */ async function zeige(fx, daten, meDaten) { fx.titel = fx.titel ?? [] const w = neueWurzel(fx) w.addEventListener('kn:titel', (e) => fx.titel.push(e.detail.text)) await zeigeWechsel(w, { route: ROUTE, me: meDaten, nav: 'push', daten }) await fx.ruhe() return w } test('Betrieb: Block, Linkzeile, Knopf, kein Häkchen, Knopf nach der Vorschau frei, Body ohne earlyStart', mit(SRV, async (fx) => { const w = await zeige(fx, HOSTING(), meMit('unternehmen')) assert.deepEqual(fx.titel, ['Wechsel ins Wartungs-Abo']) assert.equal(fx.alle(w, 'h1').length, 0) // Titel kommt als Ereignis, keine eigene h1 assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-error', 'switch-buy', 'switch-kundenart', 'switch-einzelheiten']) // Block über dem Knopf: knapp (F1); Leistung als eine Zeile mit Link auf die Leistungsbeschreibung B (Wartung) const zeilen = fx.alle(fx.finde(w, '#switch-block'), '.kn-block-zeile').map((z) => [fx.text(fx.finde(z, 'dt')), fx.text(fx.finde(z, 'dd'))]) // Zusatzspeicher steckt im Preis (ein Block weniger über dem Knopf), die gutgeschriebenen Tage in „Fällig“ (F1: auch mit gebuchtem Speicher auf 375 x 667) assert.deepEqual(zeilen.map(([l]) => l), ['Leistung', 'Preis', 'Fällig', 'Laufzeit', 'Beginn']) assert.deepEqual(zeilen[0], ['Leistung', 'Wartungs-Abo statt Hosting-Abo · Leistungen ansehen']) assert.equal(fx.finde(fx.finde(w, '#switch-block'), 'a').getAttribute('href'), `${TERMS_URL}#wartung`) assert.ok(zeilen[1][1].includes('mit 2 Paketen Zusatzspeicher (+10 GB)') && zeilen[1][1].includes('gemäß § 19 UStG ohne Umsatzsteuer'), zeilen[1][1]) assert.ok(zeilen[2][1].startsWith('heute 30,00') && zeilen[2][1].includes('Gutschrift von 15,00') && zeilen[2][1].includes('ab 16.11.2026'), zeilen[2][1]) assert.equal(zeilen[3][1], 'Mindestlaufzeit bis 16.10.2027 (12 Monate), danach monatlich kündbar') assert.ok(zeilen[4][1].startsWith('sofort mit der Zahlung')) for (const [l, text] of zeilen) assert.ok(text.length <= 140, `${l}: ${text.length} Zeichen`) // unter dem Knopf: die ausführlichen Sätze, ohne Klappe assert.equal(fx.alle(w, '#switch-order > *').map((e) => e.id).filter(Boolean).at(-1), 'switch-einzelheiten') const ein = fx.text(fx.finde(w, '#switch-einzelheiten')) for (const x of ['Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen', 'Zusatzspeicher', '2 Pakete (+10 GB) bleiben gebucht', 'Gutschrift für nicht genutzte Tage', '−15,00', 'Neuer Abbuchungstag', 'nächste Abbuchung am', 'Heute fällig', 'bei deiner Bank', 'Einen späteren Beginn gibt es nicht', '12 Monate ab dem Wechsel']) assert.ok(ein.includes(x), x) assert.equal(fx.finde(w, '#switch-buy').textContent, 'Zahlungspflichtig bestellen') assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), false) assert.equal(fx.alle(w, 'details, dialog, input[type="checkbox"]').length, 0) assert.equal(fx.text(fx.finde(w, '#switch-kundenart')), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat') await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, termsVersion: NEU, prorationDate: VORSCHAU.prorationDate, amountDueCents: VORSCHAU.amountDueCents }) assert.equal(fx.window.location.href, 'https://pay.stripe.test/x') assert.match(fx.text(w), /Dein Wechsel wartet auf die Zahlung\. Wir leiten dich zur Zahlung weiter\./) })) // E-Fix F1 (Nachkorrektur): nach „mit“ steht der Dativ Plural; „Paket“ im Singular bleibt, „Pakete“ in „… bleiben gebucht“ ist ein Nominativ test('Preiszeile: „mit 1 Paket“ und „mit 2 Paketen“ Zusatzspeicher (Dativ), die Einzelheiten nennen „1 Paket“ bzw. „2 Pakete“', mit(SRV, async (fx) => { for (const [einheiten, mitText, einzelText] of [[1, 'mit 1 Paket Zusatzspeicher (+5 GB)', '1 Paket (+5 GB) bleiben gebucht'], [2, 'mit 2 Paketen Zusatzspeicher (+10 GB)', '2 Pakete (+10 GB) bleiben gebucht']]) { const daten = HOSTING({ subscription: abo({ produkt: 'hosting', canSwitch: true, storage: { units: einheiten, pendingSetup: false, pendingReduction: null } }) }) const w = await zeige(fx, daten, meMit('unternehmen')) const preis = fx.text(fx.alle(fx.finde(w, '#switch-block'), '.kn-block-zeile')[1]) assert.ok(preis.includes(mitText), preis) assert.ok(!/mit \d+ Pakete /.test(preis), preis) // nie „mit 2 Pakete Zusatzspeicher“ assert.ok(fx.text(fx.finde(w, '#switch-einzelheiten')).includes(einzelText), einzelText) } })) test('Privat und leer: Knopf gesperrt mit Sperrzeile, nach dem Häkchen frei, Body mit earlyStart true', mit(SRV, async (fx) => { for (const m of [meMit('privat'), meMit('')]) { const w = await zeige(fx, HOSTING(), m) assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart', 'switch-einzelheiten']) assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true) assert.equal(fx.text(fx.finde(w, '#switch-sperre')), HOSTING().bestelltexte.wechsel.gesperrt) assert.equal(fx.text(fx.finde(w, '#switch-beginn-text')), HOSTING().bestelltexte.wechsel.haken) assert.equal(fx.finde(w, '#switch-beginn').checked, false) await fx.klick(fx.finde(w, '#switch-beginn')) assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), false) assert.equal(fx.finde(w, '#switch-sperre'), null) await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.deepEqual(fx.fetchLog.findLast((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, earlyStart: true, termsVersion: NEU, prorationDate: VORSCHAU.prorationDate, amountDueCents: VORSCHAU.amountDueCents }) } })) test('Das Häkchen bleibt beim Neuzeichnen erhalten und lässt sich wieder abwählen', mit(SRV, async (fx) => { const w = await zeige(fx, HOSTING(), meMit('privat')) await fx.klick(fx.finde(w, '#switch-beginn')) assert.equal(fx.finde(w, '#switch-beginn').checked, true) await fx.klick(fx.finde(w, '#switch-beginn')) assert.equal(fx.finde(w, '#switch-beginn').checked, false) assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true) assert.ok(fx.finde(w, '#switch-sperre')) })) test('Der Body enthält nie kundenart, unternehmer oder startChoice', mit(SRV, async (fx) => { for (const m of [meMit('unternehmen'), meMit('privat')]) { const w = await zeige(fx, HOSTING(), m) if (fx.finde(w, '#switch-beginn')) await fx.klick(fx.finde(w, '#switch-beginn')) await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.deepEqual(Object.keys(fx.fetchLog.findLast((a) => a.url === '/api/abo/switch').body).filter((k) => /kundenart|startChoice|unternehmer/i.test(k)), []) } })) test('Wechsel nicht möglich: Satz und Rückweg zu Verträgen', mit({}, async (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` } const w = await zeige(fx, aboNeu({ subscription: abo({ produkt: 'wartung' }) }), meMit('privat')) assert.match(fx.text(w), /Ein Wechsel ist derzeit nicht möglich\. Details findest du unter Verträge\./) assert.equal(fx.window.location.hash, '#vertraege') })) test('Ohne Wortlaut oder ohne Sätze des Wechsels: nur ein Satz, kein Knopf, keine Vorschau-Abfrage', mit(SRV, async (fx) => { const ohneWechsel = HOSTING() delete ohneWechsel.bestelltexte.wechsel for (const d of [aboAntwort({ termsVersion: NEU, subscription: abo({ produkt: 'hosting', canSwitch: true }) }), ohneWechsel]) { const w = await zeige(fx, d, meMit('unternehmen')) assert.match(fx.text(w), /Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat\./) assert.equal(fx.finde(w, '#switch-buy'), null) } assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo/switch/preview').length, 0) })) test('Unsichere Linkadressen werden Text', mit(SRV, async (fx) => { const boese = HOSTING() boese.bestelltexte.wechsel.linkzeile.urls = ['javascript:alert(1)', '//evil.example'] assert.equal(fx.alle(await zeige(fx, boese, meMit('unternehmen')), '#switch-linkzeile a[href]').length, 0) })) test('Prüfungen: Body mit gemerkter Funktion, Schlüssel danach weg', mit(SRV, async (fx) => { const speicher = new Map() globalThis.sessionStorage = { getItem: (k) => speicher.get(k) ?? null, setItem: (k, v) => speicher.set(k, v), removeItem: (k) => speicher.delete(k) } try { const w = await zeige(fx, HOSTING(), meMit('privat')) await fx.klick(fx.finde(w, '#switch-beginn')) speicher.set('kn-wechsel-funktion', JSON.stringify({ key: 'termine', projectId: 'p1' })) await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, earlyStart: true, termsVersion: NEU, prorationDate: 1760000000, amountDueCents: 3000, funktion: { key: 'termine', projectId: 'p1' } }) assert.equal(speicher.has('kn-wechsel-funktion'), false) assert.equal(fx.window.location.href, 'https://pay.stripe.test/x') } finally { delete globalThis.sessionStorage } })) test('gemerkteFunktion: kaputte oder fremde Werte ergeben null', () => { const wert = (v) => { globalThis.sessionStorage = { getItem: () => v }; try { return gemerkteFunktion() } finally { delete globalThis.sessionStorage } } assert.deepEqual(wert('{"key":"shop","projectId":"p2"}'), { key: 'shop', projectId: 'p2' }) for (const v of ['kaputt', '{"key":"shop"}', '{"key":1,"projectId":"p"}', null]) assert.equal(wert(v), null) assert.equal(gemerkteFunktion(), null) // ohne sessionStorage }) const FUNKTION = { key: 'termine', projectId: 'p1' } const WOHIN = (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` }; fx.window.history.pushState = fx.window.history.replaceState } for (const [name, gemerkt, antwort, nachgeladen, ziel] of [ ['gemerkt, Funktion steht in der Antwort', true, { wechselFunktion: FUNKTION }, undefined, '#funktionen'], ['gemerkt, Funktion erst im GET /api/abo', true, {}, { wechselFunktion: FUNKTION }, '#funktionen'], ['gemerkt, Wechsel noch nicht erledigt', true, {}, { wechselFunktion: null }, '#vertraege'], ['nicht gemerkt, der Server meldet trotzdem eine Funktion', false, { wechselFunktion: FUNKTION }, { wechselFunktion: FUNKTION }, '#vertraege'], ]) { test(`Wechsel ohne Zahlungsseite (${name}): Ziel ${ziel} (E14, C5)`, mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { subscription: abo(antwort) } }), 'GET /api/abo': aboNeu({ subscription: abo({ produkt: 'wartung', ...nachgeladen }) }), }, async (fx) => { WOHIN(fx) const speicher = new Map(gemerkt ? [['kn-wechsel-funktion', JSON.stringify(FUNKTION)]] : []) globalThis.sessionStorage = { getItem: (k) => speicher.get(k) ?? null, setItem: (k, v) => speicher.set(k, v), removeItem: (k) => speicher.delete(k) } try { nimmHinweis() const w = await zeige(fx, HOSTING(), meMit('unternehmen')) await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.equal(fx.window.location.hash, ziel) assert.equal(speicher.has('kn-wechsel-funktion'), false) assert.equal(nimmHinweis() !== '', ziel === '#vertraege') // bei #funktionen bleibt kein Hinweis für später liegen assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo').length, gemerkt && !antwort.wechselFunktion ? 1 : 0) // höchstens ein Nachladen, nur mit gemerkter Funktion } finally { delete globalThis.sessionStorage } })) } test('Erfolgstexte in du: SEPA und Karte', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { subscription: abo({ switchPending: { type: 'wechsel' } }) } }) }, async (fx) => { WOHIN(fx) nimmHinweis() const w = await zeige(fx, HOSTING(), meMit('unternehmen')) await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.equal(nimmHinweis(), 'Dein Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen dir den Wechsel per E-Mail.') fx.tabelle['POST /api/abo/switch'] = () => ({ body: { subscription: abo() } }) const v = await zeige(fx, HOSTING(), meMit('unternehmen')) await fx.klick(fx.finde(v, '#switch-buy')) await fx.ruhe() assert.equal(nimmHinweis(), 'Dein Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.') })) test('409 lädt die Vorschau neu und behält die Meldung; Ansichtsmodus sperrt Knopf und Häkchen', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ status: 409, body: { error: 'Betrag hat sich geändert.' } }) }, async (fx) => { const w = await zeige(fx, HOSTING(), meMit('privat')) await fx.klick(fx.finde(w, '#switch-beginn')) await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.match(fx.text(w), /Betrag hat sich geändert\./) assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo/switch/preview').length, 2) assert.equal(fx.finde(w, '#switch-beginn').checked, true, 'das Häkchen bleibt nach dem Neuzeichnen') const v = await zeige(fx, HOSTING(), meMit('privat', { viewAs: true })) assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), true) assert.equal(fx.finde(v, '#switch-beginn').hasAttribute('disabled'), true) assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./) })) test('Vorschau-Fehler, hängende Bestellung nach pageshow; im Ansichtsmodus geht kein POST raus', mit({ 'POST /api/abo/switch/preview': () => ({ status: 500, body: { error: 'Vorschau kaputt.' } }) }, async (fx) => { const w = await zeige(fx, HOSTING(), meMit('unternehmen')) assert.match(fx.text(w), /Vorschau kaputt\./) assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true) fx.tabelle['POST /api/abo/switch/preview'] = VORSCHAU fx.tabelle['POST /api/abo/switch'] = { paymentUrl: 'https://pay.stripe.test/y', subscription: abo() } const v = await zeige(fx, HOSTING(), meMit('unternehmen')) await fx.klick(fx.finde(v, '#switch-buy')) await fx.ruhe() assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), true) // wartet auf die Zahlung fx.window.dispatchEvent({ type: 'pageshow', persisted: true }) await fx.ruhe() assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), false) assert.equal(fx.finde(v, '#switch-buy').textContent, 'Zahlungspflichtig bestellen') const vorher = fx.fetchLog.length const a = await zeige(fx, HOSTING(), meMit('unternehmen', { viewAs: true })) await fx.klick(fx.finde(a, '#switch-buy')) assert.deepEqual(fx.fetchLog.slice(vorher).filter((x) => x.url === '/api/abo/switch'), []) })) test('Unsichere Zahlungsadresse wird nicht geöffnet und nicht als Erfolg gezeigt', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'javascript:alert(1)', subscription: abo() } }) }, async (fx) => { nimmHinweis() const w = await zeige(fx, HOSTING(), meMit('unternehmen')) await fx.klick(fx.finde(w, '#switch-buy')) await fx.ruhe() assert.notEqual(fx.window.location.href, 'javascript:alert(1)') assert.match(fx.text(w), /Die Zahlungsseite konnte nicht geöffnet werden\./) assert.equal(nimmHinweis(), '') })) test('Quelltext: kein Sie, keine Klappe, alte Ids weg, Body ohne kundenart', () => { const q = readFileSync(new URL('../public/neu/ansicht-wechsel.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') assert.doesNotMatch(q, /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/) assert.doesNotMatch(q, /details|dialog|innerHTML|switch-terms|switch-early/) const aufruf = q.slice(q.indexOf('const body = {'), q.indexOf("api('/api/abo/switch'")) assert.ok(aufruf.includes('acceptTerms: true'), 'der Body der Bestellung ist gefunden') assert.doesNotMatch(aufruf, /startChoice|kundenart|unternehmer/) })