diff --git a/public/auftrag.js b/public/auftrag.js new file mode 100644 index 0000000..4207b9a --- /dev/null +++ b/public/auftrag.js @@ -0,0 +1,107 @@ +/*! WEBklar Aufträge: öffentliche Bestätigungsseite (auftrag.html#t=, Rückfall ?t=). Link aus der Mail K31. + * Der Token steht nur in der Adresse, in Links und im Speicher der Seite, nie in Text, Ansage, Fehlermeldung oder Protokoll. + * Keine Cookies (credentials: 'omit'), kein Browserspeicher, keine Drittanfragen. Alle Texte stehen in auftrag-ansicht.js (Sie-Form) + * und kommen nur über textContent in die Seite. */ +import { + KONTAKT, agbPfad, darfBestaetigen, pdfPfad, statusAnzeige, texte, tokenAusAdresse, zustandAusAntwort, +} from './auftrag-ansicht.js' +import { h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung } from './auftrag-dom.js' + +/** Einzige Anfrage der Seite: { status, body }; jeder Fehler des Netzes ist Status 0 (nie die Meldung des Browsers weiterreichen). */ +async function anfrage(token, aktion, { method = 'GET', body } = {}) { + try { + const antwort = await fetch(`/api/auftrag/${encodeURIComponent(token)}${aktion ? `/${aktion}` : ''}`, { + method, + credentials: 'omit', + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + const daten = await antwort.json().catch(() => ({})) + return { status: antwort.status, body: daten !== null && typeof daten === 'object' ? daten : {} } + } catch { + return { status: 0, body: {} } + } +} + +export async function initAuftragsseite({ root, ansage, suche = '', hash = '' }) { + const t = texte() + const token = tokenAusAdresse(suche, hash) + + const sage = (text) => { ansage.textContent = text } + const zeige = (zustand, ...kinder) => { + root.dataset.zustand = zustand + root.setAttribute('aria-busy', zustand === 'laedt' ? 'true' : 'false') + root.replaceChildren(...kinder.filter(Boolean)) + } + const kontakt = () => h('p', { class: 'ab-kontakt' }, + t.kontaktVor, ' ', + h('a', { href: `mailto:${KONTAKT.email}` }, KONTAKT.email), ' oder ', + h('a', { href: `tel:${KONTAKT.telefon.replace(/\D/g, '')}` }, KONTAKT.telefon), '.') + + function zeigeZustand({ zustand, titel, text }) { + zeige(zustand, + h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, titel), + h('p', { class: 'ab-text' }, text), + zustand === 'limit' ? null : kontakt(), + zustand === 'limit' || zustand === 'fehler' ? h('button', { type: 'button', class: 'ab-knopf', 'data-aktion': 'erneut', onclick: () => lade() }, t.erneut) : null) + sage(titel) + } + + async function senden(koerper) { + const { status, body } = await anfrage(token, 'bestaetigen', { method: 'POST', body: koerper }) + if (status === 200 && body.auftrag) return { ok: true, auftrag: body.auftrag } + return { ok: false, status: status === 200 ? 0 : status, body } + } + + function zeigeAuftrag(auftrag) { + const bestaetigt = Boolean(auftrag.bestaetigtAm) + const offen = darfBestaetigen(auftrag) + const status = statusAnzeige({ ...auftrag, kannBestaetigen: offen }) + zeige(bestaetigt ? 'bestaetigt' : 'offen', + h('p', { class: `ab-pille ab-pille-${status.ton}` }, status.text), + h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, bestaetigt ? t.titelBestaetigt : `${t.titelOffen} ${auftrag.nummer || ''}`.trim()), + bestaetigt ? null : h('p', { class: 'ab-intro' }, t.intro), + renderBestaetigtBlock(auftrag), + renderZusammenfassung(auftrag), + h('section', { class: 'ab-abschnitt' }, + h('h2', { class: 'ab-h2' }, t.unterlagen), + renderDokumente({ pdfHref: pdfPfad(token), agbHref: agbPfad(auftrag.agbUrl) })), + offen + ? renderBestaetigung(auftrag, { + senden, + nachErfolg: (neu) => { + zeigeAuftrag(neu) + sage(t.titelBestaetigt) + root.querySelector('[data-bestaetigt]')?.focus() + }, + nachEnde: (code, antwortBody) => zeigeZustand(zustandAusAntwort(code, antwortBody)), + }) + : null) + } + + async function lade() { + if (!token) { + zeigeZustand(zustandAusAntwort(404, {})) + return + } + zeige('laedt', h('p', { class: 'ab-laden' }, t.laedt)) + const { status, body } = await anfrage(token, '') + if (status === 200 && body.auftrag) { + zeigeAuftrag(body.auftrag) + sage(t.geladen) + } else { + zeigeZustand(zustandAusAntwort(status === 200 ? 0 : status, body)) + } + } + + await lade() +} + +if (typeof document !== 'undefined' && document.getElementById('ab-wurzel')) { + initAuftragsseite({ + root: document.getElementById('ab-wurzel'), + ansage: document.getElementById('ab-ansage'), + suche: window.location.search, + hash: window.location.hash, + }) +} diff --git a/test/auftraegeKundenportalSeite.test.js b/test/auftraegeKundenportalSeite.test.js new file mode 100644 index 0000000..7ac8c5b --- /dev/null +++ b/test/auftraegeKundenportalSeite.test.js @@ -0,0 +1,160 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initAuftragsseite } from '../public/auftrag.js' +import { TOKEN, KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const ok = (body, status = 200) => ({ status, body }) +const mit = (antworten, fn) => async () => { + const fx = installFakeDom({ antworten }) + const root = h('div', { id: 'ab-wurzel' }) + const ansage = h('div', { id: 'ab-ansage' }) + fx.document.body.append(root, ansage) + try { + await fn(fx, root, ansage) + } finally { + fx.restore() + } +} +const start = (root, ansage, adresse = {}) => initAuftragsseite({ root, ansage, suche: '', hash: `#t=${TOKEN}`, ...adresse }) // K31 verlinkt #t= +const bestaetige = async (fx, root, { frueh = false } = {}) => { + await fx.klick(fx.finde(root, '[data-feld="bestaetigung"]')) + if (frueh) await fx.klick(fx.finde(root, '[data-feld="fruehbeginn"]')) + await fx.klick(fx.finde(root, '[data-aktion="bestaetigen"]')) +} + +test('Ohne gültigen Token: „ungültig“ und Kontaktweg, ohne Anfrage', mit([], async (fx, root, ansage) => { + for (const adresse of [{ hash: '' }, { hash: '#t=' }, { hash: '#t=zu-kurz' }, { hash: `#t=${TOKEN}x` }, { hash: '', suche: '?t=zu-kurz' }]) { + await start(root, ansage, adresse) + assert.equal(root.dataset.zustand, 'ungueltig', JSON.stringify(adresse)) + assert.equal(fx.finde(root, 'h1').textContent, 'Dieser Link ist ungültig oder abgelaufen.') + assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]').textContent, 'support@webklar.com') + assert.equal(fx.finde(root, 'a[href="tel:017623726355"]').textContent, '0176 23726355') + } + assert.equal(fx.fetchLog.length, 0) +})) + +test('Laden und Anzeigen: Kopf, Zusammenfassung, Unterlagen, Formular; der Token steht nur in Links', mit([ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.deepEqual(fx.fetchLog, [{ method: 'GET', url: `/api/auftrag/${TOKEN}`, body: undefined }]) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.finde(root, 'h1').textContent, 'Auftragsbestätigung AB-2026-0002') + assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bitte bestätigen') + assert.equal(fx.alle(root, '[data-position]').length, 2) + const pdf = fx.finde(root, '[data-link="pdf"]') + const agb = fx.finde(root, '[data-link="agb"]') + assert.equal(pdf.getAttribute('href'), `/api/auftrag/${TOKEN}/pdf`) + assert.equal(agb.getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf') + assert.ok(fx.finde(root, 'form [data-feld="bestaetigung"]')) + assert.equal(fx.finde(root, '[data-feld="fruehbeginn"]'), null) + assert.equal(fx.finde(root, '[data-hinweis]').textContent, KLEINUNTERNEHMER, 'der § 19-Satz kommt aus der API') + assert.equal(fx.finde(root, 'legend').textContent, 'Ihre Bestätigung', 'öffentliche Seite: Sie-Form') + assert.equal(fx.text(ansage), 'Ihre Auftragsbestätigung wurde geladen.') + assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN)) +})) + +test('Der Token steht im Fragment (#t=, so verlinkt K31); als Rückfall genügt ?t=', mit([ok({ auftrag: ab() }), ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'offen') + await start(root, ansage, { hash: '', suche: `?t=${TOKEN}` }) + assert.equal(root.dataset.zustand, 'offen') + assert.deepEqual(fx.fetchLog.map((a) => a.url), [`/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}`]) +})) + +test('Aufwand: Aufwandstext statt Tabelle', mit([ok({ auftrag: ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde.' }) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(fx.finde(root, '[data-aufwand]').textContent, '100,00 € pro Stunde.') + assert.equal(fx.finde(root, 'table'), null) +})) + +test('Bestätigen (Unternehmen): POST mit Häkchen, danach „bestätigt“ mit Zeitpunkt, Fokus und Ansage', mit([ok({ auftrag: ab() }), ok({ auftrag: bestaetigt(ab()) })], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.deepEqual(fx.fetchLog[1], { method: 'POST', url: `/api/auftrag/${TOKEN}/bestaetigen`, body: { bestaetigt: true, fruehbeginn: false } }) + assert.equal(root.dataset.zustand, 'bestaetigt') + assert.equal(fx.finde(root, 'h1').textContent, 'Ihr Auftrag ist bestätigt.') + assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bestätigt') + const block = fx.finde(root, '[data-bestaetigt]') + assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr']) + assert.equal(fx.finde(root, 'form'), null) + assert.equal(fx.document.activeElement, block) + assert.equal(fx.text(ansage), 'Ihr Auftrag ist bestätigt.') + assert.ok(fx.finde(root, '[data-link="pdf"]'), 'Unterlagen bleiben erreichbar') +})) + +test('Privatperson: zweites Häkchen; angezeigt wird, was der Server meldet (Frühbeginn „nein“ trotz Häkchen)', mit([ok({ auftrag: privat() }), ok({ auftrag: bestaetigt(privat({ fruehbeginn: false })) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.ok(fx.finde(root, '[data-feld="fruehbeginn"]')) + await bestaetige(fx, root, { frueh: true }) + assert.deepEqual(fx.fetchLog[1].body, { bestaetigt: true, fruehbeginn: true }) + assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [ + 'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.', + ]) +})) + +test('Schon bestätigt beim Laden: „bestätigt“ ohne Formular, mit der Zeile zum Beginn', mit([ok({ auftrag: bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'bestaetigt') + assert.equal(fx.finde(root, 'form'), null) + assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [ + 'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.', + ]) +})) + +test('Zustände: ungültig, ersetzt, storniert, widerrufen, zu viele Anfragen, Serverfehler – feste Texte, nie der Token', mit([], async (fx, root, ansage) => { + const faelle = [ + [ok({ error: 'Dieser Link ist ungültig oder abgelaufen.' }, 404), 'ungueltig', 'Dieser Link ist ungültig oder abgelaufen.'], + [ok({ error: 'Diese Auftragsbestätigung wurde ersetzt.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde ersetzt.'], + [ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde storniert.'], + [ok({ error: 'Diese Auftragsbestätigung wurde widerrufen.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde widerrufen.'], + [ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429), 'limit', 'Zu viele Anfragen'], + [ok({ error: 'Interner Fehler.' }, 500), 'fehler', 'Das hat leider nicht geklappt'], + [ok({}, 502), 'fehler', 'Das hat leider nicht geklappt'], + [ok({}, 200), 'fehler', 'Das hat leider nicht geklappt'], // Antwort ohne auftrag + ] + for (const [antwort, zustand, titel] of faelle) { + fx.antworten.push(antwort) + await start(root, ansage) + assert.equal(root.dataset.zustand, zustand, titel) + assert.equal(fx.finde(root, 'h1').textContent, titel) + assert.equal(fx.text(ansage), titel) + assert.equal(fx.finde(root, 'form'), null) + assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN)) + assert.equal(Boolean(fx.finde(root, '[data-aktion="erneut"]')), ['limit', 'fehler'].includes(zustand), `Erneut-Knopf bei ${zustand}`) + } + assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]') !== null, true, 'auch beim Fehler steht der Kontaktweg da') +})) + +test('Netzfehler beim Laden: Fehlerzustand ohne Fremdtext, „Erneut versuchen“ lädt neu', mit([() => { throw new TypeError('fetch failed') }, ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'fehler') + assert.ok(!fx.text(root).includes('fetch failed')) + await fx.klick(fx.finde(root, '[data-aktion="erneut"]')) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.fetchLog.length, 2) +})) + +test('Status wechselt zwischen Laden und Bestätigen (410): „storniert“, kein Formular mehr', mit([ok({ auftrag: ab() }), ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410)], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.equal(root.dataset.zustand, 'beendet') + assert.equal(fx.finde(root, 'h1').textContent, 'Diese Auftragsbestätigung wurde storniert.') + assert.equal(fx.finde(root, 'form'), null) +})) + +test('Fehler beim Bestätigen (429): Meldung in der Fehlerzeile, Seite bleibt offen, Häkchen bleiben', mit([ok({ auftrag: ab() }), ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429)], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.finde(root, '[data-fehler]').textContent, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.') + assert.equal(fx.finde(root, '[data-feld="bestaetigung"]').checked, true) + assert.equal(fx.finde(root, '[data-aktion="bestaetigen"]').disabled, false) + assert.ok(!fx.text(root).includes(TOKEN)) +})) + +test('Quelltext: genau ein fetch ohne Cookies, keine Protokollausgabe, nur relative Importe', () => { + const quelle = pruefeQuelltext('auftrag.js', { fetchAnzahl: 1, importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + assert.match(quelle, /credentials: 'omit'/) + assert.doesNotMatch(quelle, /location\.href|history\.|document\.referrer/) +})