import { test } from 'node:test' import assert from 'node:assert/strict' import { zeigeBestellen, produktAusRoute } from '../public/neu/ansicht-bestellen.js' import { mit, me, aboAntwort, angebot, neueWurzel } from './support/knDaten.js' import { NEU, TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js' // Bestellseite im schlanken Muster (Spec 5.6, Fassung 6): Block, Linkzeile, Häkchen nur privat, Knopf, Kundenart-Zeile const route = (produkt) => ({ bereich: 'vertraege', etage: 'bestellen', produkt, query: '' }) // fmtEuro setzt ein geschütztes Leerzeichen vor das Euro-Zeichen const eur = (text) => text.replaceAll(' €', '\u00a0€') const IDS = ['abo-block', 'abo-linkzeile', 'abo-beginn', 'abo-error', 'abo-buy', 'abo-kundenart', 'abo-einzelheiten'] /** Seite zeigen; die Titel, die die Ansicht meldet (Ereignis kn:titel), sammelt fx.titel. */ async function zeige(fx, daten, meDaten, produkt = 'wartung') { fx.titel = fx.titel ?? [] const w = neueWurzel(fx) w.addEventListener('kn:titel', (e) => fx.titel.push(e.detail.text)) await zeigeBestellen(w, { route: route(produkt), me: meDaten, nav: 'push', daten }) await fx.ruhe() return w } /** Die Ids aus IDS in Dokumentreihenfolge unterhalb von #abo-order. */ const reihe = (fx, w) => fx.alle(fx.finde(w, '#abo-order'), (el) => IDS.includes(el.id)).map((el) => el.id) test('Betrieb: Block, Linkzeile, Knopf, Kundenart-Zeile in dieser Reihenfolge, ohne Häkchen und ohne Klappe', mit({}, async (fx) => { const w = await zeige(fx, aboNeu(), meMit('unternehmen')) assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-error', 'abo-buy', 'abo-kundenart', 'abo-einzelheiten']) assert.equal(fx.finde(w, '#abo-buy').textContent, 'Zahlungspflichtig bestellen') assert.equal(fx.alle(w, 'details, dialog, input[type="radio"], input[type="checkbox"]').length, 0) const t = fx.text(fx.finde(w, '#abo-block')) for (const s of ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung', '50,00', 'gemäß § 19 UStG ohne Umsatzsteuer', 'heute 50,00']) assert.ok(t.includes(s), s) // die Zeilen „Anbieter“ und „Abrechnung“ entfallen (N37); das Wort steht nur im Satz zur Kündigung („Abrechnungsmonats“) assert.deepEqual(fx.alle(fx.finde(w, '#abo-block'), 'dt').map((e) => fx.text(e)), ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung']) assert.equal(fx.text(fx.finde(w, '#abo-start-summary')), 'sofort') assert.equal(fx.text(fx.finde(w, '#abo-kundenart')), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat') assert.equal(fx.finde(w, '#abo-linkzeile a').getAttribute('href'), TERMS_URL) assert.equal(fx.text(fx.finde(w, '#abo-linkzeile')), 'Es gelten die Vertragsbedingungen mit Leistungsbeschreibung, Auftragsverarbeitung und Widerrufsbelehrung (Fassung ' + NEU + '). Alles kommt danach auch als PDF per Mail. Datenschutz') })) test('Block: knappe Zeilentexte über dem Knopf, die ausführlichen Sätze unter dem Knopf (F1)', mit({}, async (fx) => { const w = await zeige(fx, aboNeu(), meMit('unternehmen')) const zeilen = fx.alle(fx.finde(w, '#abo-block'), '.kn-block-zeile').map((z) => [fx.text(fx.finde(z, 'dt')), fx.text(fx.finde(z, 'dd'))]) assert.deepEqual(zeilen.map(([l]) => l), ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung']) // Leistung: eine kurze Zeile mit Link auf die Leistungsbeschreibung B (Wartung), kein Aufzählungstext assert.deepEqual(zeilen[0], ['Leistung', 'Wartungs-Abo · Leistungen ansehen']) assert.deepEqual([fx.finde(fx.finde(w, '#abo-block'), 'a').getAttribute('href'), fx.finde(fx.finde(w, '#abo-block'), 'a').getAttribute('target')], [`${TERMS_URL}#wartung`, '_blank']) assert.deepEqual(zeilen[1], ['Preis', eur('50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)')]) assert.deepEqual(zeilen[2], ['Fällig', eur('heute 50,00 €')]) assert.deepEqual(zeilen[4], ['Laufzeit', 'Mindestlaufzeit 12 Monate, kündbar bis 1 Monat vor Ende; danach monatlich']) assert.deepEqual(zeilen[5], ['Zahlung', 'Karte oder SEPA über Stripe']) // jede Zeile höchstens 110 Zeichen: sonst passt die Kerninfo am Handy nicht mehr auf den Bildschirm for (const [l, t] of zeilen) assert.ok(t.length <= 110, `${l}: ${t.length} Zeichen`) // unter dem Knopf und der Kundenart-Zeile: die ausführlichen Sätze, ohne Klappe const ein = fx.finde(w, '#abo-einzelheiten') assert.equal(fx.alle(w, '#abo-order > *').map((e) => e.id).filter(Boolean).at(-1), 'abo-einzelheiten') const z2 = fx.alle(ein, '.kn-block-zeile').map((z) => [fx.text(fx.finde(z, 'dt')), fx.text(fx.finde(z, 'dd'))]) assert.deepEqual(z2.map(([l]) => l), ['Laufzeit', 'Zahlung', 'Kosten']) assert.equal(z2[0][1], 'Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats kündbar') assert.equal(z2[1][1], 'Kreditkarte oder SEPA-Lastschrift über Stripe; die Zahlungsart hinterlegst du im nächsten Schritt, bis zum Abschluss dort kannst du abbrechen') assert.equal(z2[2][1], 'keine weiteren Kosten') })) test('Titel: Produktname als Ereignis, keine eigene h1 (P2, P1 VF11)', mit({}, async (fx) => { const w = await zeige(fx, aboNeu(), meMit('unternehmen')) assert.deepEqual(fx.titel, ['Wartungs-Abo']) assert.equal(fx.alle(w, 'h1').length, 0) assert.deepEqual(fx.alle(w, 'h2').map((e) => fx.text(e)), ['Was du bestellst']) })) test('Betrieb: Body ohne earlyStart, lateStartAt, startChoice und Kundenart', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => { const w = await zeige(fx, aboNeu(), meMit('unternehmen')) await fx.klick(fx.finde(w, '#abo-buy')) await fx.ruhe() assert.deepEqual(fx.fetchLog.at(-1), { method: 'POST', url: '/api/abo/checkout', body: { productKey: 'wartung', acceptTerms: true, termsVersion: NEU, offerId: '' } }) assert.equal(fx.window.location.href, 'https://checkout.stripe.test/x') })) test('Privat und leer: ein Häkchen aus dem Wortlaut, ungehakt nach der Frist, gehakt sofort', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => { for (const m of [meMit('privat'), meMit('')]) { const w = await zeige(fx, aboNeu(), m) assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-beginn', 'abo-error', 'abo-buy', 'abo-kundenart', 'abo-einzelheiten']) assert.equal(fx.finde(w, '#abo-beginn').checked, false) assert.equal(fx.text(fx.finde(w, '#abo-beginn-text')), aboNeu().bestelltexte.abo.haken) assert.ok(fx.text(fx.finde(w, '#abo-start-summary')).startsWith('nach Ablauf der 14-tägigen Widerrufsfrist (am ')) assert.match(fx.text(fx.finde(w, '#abo-due-summary')), /^erst am \d{2}\.\d{2}\.\d{4} \(50,00\u00a0€\)$/) assert.ok(fx.text(fx.finde(w, '#abo-kundenart')).startsWith('Du bestellst privat · ')) await fx.klick(fx.finde(w, '#abo-buy')) await fx.ruhe() assert.deepEqual(fx.fetchLog.at(-1).body, { productKey: 'wartung', acceptTerms: true, earlyStart: false, termsVersion: NEU, offerId: '', lateStartAt: aboNeu().lateStartAt }) const v = await zeige(fx, aboNeu(), m) await fx.klick(fx.finde(v, '#abo-beginn')) assert.ok(fx.text(fx.finde(v, '#abo-start-summary')).startsWith('sofort, auf deinen ausdrücklichen Wunsch')) assert.ok(fx.text(fx.finde(v, '#abo-due-summary')).startsWith('heute ')) await fx.klick(fx.finde(v, '#abo-buy')) await fx.ruhe() assert.equal(fx.fetchLog.at(-1).body.earlyStart, true) await fx.klick(fx.finde(v, '#abo-beginn')) // Häkchen wieder weg: die Zeilen folgen assert.ok(fx.text(fx.finde(v, '#abo-start-summary')).startsWith('nach Ablauf der 14-tägigen Widerrufsfrist')) } })) test('M6: zurück von der Zahlungsseite (Wischgeste, Bfcache) ist der Knopf wieder bedienbar; ein später entfernter Knopf bekommt nichts mehr', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => { const w = await zeige(fx, aboNeu(), meMit('privat')) const knopf = fx.finde(w, '#abo-buy') await fx.klick(knopf); await fx.ruhe() assert.deepEqual([knopf.hasAttribute('disabled'), knopf.textContent], [true, 'Weiter zur Zahlung…']) fx.window.dispatchEvent({ type: 'pageshow', persisted: false }); assert.equal(knopf.hasAttribute('disabled'), true, 'ein normales Laden (nicht persisted) ändert nichts') fx.window.dispatchEvent({ type: 'pageshow', persisted: true }) assert.deepEqual([knopf.hasAttribute('disabled'), knopf.textContent], [false, 'Zahlungspflichtig bestellen']) // der Knopf kann wieder bestellen await fx.klick(knopf); await fx.ruhe(); assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo/checkout').length, 2) // die Seite wurde ersetzt: der Hörer entfernt sich und fasst den alten Knopf nicht mehr an w.replaceChildren(); knopf.disabled = true fx.window.dispatchEvent({ type: 'pageshow', persisted: true }); assert.equal(knopf.hasAttribute('disabled'), true) })) test('Der Body enthält nie kundenart oder startChoice', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => { for (const m of [meMit('unternehmen'), meMit('privat'), meMit('')]) { const w = await zeige(fx, aboNeu(), m) await fx.klick(fx.finde(w, '#abo-buy')) await fx.ruhe() assert.deepEqual(Object.keys(fx.fetchLog.at(-1).body).filter((k) => /kundenart|startChoice|unternehmer/i.test(k)), []) } })) test('Angebot: Angebotsnummer im Body, Text in Abschnitt und Preiszeile', mit({}, async (fx) => { const d = aboNeu({ offers: { hosting: null, wartung: angebot() } }) const w = await zeige(fx, d, meMit('unternehmen')) assert.ok(fx.text(fx.finde(w, '#abo-angebot')).includes(angebot().text)) assert.ok(fx.text(fx.finde(w, '#abo-block')).includes(`individuelles Angebot: ${angebot().text}`)) assert.ok(fx.text(fx.finde(w, '#abo-block')).includes(eur('25,00 € pro Monat für die ersten 2 Monate, danach 50,00 € pro Monat'))) assert.ok(fx.text(fx.finde(w, '#abo-due-summary')).startsWith('heute 25,00')) await fx.klick(fx.finde(w, '#abo-buy')) await fx.ruhe() assert.equal(fx.fetchLog.at(-1).body.offerId, angebot().id) })) test('Ohne Wortlaut, nicht verfügbar oder unbekanntes Produkt: nur der Satz, kein Knopf; unsichere Linkadressen werden Text', mit({}, async (fx) => { const satz = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.' for (const d of [aboAntwort({ termsVersion: NEU }), aboNeu({ available: false })]) { const w = await zeige(fx, d, meMit('unternehmen')) assert.ok(fx.text(w).includes(satz)) assert.equal(fx.finde(w, '#abo-buy'), null) } assert.equal(fx.finde(await zeige(fx, aboNeu(), meMit('unternehmen'), 'gibtsnicht'), '#abo-buy'), null) const boese = aboNeu() boese.bestelltexte.abo.linkzeile.urls = ['javascript:alert(1)', '//evil.example'] assert.equal(fx.alle(await zeige(fx, boese, meMit('unternehmen')), '#abo-linkzeile a[href]').length, 0) })) test('produktAusRoute', () => { assert.equal(produktAusRoute({ produkt: 'wartung' }, ''), 'wartung') assert.equal(produktAusRoute({}, '#bestellen-hosting'), 'hosting') assert.equal(produktAusRoute({}, '#abo'), '') }) test('Fehler der Zahlungsseite: Text in #abo-error, Knopf zurück; unsichere Zahlungsadresse leitet nicht weiter', mit({ 'POST /api/abo/checkout': () => ({ status: 400, body: { error: 'Kaputt.' } }) }, async (fx) => { const w = await zeige(fx, aboNeu(), meMit('unternehmen')) await fx.klick(fx.finde(w, '#abo-buy')) await fx.ruhe() assert.equal(fx.text(fx.finde(w, '#abo-error')), 'Kaputt.') assert.equal(fx.finde(w, '#abo-error').hasAttribute('hidden'), false) assert.equal(fx.finde(w, '#abo-buy').hasAttribute('disabled'), false) assert.equal(fx.finde(w, '#abo-buy').textContent, 'Zahlungspflichtig bestellen') fx.tabelle['POST /api/abo/checkout'] = () => ({ body: { url: 'javascript:alert(1)' } }) await fx.klick(fx.finde(w, '#abo-buy')) await fx.ruhe() assert.notEqual(fx.window.location.href, 'javascript:alert(1)') assert.equal(fx.text(fx.finde(w, '#abo-error')), 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.') assert.equal(fx.finde(w, '#abo-buy').hasAttribute('disabled'), false) })) test('Ansichtsmodus sperrt Knopf und Häkchen, Testkonto-Hinweis in du', mit({}, async (fx) => { const v = await zeige(fx, aboNeu({ testAccount: true }), meMit('privat', { viewAs: true })) assert.equal(fx.finde(v, '#abo-buy').hasAttribute('disabled'), true) assert.equal(fx.finde(v, '#abo-beginn').hasAttribute('disabled'), true) assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./) assert.match(fx.text(v), /Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat\./) // Literal im Quelltext mit normalem Leerzeichen await fx.klick(fx.finde(v, '#abo-buy')) assert.equal(fx.fetchLog.length, 0) })) test('Kein Abo geladen: nur der Hinweis', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => { const v = await zeige(fx, undefined, me()) assert.match(fx.text(v), /Deine Verträge konnten gerade nicht geladen werden\./) })) test('Quelltext: kein Sie, keine Klappe, kein startChoice', async () => { const { readFileSync } = await import('node:fs') const q = readFileSync(new URL('../public/neu/ansicht-bestellen.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/) const aufruf = q.slice(q.indexOf("api('/api/abo/checkout'"), q.indexOf('const ziel')) assert.ok(aufruf.includes('acceptTerms: true'), 'der Aufruf der Bestellung ist gefunden') assert.doesNotMatch(aufruf, /startChoice|kundenart|unternehmer/) })