diff --git a/public/neu/ansicht-gutschrift.js b/public/neu/ansicht-gutschrift.js index daeb94e..14b7f4c 100644 --- a/public/neu/ansicht-gutschrift.js +++ b/public/neu/ansicht-gutschrift.js @@ -1,7 +1,224 @@ -/* Kundenansicht neu: Platzhalter, den das nächste Paket füllt (Stub aus P1). */ +/* Kundenansicht neu (P2): Übernahmegutschrift beantragen (#gutschrift), Text und Logik 1:1 aus app.js (loadTakeoverCard und Helfer, + Fassung 5, Sie-Form, C14). Antrag stellen, Stand sehen, Rückfragen beantworten. Kundentext (Rückfrage, Entscheidung, Anbieter, Domain) + kommt nur als Text in die Seite. Das Wortlaut-Orakel prüft den sichtbaren Text, der Hash-Pin in legalTexts.test.js den Block der Bedingungen. */ -import { h } from './dom.js' +import { api } from './api.js' +import { h, leeren } from './dom.js' +import { K, ersetze, zuAnsicht, ladeAbo, fmtDatum, fmtEuro, meldeZaehlerNeu, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' -export async function zeigeGutschrift(wurzel, { route, me, nav }) { - wurzel.replaceChildren(h('p', { class: 'kn-leer' }, 'Übernahmegutschrift (folgt)')) +const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs +const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus kann kein Antrag gestellt werden.' +const GRENZEN_VORGABE = { maxFiles: 3, maxFileBytes: 8388608, maxTotalBytes: 10485760 } + +const TAKEOVER_STATUS = { + offen: ['In Prüfung', 'orange'], + rueckfrage: ['Rückfrage', 'violett'], + genehmigt: ['Genehmigt', 'gruen'], + abgelehnt: ['Nicht genehmigt', 'grau'], +} + +// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen +const TAKEOVER_CONDITIONS = [ + '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.', + 'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.', + 'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.', + 'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet, auch nach einem Wechsel in ein anderes Abo. Sie wird nicht ausgezahlt und ist nicht übertragbar; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest.', + 'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.', +] + +const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']] + +/** Datei als Base64 lesen (ohne den data:-Anfang). Tests ersetzen IO.lies, im Test gibt es keinen FileReader. */ +function leseAlsBase64(datei) { + return new Promise((resolve, reject) => { + const leser = new FileReader() + leser.onload = () => resolve(String(leser.result).split(',')[1] || '') + leser.onerror = () => reject(new Error(`„${datei.name}“ konnte nicht gelesen werden.`)) + leser.readAsDataURL(datei) + }) +} +export const IO = { lies: leseAlsBase64 } + +/** Dateien prüfen und lesen: → [{ name, data }]; wirft die Texte der Prüfungen aus app.js. */ +export async function sammleDateien(dateien, grenzen, { erforderlich }, lies = IO.lies) { + const files = [...(dateien || [])] + const mb = (bytes) => Math.round(bytes / 1024 / 1024) + if (erforderlich && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.') + if (files.length > grenzen.maxFiles) throw new Error(`Bitte höchstens ${grenzen.maxFiles} Dateien auf einmal hochladen.`) + const zuGross = files.find((f) => f.size > grenzen.maxFileBytes) + if (zuGross) throw new Error(`„${zuGross.name}“ ist größer als ${mb(grenzen.maxFileBytes)} MB.`) + if (files.reduce((s, f) => s + f.size, 0) > grenzen.maxTotalBytes) throw new Error(`Die Dateien sind zusammen größer als ${mb(grenzen.maxTotalBytes)} MB.`) + return Promise.all(files.map(async (f) => ({ name: f.name, data: await lies(f) }))) +} + +const feld = (label, steuerung, klein) => h('div', { class: 'kn-tk-feld' }, h('label', { for: steuerung.getAttribute('id') }, label, klein ? [' ', h('small', null, klein)] : null), steuerung) +const leistung = (wert, text) => h('label', null, h('input', { type: 'checkbox', name: 'services', value: wert }), ` ${text}`) + +function antragsFormular(grenzen, email) { + const maxMb = Math.round(grenzen.maxTotalBytes / 1024 / 1024) + const bedingungen = h('details', { class: 'kn-tk-bedingungen' }, h('summary', null, 'Bedingungen der Übernahmegutschrift'), h('ol', null, TAKEOVER_CONDITIONS.map((c) => h('li', null, c)))) + return h('form', { id: 'tk-form', class: 'kn-tk-form', novalidate: true, hidden: true }, + h('fieldset', { class: 'kn-tk-feld' }, h('legend', null, 'Welche Leistung haben Sie bisher woanders bezahlt?'), + h('div', { class: 'kn-tk-leistungen' }, leistung('domain', 'Domain'), leistung('hosting', 'Hosting / Website-Betrieb'), leistung('email', 'E-Mail-Postfächer'), leistung('sonstiges', 'Sonstiges'))), + h('div', { class: 'kn-tk-zeile' }, + feld('Bisheriger Anbieter', h('input', { id: 'tk-provider', class: K.input, maxlength: 200, placeholder: 'z. B. IONOS, STRATO' })), + feld('Domain', h('input', { id: 'tk-domain', class: K.input, maxlength: 255, placeholder: 'meine-firma.de', autocapitalize: 'off', spellcheck: 'false' }), '(falls vorhanden)')), + h('div', { class: 'kn-tk-zeile' }, + feld('Rechnungsbetrag', h('input', { id: 'tk-amount', class: K.input, type: 'number', min: '0.01', step: '0.01', inputmode: 'decimal', placeholder: '119,88' }), '(brutto, in €)'), + feld('Die Rechnung gilt für', h('select', { id: 'tk-months', class: K.select }, h('option', { value: '' }, 'bitte wählen'), TAKEOVER_MONTHS.map(([v, l]) => h('option', { value: String(v) }, l)))), + feld('Bezahlt bis', h('input', { id: 'tk-paid-until', class: K.input, type: 'date' }), '(letzter Tag)')), + feld('Grund / Nachricht an uns', h('textarea', { id: 'tk-message', class: K.textarea, maxlength: 4000, placeholder: 'z. B.: Domain und Postfach liegen noch bis Ende März bei IONOS, der Vertrag dort ist gekündigt.' })), + feld('Rechnungsnachweis', h('input', { id: 'tk-files', type: 'file', accept: 'application/pdf,image/jpeg,image/png', multiple: true }), `(PDF, JPG oder PNG · bis zu ${grenzen.maxFiles} Dateien · zusammen max. ${maxMb} MB)`), + h('p', { class: 'kn-hinweis' }, 'Tipp: Laden Sie auch die Kündigungsbestätigung Ihres bisherigen Anbieters hoch – daraus sehen wir, wann der Vertrag dort endet.'), + feld('E-Mail-Adresse für unsere Antwort', h('input', { id: 'tk-email', class: K.input, type: 'email', maxlength: 255, autocomplete: 'email', value: email })), + bedingungen, + h('label', { class: 'kn-check' }, h('input', { type: 'checkbox', id: 'tk-consent' }), + h('span', null, 'Ich versichere, dass meine Angaben stimmen und die Rechnung auf mich bzw. mein Unternehmen ausgestellt ist. Die Bedingungen der Übernahmegutschrift habe ich gelesen. Angaben und Nachweise verwendet WEBklar zur Prüfung und Abwicklung des Antrags (', + h('a', { href: '/datenschutz.html#uebernahmegutschrift', target: '_blank', rel: 'noopener' }, 'Datenschutz'), ').')), + h('p', { class: K.fehler, id: 'tk-error', hidden: true }), + h('div', { class: 'kn-aktionen' }, + h('button', { type: 'submit', class: K.primaer, id: 'tk-submit' }, 'Antrag absenden'), + h('button', { type: 'button', class: K.knopf, id: 'tk-cancel' }, 'Abbrechen'))) +} + +function antrag(r, viewAs) { + const fakten = [ + `eingereicht am ${fmtDatum(r.createdAt)}`, + `bisher bei ${r.provider}${r.domain ? ` (${r.domain})` : ''}`, + `${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDatum(r.paidUntil)}`, + ] + const [pilleText, ton] = TAKEOVER_STATUS[r.status] || [r.status, 'grau'] + let koerper + if (r.status === 'genehmigt') { + koerper = [h('p', null, 'Gutschrift: ', h('strong', null, fmtEuro(r.amountCents / 100)), ' – wird mit Ihren nächsten Monatsgebühren verrechnet.'), + r.decisionNote ? h('p', { class: 'kn-hinweis kn-umbruch' }, r.decisionNote) : null] + } else if (r.status === 'abgelehnt') { + koerper = [h('p', { class: 'kn-hinweis kn-umbruch' }, r.decisionNote)] + } else if (r.status === 'rueckfrage') { + koerper = [ + h('div', { class: 'kn-tk-frage' }, h('strong', null, 'Unsere Rückfrage:'), h('br'), r.question), + h('form', { class: 'kn-tk-antwort', 'data-reply-for': r.id, novalidate: true }, + h('textarea', { name: 'message', class: K.textarea, maxlength: 4000, placeholder: 'Ihre Antwort…' }), + h('input', { type: 'file', name: 'files', accept: 'application/pdf,image/jpeg,image/png', multiple: true, 'aria-label': 'Weitere Nachweise' }), + h('p', { class: K.fehler, hidden: true }), + h('div', { class: 'kn-aktionen' }, h('button', { type: 'submit', class: K.primaer, disabled: viewAs }, 'Antwort senden')))] + } else { + koerper = [h('p', { class: 'kn-hinweis' }, 'Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.')] + } + return h('div', { class: 'kn-tk-antrag' }, + h('div', { class: 'kn-tk-kopf' }, h('strong', null, `Antrag ${r.ref} · ${r.services}`), h('span', { class: K.pille[ton] }, pilleText)), + h('div', { class: 'kn-hinweis' }, fakten.join(' · ')), + ...koerper) +} + +/** Seite Übernahmegutschrift. Lädt Abo und Anträge selbst; nach Absenden oder Antworten lädt sie neu und meldet den Zähler. */ +export async function zeigeGutschrift(wurzel, { route, me, nav } = {}) { + const viewAs = me?.viewAs === true + const email = me?.customer?.email || '' + const nr = (laeufe.get(wurzel) ?? 0) + 1 + laeufe.set(wurzel, nr) + const aktuell = () => laeufe.get(wurzel) === nr + leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) + await lade('') + + async function lade(notice, { zaehler = false } = {}) { + const abo = await ladeAbo() + if (!aktuell()) return + if (!abo) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return } + const aktiv = Boolean(abo.subscription?.active) + let daten = { requests: [], limits: null } + if (abo.subscription) { + try { + daten = await api('/api/abo/takeover-credits') + } catch (f) { + if (!aktuell()) return + leeren(wurzel).append(h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel kn-leer' }, `Übernahmegutschrift konnte nicht geladen werden: ${f.message}`))) + return + } + if (!aktuell()) return + } + if (zaehler) meldeZaehlerNeu('vertraege') + const anfragen = Array.isArray(daten.requests) ? daten.requests : [] + const grenzen = daten.limits || GRENZEN_VORGABE + if (!aktiv && !anfragen.length) { + leeren(wurzel).append(h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel' }, + h('p', { class: 'kn-hinweis' }, 'Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft. ', h('a', zuAnsicht('vertraege'), 'Zum Abo'))))) + return + } + const formular = aktiv ? antragsFormular(grenzen, email) : null + const start = aktiv ? h('button', { type: 'button', class: K.knopf, id: 'tk-start', disabled: viewAs }, 'Gutschrift beantragen') : null + ersetze(wurzel, h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel' }, + h('p', { class: 'kn-hinweis kn-tk-einleitung' }, 'Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.'), + notice ? h('div', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, notice) : null, + aktiv ? h('div', { class: 'kn-aktionen' }, start, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null) : null, + formular, + anfragen.length ? h('div', { class: 'kn-tk-liste' }, anfragen.map((r) => antrag(r, viewAs))) : null))) + + const neuLaden = (text) => lade(text, { zaehler: true }) + start?.addEventListener('click', () => { + formular.hidden = false + start.hidden = true + formular.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }) + }) + formular?.querySelector('#tk-cancel')?.addEventListener('click', () => { formular.hidden = true; if (start) start.hidden = false }) + formular?.addEventListener('submit', (ev) => { ev.preventDefault(); sendeAntrag(formular, grenzen, neuLaden) }) + wurzel.querySelectorAll('form[data-reply-for]').forEach((f) => f.addEventListener('submit', (ev) => { ev.preventDefault(); sendeAntwort(f, grenzen, neuLaden, viewAs) })) + } +} + +function zeigeFehler(el, text) { + if (!el) return + el.textContent = text + el.hidden = false +} + +async function sendeAntrag(form, grenzen, neuLaden) { + const fehler = form.querySelector('#tk-error') + const knopf = form.querySelector('#tk-submit') + fehler.hidden = true + const wert = (id) => form.querySelector(id).value.trim() + const payload = { + services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value), + provider: wert('#tk-provider'), + domain: wert('#tk-domain'), + invoiceAmount: wert('#tk-amount'), + billingMonths: Number(wert('#tk-months')), + paidUntil: wert('#tk-paid-until'), + message: wert('#tk-message'), + email: wert('#tk-email'), + consent: form.querySelector('#tk-consent').checked, + } + try { + if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.') + if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.') + if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.') + if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.') + knopf.disabled = true + knopf.textContent = 'Wird gesendet…' + payload.files = await sammleDateien(form.querySelector('#tk-files').files, grenzen, { erforderlich: true }) + await api('/api/abo/takeover-credits', { method: 'POST', body: payload }) + await neuLaden(`Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`) + } catch (f) { + zeigeFehler(fehler, f.message) + knopf.disabled = false + knopf.textContent = 'Antrag absenden' + } +} + +async function sendeAntwort(form, grenzen, neuLaden, viewAs) { + if (viewAs) return + const fehler = form.querySelector('p') + const knopf = form.querySelector('button[type="submit"]') + fehler.hidden = true + knopf.disabled = true + try { + const message = form.querySelector('textarea').value.trim() + const files = await sammleDateien(form.querySelector('input[type="file"]').files, grenzen, { erforderlich: false }) + if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.') + await api(`/api/abo/takeover-credits/${encodeURIComponent(form.getAttribute('data-reply-for'))}/reply`, { method: 'POST', body: { message, files } }) + await neuLaden('Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.') + } catch (f) { + zeigeFehler(fehler, f.message) + knopf.disabled = false + } } diff --git a/test/knAnsichtGutschrift.test.js b/test/knAnsichtGutschrift.test.js new file mode 100644 index 0000000..f27c62e --- /dev/null +++ b/test/knAnsichtGutschrift.test.js @@ -0,0 +1,63 @@ +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 { sichtbarerText, ladeAlteApp } from './support/knWortlautOrakel.js' +import { mit, me, abo, aboAntwort, anfrageGutschrift, neueWurzel, LIMITS } from './support/knDaten.js' + +const alt = ladeAlteApp(); const ABO = aboAntwort({ 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 = me()) => { const w = neueWurzel(fx); await zeigeGutschrift(w, { route: { bereich: 'vertraege', etage: 'gutschrift', query: '' }, me: m, nav: 'push' }); await fx.ruhe(); return w } +for (const [name, abo_, antwort] of [['mit Anträgen', ABO, ANTRAEGE], ['ohne Abo', aboAntwort(), { requests: [], limits: LIMITS }]]) { + test(`Gutschrift (${name}): Karte wie in app.js`, mit({ 'GET /api/abo': abo_, 'GET /api/abo/takeover-credits': antwort }, async (fx) => { + const w = await zeigeNeu(fx) + assert.equal(sichtbarerText(fx.finde(w, '[data-karte="gutschrift"]')), sichtbarerText(await alt.gutschrift(abo_, antwort))) + })) +} +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 wörtlich', 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 laden Sie die Rechnung Ihres 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 wörtlich, Body mit denselben Schlüsseln wie früher, 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ählen Sie, welche Leistung Sie bisher woanders bezahlt haben\./) + await fx.klick(fx.finde(w, 'input[value="domain"]')); 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.klick(fx.finde(w, '#tk-submit')); assert.match(fx.text(w), /Bitte bestätigen Sie Ihre Angaben und die Bedingungen\./) + fx.finde(w, '#tk-files').files = [{ name: 'r.pdf', size: 10 }]; await fx.klick(fx.finde(w, '#tk-consent')); await fx.klick(fx.finde(w, '#tk-submit')); await fx.ruhe() + assert.deepEqual(fx.fetchLog.find((a) => a.method === 'POST').body, { services: ['domain'], provider: 'IONOS', domain: '', invoiceAmount: '119.88', billingMonths: 12, paidUntil: '2027-03-31', message: '', email: 'anna@example.com', consent: true, files: [{ name: 'r.pdf', data: 'QQ==' }] }) + assert.match(fx.text(w), /Vielen Dank! Ihr Antrag ist eingegangen\. Die Eingangsbestätigung haben wir an anna@example\.com geschickt; unsere Antwort kommt ebenfalls per E-Mail\./) +})) +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.equal(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: [] }) + const v = await zeigeNeu(fx, me({ 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 können Sie beantragen, sobald Ihr 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, Ausfälle haben eigene Texte', 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 schreiben Sie eine Antwort oder laden Sie 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 € – wird mit Ihren 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\./) +}))