import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { createHash } from 'node:crypto' import { zeigeGutschrift, sammleDateien, IO } from '../public/neu/ansicht-gutschrift.js' import { klartext } from '../server/services/bestelltexte.js' import { mit, me, abo, aboAntwort, anfrageGutschrift, neueWurzel, LIMITS } from './support/knDaten.js' import { NEU, meMit, aboNeu } from './support/knBestellDaten.js' // Gutschrift-Antrag im schlanken Muster (Spec 5.6, Fassung 6): Satz mit Links über dem Knopf, keine Klappe, kein Häkchen; // die Bedingungen sind eine eigene Ansicht (#gutschrift?bedingungen=1), der Rechtstext bleibt unverändert const ABO = aboNeu({ subscription: abo() }) const ANTRAEGE = { requests: [anfrageGutschrift({ id: 't1', status: 'rueckfrage', question: 'Bitte Kündigungsbestätigung hochladen' }), anfrageGutschrift({ id: 't2', status: 'genehmigt', amountCents: 4000, decisionNote: 'Passt.' }), anfrageGutschrift({ id: 't3', status: 'abgelehnt', decisionNote: 'Nicht erstattbar.' })], conditionsVersion: '2026-09-30', limits: LIMITS } const SRV = { 'GET /api/abo': ABO, 'GET /api/abo/takeover-credits': ANTRAEGE } const zeigeNeu = async (fx, m = meMit('unternehmen'), query = '') => { const w = neueWurzel(fx) await zeigeGutschrift(w, { route: { bereich: 'vertraege', etage: 'gutschrift', query }, me: m, nav: 'push' }) await fx.ruhe() return w } async function fuelleAntrag(fx, w) { await fx.klick(fx.finde(w, 'input[value="hosting"]')) await fx.setze(fx.finde(w, '#tk-provider'), 'IONOS') await fx.setze(fx.finde(w, '#tk-amount'), '119.88') await fx.setze(fx.finde(w, '#tk-months'), '12') await fx.setze(fx.finde(w, '#tk-paid-until'), '2027-03-31') await fx.setze(fx.finde(w, '#tk-message'), 'Der Vertrag dort läuft noch bis Ende März.') fx.finde(w, '#tk-files').files = [{ name: 'r.pdf', size: 10 }] } test('Formular: Satz vor dem Knopf, keine Klappe, kein Häkchen, Links wie im Wortlaut', mit(SRV, async (fx) => { const w = await zeigeNeu(fx) await fx.klick(fx.finde(w, '#tk-start')) assert.ok(fx.finde(w, '#tk-consent') === null, 'kein Häkchen für die Zustimmung') assert.deepEqual([fx.alle(w, 'details').length, fx.finde(w, '#tk-submit').textContent], [0, 'Antrag absenden']) const ids = fx.alle(w, '[id]').map((e) => e.id) assert.ok(ids.indexOf('tk-satz') > -1 && ids.indexOf('tk-satz') < ids.indexOf('tk-submit')) assert.equal(fx.text(fx.finde(w, '#tk-satz')), klartext(ABO.bestelltexte.gutschrift.satz.text)) const [bed, ds] = fx.alle(w, '#tk-satz a') assert.deepEqual([bed.textContent, bed.getAttribute('href'), bed.getAttribute('target'), ds.getAttribute('href'), ds.getAttribute('target')], ['Bedingungen der Übernahmegutschrift', '#gutschrift?bedingungen=1', '_blank', '/datenschutz.html#uebernahmegutschrift', '_blank']) assert.equal(fx.alle(w, (e) => e.tagName === 'INPUT' && e.getAttribute('type') === 'checkbox' && e.getAttribute('name') !== 'services').length, 0, 'nur die Leistungen sind Häkchen') })) test('Absenden sendet consent und die Fassung, nie eine Kundenart', mit({ ...SRV, 'POST /api/abo/takeover-credits': { request: ANTRAEGE.requests[0] } }, async (fx) => { IO.lies = async () => 'UERGLTEuNA==' const w = await zeigeNeu(fx) await fx.klick(fx.finde(w, '#tk-start')) await fuelleAntrag(fx, w) await fx.klick(fx.finde(w, '#tk-submit')) await fx.ruhe() const b = fx.fetchLog.find((a) => a.method === 'POST' && a.url === '/api/abo/takeover-credits').body assert.deepEqual([b.consent, b.termsVersion, 'kundenart' in b, 'unternehmer' in b], [true, NEU, false, false]) assert.deepEqual({ ...b, files: undefined }, { services: ['hosting'], provider: 'IONOS', domain: '', invoiceAmount: '119.88', billingMonths: 12, paidUntil: '2027-03-31', message: 'Der Vertrag dort läuft noch bis Ende März.', email: 'anna@example.com', consent: true, termsVersion: NEU, files: undefined }) assert.deepEqual(b.files, [{ name: 'r.pdf', data: 'UERGLTEuNA==' }]) assert.match(fx.text(w), /Vielen Dank! Dein Antrag ist eingegangen\. Die Eingangsbestätigung haben wir an anna@example\.com geschickt; unsere Antwort kommt ebenfalls per E-Mail\./) })) test('Bedingungen-Ansicht: Titel als Ereignis, fünf Sätze aus dem Block, Rückweg, kein Formular', mit(SRV, async (fx) => { const w = neueWurzel(fx) const titel = [] w.addEventListener('kn:titel', (e) => titel.push(e.detail.text)) await zeigeGutschrift(w, { route: { bereich: 'vertraege', etage: 'gutschrift', query: 'bedingungen=1' }, me: meMit('unternehmen'), nav: 'push' }) await fx.ruhe() assert.deepEqual(titel, ['Bedingungen der Übernahmegutschrift']) assert.equal(fx.alle(w, 'h1').length, 0) assert.equal(fx.alle(w, 'ol > li').length, 5) assert.equal(fx.finde(w, 'ol > li').textContent, 'Die Übernahmegutschrift gibt es für Leistungen, die Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfächer) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.') assert.equal(fx.finde(w, 'a[href="#gutschrift"]').textContent, 'Zurück zum Antrag') assert.ok(fx.finde(w, '#tk-form') === null) assert.equal(fx.fetchLog.length, 0, 'die Bedingungen brauchen keinen Abruf') })) test('Andere Abfragen zeigen den Antrag, nicht die Bedingungen', mit(SRV, async (fx) => { for (const query of ['', 'bedingungen=0', 'x=1']) { const w = await zeigeNeu(fx, meMit('unternehmen'), query) assert.ok(fx.finde(w, '#tk-start'), query) assert.equal(fx.alle(w, 'ol > li').length, 0, query) } })) test('Bedingungen der Gutschrift: Block gleich dem Pin in legalTexts.test.js (Fassung 2026-09-30)', () => { const q = readFileSync(new URL('../public/neu/ansicht-gutschrift.js', import.meta.url), 'utf8') const i = q.indexOf('const TAKEOVER_CONDITIONS') const block = q.slice(i, q.indexOf(']', i)) assert.equal(createHash('sha256').update(block).digest('hex'), '2b7a745cb599c0ab28f20615a745bd86da6faa8d120391ca56beac35a0473e62') }) test('sammleDateien: Prüftexte in du', async () => { const g = { maxFiles: 3, maxFileBytes: 8 * 1048576, maxTotalBytes: 10 * 1048576 } const f = (name, size) => ({ name, size }) const lies = async () => 'QQ==' await assert.rejects(sammleDateien([], g, { erforderlich: true }, lies), { message: 'Bitte lade die Rechnung deines bisherigen Anbieters hoch.' }) await assert.rejects(sammleDateien([1, 2, 3, 4].map((n) => f(`${n}.pdf`, 1)), g, { erforderlich: false }, lies), { message: 'Bitte höchstens 3 Dateien auf einmal hochladen.' }) await assert.rejects(sammleDateien([f('groß.pdf', 9 * 1048576)], g, { erforderlich: false }, lies), { message: '„groß.pdf“ ist größer als 8 MB.' }) await assert.rejects(sammleDateien([f('a.pdf', 6 * 1048576), f('b.pdf', 6 * 1048576)], g, { erforderlich: false }, lies), { message: 'Die Dateien sind zusammen größer als 10 MB.' }) assert.deepEqual(await sammleDateien([f('a.pdf', 5)], g, { erforderlich: true }, lies), [{ name: 'a.pdf', data: 'QQ==' }]) }) test('Antrag: Prüfungen in du, Meldung nach Erfolg', mit({ ...SRV, 'POST /api/abo/takeover-credits': {} }, async (fx) => { IO.lies = async () => 'QQ==' const w = await zeigeNeu(fx) await fx.klick(fx.finde(w, (e) => e.textContent === 'Gutschrift beantragen')) await fx.klick(fx.finde(w, '#tk-submit')) assert.match(fx.text(w), /Bitte wähle, welche Leistung du bisher woanders bezahlt hast\./) await fx.klick(fx.finde(w, 'input[value="domain"]')) await fx.klick(fx.finde(w, '#tk-submit')) assert.match(fx.text(w), /Bitte nenne deinen bisherigen Anbieter\./) await fx.setze(fx.finde(w, '#tk-provider'), 'IONOS') await fx.klick(fx.finde(w, '#tk-submit')) assert.match(fx.text(w), /Bitte gib Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an\./) await fx.setze(fx.finde(w, '#tk-amount'), '119.88') await fx.setze(fx.finde(w, '#tk-months'), '12') await fx.setze(fx.finde(w, '#tk-paid-until'), '2027-03-31') await fx.klick(fx.finde(w, '#tk-submit')) await fx.ruhe() assert.match(fx.text(w), /Bitte lade die Rechnung deines bisherigen Anbieters hoch\./) assert.ok(!fx.fetchLog.some((a) => a.method === 'POST')) fx.finde(w, '#tk-files').files = [{ name: 'r.pdf', size: 10 }] await fx.klick(fx.finde(w, '#tk-submit')) await fx.ruhe() assert.match(fx.text(w), /Vielen Dank! Dein Antrag ist eingegangen\./) })) test('Rückfrage beantworten; Kundentext bleibt Text; Ansichtsmodus sperrt alles', mit({ ...SRV, 'POST /api/abo/takeover-credits/t1/reply': {} }, async (fx) => { const w = await zeigeNeu(fx) assert.ok(fx.finde(w, 'b') === null) await fx.setze(fx.finde(w, 'form[data-reply-for="t1"] textarea'), 'Anbei.') await fx.klick(fx.finde(w, 'form[data-reply-for="t1"] button[type="submit"]')) await fx.ruhe() assert.deepEqual(fx.fetchLog.find((a) => a.url.endsWith('/reply')).body, { message: 'Anbei.', files: [] }) assert.match(fx.text(w), /Danke für deine Antwort! Wir prüfen deinen Antrag weiter und melden uns per E-Mail\./) const v = await zeigeNeu(fx, meMit('unternehmen', { viewAs: true })) assert.match(fx.text(v), /Im Ansichtsmodus kann kein Antrag gestellt werden\./) assert.ok(fx.alle(v, 'button').filter((b) => ['Gutschrift beantragen', 'Antwort senden'].includes(b.textContent)).every((b) => b.hasAttribute('disabled'))) })) test('Gutschrift ohne Abo: Satz und Link zum Abo über die Brücke', mit({ 'GET /api/abo': aboAntwort(), 'GET /api/abo/takeover-credits': { requests: [], limits: LIMITS } }, async (fx) => { const w = await zeigeNeu(fx) assert.match(fx.text(w), /Die Übernahmegutschrift kannst du beantragen, sobald dein Abo läuft\./) const a = fx.finde(w, 'a[href="#vertraege"]') assert.deepEqual([a.textContent, a.getAttribute('data-view')], ['Zum Abo', 'vertraege']) })) test('Gutschrift: Antwort ohne Text und Nachweis wird abgewiesen, Abbrechen schließt das Formular', mit({ ...SRV, 'POST /api/abo/takeover-credits/t1/reply': {} }, async (fx) => { const w = await zeigeNeu(fx) await fx.klick(fx.finde(w, 'form[data-reply-for="t1"] button[type="submit"]')) await fx.ruhe() assert.match(fx.text(w), /Bitte schreib eine Antwort oder lade einen Nachweis hoch\./) assert.ok(!fx.fetchLog.some((a) => a.url.endsWith('/reply'))) assert.equal(fx.finde(w, '#tk-form').hasAttribute('hidden'), true) await fx.klick(fx.finde(w, '#tk-start')) assert.equal(fx.finde(w, '#tk-form').hasAttribute('hidden'), false) await fx.klick(fx.finde(w, '#tk-cancel')) assert.equal(fx.finde(w, '#tk-form').hasAttribute('hidden'), true) assert.match(fx.text(w), /Antrag .* · Domain/) assert.match(fx.text(w), /Nicht genehmigt/) assert.match(fx.text(w), /Gutschrift: 40,00\s€ – wird mit deinen nächsten Monatsgebühren verrechnet\./) })) test('Gutschrift: Ausfall der Anträge und des Abos', mit({ 'GET /api/abo': ABO, 'GET /api/abo/takeover-credits': () => ({ status: 500, body: { error: 'Server kaputt' } }) }, async (fx) => { assert.match(fx.text(await zeigeNeu(fx)), /Übernahmegutschrift konnte nicht geladen werden: Server kaputt/) fx.tabelle['GET /api/abo'] = () => ({ status: 500, body: { error: 'x' } }) assert.match(fx.text(await zeigeNeu(fx)), /Deine Verträge konnten gerade nicht geladen werden\./) })) test('Ohne Wortlaut der Gutschrift: nur ein Satz, kein Formular, kein Knopf; unsichere Adressen werden Text', mit({ 'GET /api/abo': ABO, 'GET /api/abo/takeover-credits': ANTRAEGE }, async (fx) => { const ohne = aboNeu({ subscription: abo() }) delete ohne.bestelltexte.gutschrift fx.tabelle['GET /api/abo'] = ohne const w = await zeigeNeu(fx) assert.ok(fx.finde(w, '#tk-start') === null) assert.ok(fx.finde(w, '#tk-form') === null) assert.match(fx.text(w), /Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat\./) const boese = aboNeu({ subscription: abo() }) boese.bestelltexte.gutschrift.satz.urls = ['javascript:alert(1)', '//evil.example'] fx.tabelle['GET /api/abo'] = boese const v = await zeigeNeu(fx) await fx.klick(fx.finde(v, '#tk-start')) assert.equal(fx.alle(v, '#tk-satz a[href]').length, 0) })) test('Quelltext: der Block TAKEOVER_CONDITIONS ist die einzige Sie-Stelle, keine Klappe, kein Häkchen', () => { const q = readFileSync(new URL('../public/neu/ansicht-gutschrift.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') const i = q.indexOf('const TAKEOVER_CONDITIONS') const ohneBlock = q.slice(0, i) + q.slice(q.indexOf(']', i)) assert.doesNotMatch(ohneBlock, /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/) assert.doesNotMatch(ohneBlock, /details|dialog|innerHTML|tk-consent|tk-conditions/) assert.doesNotMatch(ohneBlock, /body:\s*\{[^}]*kundenart/s) })