From e6a0fc9acf8a61fa177d1b07f00488f5bed6994d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:34 +0000 Subject: [PATCH] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20reine=20Ansicht?= =?UTF-8?q?slogik=20(Token,=20Betr=C3=A4ge,=20Zust=C3=A4nde,=20Status,=20A?= =?UTF-8?q?B-Nummer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/auftrag-ansicht.js | 260 ++++++++++++++++++++++ test/auftraegeKundenportalAnsicht.test.js | 211 ++++++++++++++++++ test/auftraegeKundenportalDaten.js | 65 ++++++ 3 files changed, 536 insertions(+) create mode 100644 public/auftrag-ansicht.js create mode 100644 test/auftraegeKundenportalAnsicht.test.js create mode 100644 test/auftraegeKundenportalDaten.js diff --git a/public/auftrag-ansicht.js b/public/auftrag-ansicht.js new file mode 100644 index 0000000..c84c239 --- /dev/null +++ b/public/auftrag-ansicht.js @@ -0,0 +1,260 @@ +// Reine Ansichtslogik der Auftragsbestätigung (AB) im Kundenportal: Token, Beträge, Daten, Zustände, Status, AB-Nummer +// und alle Seitentexte. Ohne Importe, ohne DOM, ohne fetch; jede Funktion verträgt fehlende Felder. +// Die Anrede wählt der Aufrufer über ANREDE_SIE (öffentliche Seite, Widerrufsseite) oder ANREDE_DU (angemeldetes Portal). + +export const TOKEN_MUSTER = /^[A-Za-z0-9_-]{43}$/ +export const KONTAKT = { email: 'support@webklar.com', telefon: '0176 23726355' } +export const BESTAETIGUNG_FALLBACK = 'Ich bestätige den Auftrag zu diesen Bedingungen.' +export const ANREDE_SIE = 'sie' +export const ANREDE_DU = 'du' + +/** Texte, die in beiden Anreden gleich lauten (weder Du- noch Sie-Formen). */ +export const TEXTE_GEMEINSAM = { + titelOffen: 'Auftragsbestätigung', + leistung: 'Leistungsumfang', + aufwand: 'Abrechnung nach Aufwand', + zahlung: 'Zahlung', + termine: 'Termine', + unterlagen: 'Unterlagen', + spalten: ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'], + gesamtpreis: 'Gesamtpreis', + linkPdf: 'Auftragsbestätigung als PDF ansehen', + linkAgb: 'Allgemeine Geschäftsbedingungen (PDF)', + freiwillig: '(freiwillig)', + knopf: 'Auftrag bestätigen', + sendet: 'Wird gesendet …', + ansichtsmodus: 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.', + erneut: 'Erneut versuchen', + kartePdf: 'PDF ansehen', + karteDetails: 'Details ansehen', + karteDetailsBestaetigen: 'Details ansehen und bestätigen', +} + +/** Öffentliche Seite und Widerrufsseite (Sie-Form); enthält auch die Schlüssel, die das Portal in Du überschreibt. */ +export const TEXTE_SIE = { + laedt: 'Ihre Auftragsbestätigung wird geladen …', + geladen: 'Ihre Auftragsbestätigung wurde geladen.', + titelBestaetigt: 'Ihr Auftrag ist bestätigt.', + intro: 'Bitte prüfen Sie die Angaben. Mit einem Klick bestätigen Sie den Auftrag.', + kontaktVor: 'Sie erreichen uns unter', + keinJs: 'Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.', + abNummerUngueltig: 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.', + formular: 'Ihre Bestätigung', + speicherHinweis: 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.', + fruehbeginnWunsch: 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.', + fehler: { + keine: 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.', + haken: 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.', + anmeldung: 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.', + gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.', + zuviele: 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.', + sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.', + }, +} + +/** Angemeldetes Portal (Du-Form): nur die Schlüssel, die anders lauten. */ +export const TEXTE_DU = { + formular: 'Deine Bestätigung', + speicherHinweis: 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.', + fruehbeginnWunsch: 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.', + fehler: { + keine: 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.', + haken: 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.', + anmeldung: 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.', + gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.', + zuviele: 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.', + sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.', + }, +} + +/** Texte der gewählten Anrede (ohne Angabe: Sie). */ +export const texte = (anrede = ANREDE_SIE) => ({ ...TEXTE_GEMEINSAM, ...(anrede === ANREDE_DU ? TEXTE_DU : TEXTE_SIE) }) + +/* ------------------------------------------------------------------ Token und Links --- */ + +/** Token aus der Adresse: zuerst das Fragment (so verlinkt K31), dann die Suche (Rückfall). '' = ungültig. */ +export function tokenAusAdresse(suche = '', hash = '') { + for (const roh of [hash, suche]) { + const teil = String(roh ?? '').replace(/^[#?]/, '') + if (!teil) continue + const werte = new URLSearchParams(teil).getAll('t') + if (werte.length === 1 && TOKEN_MUSTER.test(werte[0])) return werte[0] + } + return '' +} + +export const pdfPfad = (token) => `/api/auftrag/${encodeURIComponent(token)}/pdf` + +/** AGB-Link: nur ein PDF unter /rechtliches/, sonst ''. */ +export const agbPfad = (agbUrl) => (typeof agbUrl === 'string' && /^\/rechtliches\/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$/.test(agbUrl) ? agbUrl : '') + +/* ------------------------------------------------------------------ Beträge und Zeiten --- */ + +const EURO = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }) +const BERLIN_TAG = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }) +const BERLIN_ZEIT = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', +}) + +/** Ganze Cent als deutscher Euro-Betrag (negativ mit dem Minuszeichen), sonst '–'. */ +export function euroDe(cents) { + if (!Number.isSafeInteger(cents)) return '–' + const text = EURO.format(cents / 100) + return text.startsWith('-') ? `\u{2212}${text.slice(1)}` : text +} + +/** 'YYYY-MM-DD' oder ISO-Zeitpunkt (Berliner Tag) oder 'TT.MM.JJJJ' als 'TT.MM.JJJJ', sonst ''. */ +export function datumDe(wert) { + if (typeof wert !== 'string') return '' + if (/^\d{2}\.\d{2}\.\d{4}$/.test(wert)) return wert + if (/^\d{4}-\d{2}-\d{2}$/.test(wert)) return `${wert.slice(8, 10)}.${wert.slice(5, 7)}.${wert.slice(0, 4)}` + if (/^\d{4}-\d{2}-\d{2}T/.test(wert)) { + const zeit = new Date(wert) + if (Number.isNaN(zeit.getTime())) return '' + return datumDe(BERLIN_TAG.format(zeit)) + } + return '' +} + +/** 'TT.MM.JJJJ, HH:MM Uhr' in Berliner Zeit, sonst ''. */ +export function zeitpunktDe(iso) { + if (typeof iso !== 'string' || !iso) return '' + const zeit = new Date(iso) + if (Number.isNaN(zeit.getTime())) return '' + const teile = Object.fromEntries(BERLIN_ZEIT.formatToParts(zeit).map((teil) => [teil.type, teil.value])) + return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr` +} + +/* ------------------------------------------------------------------ Zustand der AB --- */ + +export const zeigtFruehbeginn = (a) => typeof a?.texte?.fruehbeginn === 'string' && a.texte.fruehbeginn.trim() !== '' + +export const darfBestaetigen = (a) => !a?.bestaetigtAm && (a?.status === 'versendet' || a?.status === 'abgerechnet') + +const STATUS = { + versendet: { text: 'Versendet', ton: 'muted' }, + bestaetigt: { text: 'Bestätigt', ton: 'ok' }, + abgerechnet: { text: 'Abgerechnet', ton: 'ok' }, + storniert: { text: 'Storniert', ton: 'muted' }, + ersetzt: { text: 'Ersetzt', ton: 'muted' }, + widerrufen: { text: 'Widerrufen', ton: 'rot' }, +} + +export function statusAnzeige(a) { + if (a?.kannBestaetigen === true && !a.bestaetigtAm) return { text: 'Bitte bestätigen', ton: 'warn' } + const eintrag = STATUS[a?.status] + return eintrag ? { ...eintrag } : { text: '–', ton: 'muted' } +} + +export function kartenMeta(a) { + const teile = [] + const datum = datumDe(a?.datum) + if (datum) teile.push(`vom ${datum}`) + if (a?.art === 'aufwand') teile.push('nach Aufwand') + else if (Number.isSafeInteger(a?.summeCents)) teile.push(euroDe(a.summeCents)) + return teile.join(' · ') +} + +/** Kopie: zuerst die AB, die noch bestätigt werden kann, dann nach Nummer absteigend (Zeichenvergleich). */ +export function sortiereAuftraege(liste) { + if (!Array.isArray(liste)) return [] + return [...liste].sort((x, y) => { + const a = x?.kannBestaetigen === true ? 0 : 1 + const b = y?.kannBestaetigen === true ? 0 : 1 + if (a !== b) return a - b + const nx = String(x?.nummer ?? '') + const ny = String(y?.nummer ?? '') + return nx < ny ? 1 : nx > ny ? -1 : 0 + }) +} + +/* ------------------------------------------------------------------ Inhalte --- */ + +export function zusammenfassung(a) { + const auftrag = a ?? {} + const aufwand = auftrag.art === 'aufwand' + const kopf = [ + { label: 'Nummer', wert: auftrag.nummer || '' }, + { label: 'Datum', wert: datumDe(auftrag.datum) }, + { label: 'Projekt', wert: auftrag.titel || '' }, + ].filter((zeile) => zeile.wert) + const positionen = (Array.isArray(auftrag.positionen) ? auftrag.positionen : []).map((p) => ({ + beschreibung: String(p?.beschreibung ?? ''), + menge: String(p?.menge ?? ''), + einzelpreis: euroDe(p?.einzelpreisCents), + summe: euroDe(p?.summeCents), + })) + const termine = [ + { label: 'Start', wert: datumDe(auftrag.startAm) }, + { label: 'Voraussichtliche Fertigstellung', wert: datumDe(auftrag.fertigBis) }, + ].filter((zeile) => zeile.wert) + const hinweis = typeof auftrag.texte?.kleinunternehmer === 'string' ? auftrag.texte.kleinunternehmer.trim() : '' + return { + kopf, + art: aufwand ? 'aufwand' : 'festpreis', + positionen, + summe: !aufwand && Number.isSafeInteger(auftrag.summeCents) ? euroDe(auftrag.summeCents) : '', + aufwand: aufwand ? String(auftrag.aufwandText ?? '') : '', + zahlung: String(auftrag.zahlungsplanText ?? ''), + termine, + hinweis, + } +} + +export function bestaetigtZeilen(a, anrede = ANREDE_SIE) { + if (!a?.bestaetigtAm) return [] + const zeitpunkt = zeitpunktDe(a.bestaetigtAm) + const zeilen = [zeitpunkt ? `Bestätigt am ${zeitpunkt}` : 'Bestätigt'] + if (a.kundenart === 'privat') { + if (a.fruehbeginn === true) zeilen.push(texte(anrede).fruehbeginnWunsch) + else if (datumDe(a.fruehesterBeginn)) zeilen.push(`Wir beginnen frühestens am ${datumDe(a.fruehesterBeginn)}.`) + } + return zeilen +} + +/* ------------------------------------------------------------------ Antworten des Servers --- */ + +const BEENDET_GRUENDE = [['ersetzt', /ersetzt/i], ['storniert', /storniert/i], ['widerrufen', /widerrufen/i]] + +/** Zustand der öffentlichen Seite aus Status und Antwort (nur feste eigene Texte, nie Servertext). */ +export function zustandAusAntwort(status, body) { + if (status === 404) { + return { + zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', + text: 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.', + } + } + if (status === 410) { + const meldung = typeof body?.error === 'string' ? body.error : '' + const grund = BEENDET_GRUENDE.find(([, muster]) => muster.test(meldung))?.[0] || '' + return { + zustand: 'beendet', + titel: grund ? `Diese Auftragsbestätigung wurde ${grund}.` : 'Diese Auftragsbestätigung ist nicht mehr gültig.', + text: grund === 'ersetzt' + ? 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.' + : 'Bei Fragen melden Sie sich gern bei uns.', + } + } + if (status === 429) return { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' } + return { + zustand: 'fehler', titel: 'Das hat leider nicht geklappt', + text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.', + } +} + +/** Meldung beim Bestätigen nach Status (feste eigene Texte der gewählten Anrede). */ +export function fehlerMeldung(status, anrede = ANREDE_SIE) { + const fehler = texte(anrede).fehler + if (status === 0) return fehler.keine + if (status === 400) return fehler.haken + if (status === 401) return fehler.anmeldung + if (status === 403 || status === 409) return fehler.gesperrt + if (status === 429) return fehler.zuviele + return fehler.sonst +} + +/** Freiwillige AB-Nummer der Widerrufsseite: 'AB-JJJJ-NNNN' oder ''. */ +export function abNummerNormalisiert(eingabe) { + const text = String(eingabe ?? '').trim().toUpperCase().replace(/[\s\-\u{2010}-\u{2015}\u{2212}]+/gu, '-') + return /^AB-\d{4}-\d{4}$/.test(text) ? text : '' +} diff --git a/test/auftraegeKundenportalAnsicht.test.js b/test/auftraegeKundenportalAnsicht.test.js new file mode 100644 index 0000000..0f2f436 --- /dev/null +++ b/test/auftraegeKundenportalAnsicht.test.js @@ -0,0 +1,211 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + TOKEN_MUSTER, KONTAKT, ANREDE_SIE, ANREDE_DU, TEXTE_GEMEINSAM, TEXTE_SIE, TEXTE_DU, texte, tokenAusAdresse, euroDe, datumDe, zeitpunktDe, + pdfPfad, agbPfad, zeigtFruehbeginn, darfBestaetigen, statusAnzeige, kartenMeta, zusammenfassung, bestaetigtZeilen, zustandAusAntwort, + fehlerMeldung, sortiereAuftraege, abNummerNormalisiert, +} from '../public/auftrag-ansicht.js' +import { TOKEN, BESTAETIGT_AM, KLEINUNTERNEHMER, ab, privat, bestaetigt, hatDuForm, hatSieForm, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const NB = '\u{a0}' // geschütztes Leerzeichen vor „€“ (wie euroDe auf dem Server) + +test('Token: 43 Zeichen base64url, zuerst aus #t= (so verlinkt K31), sonst aus ?t=, nie mehrdeutig', () => { + assert.equal(TOKEN.length, 43) + assert.match(TOKEN, TOKEN_MUSTER) + assert.equal(tokenAusAdresse('', `#t=${TOKEN}`), TOKEN) + assert.equal(tokenAusAdresse(`?t=${TOKEN}`, ''), TOKEN) + assert.equal(tokenAusAdresse(`?x=1&t=${TOKEN}`, ''), TOKEN) + const anderer = 'y'.repeat(43) + assert.equal(tokenAusAdresse(`?t=${TOKEN}`, `#t=${anderer}`), anderer, 'das Fragment gewinnt') + assert.equal(tokenAusAdresse(`?t=${TOKEN}`, '#t=abc'), TOKEN, 'ungültiges Fragment: weiter mit der Suche') + for (const hash of ['', '#', '#t=', '#t=abc', `#t=${TOKEN}x`, `#t=${TOKEN.slice(0, 42)}!`, `#t=${TOKEN}&t=${TOKEN}`, `#token=${TOKEN}`]) { + assert.equal(tokenAusAdresse('', hash), '', hash) + } + assert.equal(tokenAusAdresse(`?t=${TOKEN}&t=${TOKEN}`, ''), '') + assert.equal(tokenAusAdresse(undefined, undefined), '') +}) + +test('Beträge: ganze Cent als deutscher Euro-Betrag wie auf dem Server', () => { + assert.equal(euroDe(80000), `800,00${NB}€`) + assert.equal(euroDe(123456), `1.234,56${NB}€`) + assert.equal(euroDe(5), `0,05${NB}€`) + assert.equal(euroDe(0), `0,00${NB}€`) + assert.equal(euroDe(-449), `−4,49${NB}€`) + for (const kaputt of [1.5, '800', null, undefined, Number.NaN]) assert.equal(euroDe(kaputt), '–') +}) + +test('Datum und Zeit: Berliner Zeit, unabhängig von der Zeitzone des Browsers', () => { + assert.equal(datumDe('2026-10-06'), '06.10.2026') + assert.equal(datumDe('06.10.2026'), '06.10.2026') + assert.equal(datumDe('2026-10-06T22:30:00.000Z'), '07.10.2026') // 00:30 MESZ am Folgetag + for (const kaputt of ['', null, undefined, 'morgen', '2026-10', 20261006]) assert.equal(datumDe(kaputt), '') + assert.equal(zeitpunktDe(BESTAETIGT_AM), '07.10.2026, 10:15 Uhr') + assert.equal(zeitpunktDe('2026-10-07T08:15:00.000+00:00'), '07.10.2026, 10:15 Uhr') // so liefert Appwrite + assert.equal(zeitpunktDe('2026-12-01T08:15:00Z'), '01.12.2026, 09:15 Uhr') // Winterzeit + assert.equal(zeitpunktDe('2026-10-06T22:05:00Z'), '07.10.2026, 00:05 Uhr') // Mitternacht ohne „24“ + for (const kaputt of ['', null, undefined, 'bald']) assert.equal(zeitpunktDe(kaputt), '') +}) + +test('Links: PDF-Pfad aus dem Token, AGB nur als PDF unter /rechtliches/', () => { + assert.equal(pdfPfad(TOKEN), `/api/auftrag/${TOKEN}/pdf`) + assert.equal(agbPfad('/rechtliches/agb-2026-09-30.pdf'), '/rechtliches/agb-2026-09-30.pdf') + for (const boese of ['https://evil.example/agb.pdf', '//evil.example/agb.pdf', '/rechtliches/../x.pdf', '/rechtliches/agb.pdf?x=1', '/rechtliches/agb.html', 'javascript:alert(1)', '', null, undefined, 5]) { + assert.equal(agbPfad(boese), '', String(boese)) + } +}) + +test('Häkchen: Frühbeginn nur mit Text vom Server; Bestätigen nur, solange nicht bestätigt', () => { + assert.equal(zeigtFruehbeginn(privat()), true) + assert.equal(zeigtFruehbeginn(ab()), false) + assert.equal(zeigtFruehbeginn(privat({ texte: { bestaetigung: 'x', fruehbeginn: ' ' } })), false) + assert.equal(zeigtFruehbeginn({}), false) + assert.equal(darfBestaetigen(ab()), true) + assert.equal(darfBestaetigen(ab({ status: 'abgerechnet' })), true) // Schlussrechnung vor der Bestätigung (Übersicht V2) + assert.equal(darfBestaetigen(bestaetigt(ab())), false) + assert.equal(darfBestaetigen(ab({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM })), false) + for (const status of ['storniert', 'ersetzt', 'widerrufen', 'entwurf']) assert.equal(darfBestaetigen(ab({ status })), false, status) +}) + +test('Status: Beschriftung und Ton; „Bitte bestätigen“ nur mit kannBestaetigen und ohne Bestätigung', () => { + assert.deepEqual(statusAnzeige({ status: 'versendet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' }) + assert.deepEqual(statusAnzeige({ status: 'abgerechnet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' }) + assert.deepEqual(statusAnzeige({ status: 'versendet' }), { text: 'Versendet', ton: 'muted' }) + assert.deepEqual(statusAnzeige({ status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM, kannBestaetigen: false }), { text: 'Bestätigt', ton: 'ok' }) + assert.deepEqual(statusAnzeige({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM }), { text: 'Abgerechnet', ton: 'ok' }) + assert.deepEqual(statusAnzeige({ status: 'storniert' }), { text: 'Storniert', ton: 'muted' }) + assert.deepEqual(statusAnzeige({ status: 'ersetzt' }), { text: 'Ersetzt', ton: 'muted' }) + assert.deepEqual(statusAnzeige({ status: 'widerrufen' }), { text: 'Widerrufen', ton: 'rot' }) + assert.deepEqual(statusAnzeige({ status: 'unbekannt' }), { text: '–', ton: 'muted' }) + assert.deepEqual(statusAnzeige(undefined), { text: '–', ton: 'muted' }) +}) + +test('Karten-Zeile und Sortierung der Portalliste', () => { + assert.equal(kartenMeta(ab()), `vom 06.10.2026 · 800,00${NB}€`) + assert.equal(kartenMeta(ab({ art: 'aufwand', positionen: [], summeCents: null })), 'vom 06.10.2026 · nach Aufwand') + assert.equal(kartenMeta(ab({ datum: '' })), `800,00${NB}€`) + assert.equal(kartenMeta({}), '') + const liste = [ + { nummer: 'AB-2026-0001', kannBestaetigen: false }, { nummer: 'AB-2026-0003', kannBestaetigen: false }, + { nummer: 'AB-2026-0002', kannBestaetigen: true }, { nummer: 'AB-2025-0009', kannBestaetigen: false }, + ] + assert.deepEqual(sortiereAuftraege(liste).map((a) => a.nummer), ['AB-2026-0002', 'AB-2026-0003', 'AB-2026-0001', 'AB-2025-0009']) + assert.equal(liste[0].nummer, 'AB-2026-0001', 'die Eingabe bleibt unverändert') + assert.deepEqual(sortiereAuftraege(undefined), []) +}) + +test('Zusammenfassung (Festpreis): Kopf, Positionen, Summe, Zahlungsplan, Termine, § 19 aus der API', () => { + assert.deepEqual(zusammenfassung(ab()), { + kopf: [{ label: 'Nummer', wert: 'AB-2026-0002' }, { label: 'Datum', wert: '06.10.2026' }, { label: 'Projekt', wert: 'Website Anelia Wolf' }], + art: 'festpreis', + positionen: [ + { beschreibung: 'Erstellung der Website anelia-wolf.com', menge: '1', einzelpreis: `600,00${NB}€`, summe: `600,00${NB}€` }, + { beschreibung: 'Texte und Bildbearbeitung', menge: '2', einzelpreis: `100,00${NB}€`, summe: `200,00${NB}€` }, + ], + summe: `800,00${NB}€`, + aufwand: '', + zahlung: ab().zahlungsplanText, + termine: [{ label: 'Start', wert: '12.10.2026' }, { label: 'Voraussichtliche Fertigstellung', wert: '30.11.2026' }], + hinweis: KLEINUNTERNEHMER, + }) +}) + +test('Zusammenfassung (Aufwand): Aufwandstext statt Positionen; Termine nur, wenn gesetzt; ohne Satz der API kein § 19-Hinweis; leere Eingabe wirft nicht', () => { + const z = zusammenfassung(ab({ + art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', + zahlungsplanText: 'Monatlich nach Aufwand.', startAm: '2026-10-12', fertigBis: '', + })) + assert.equal(z.art, 'aufwand') + assert.deepEqual(z.positionen, []) + assert.equal(z.summe, '') + assert.equal(z.aufwand, '100,00 € pro Stunde in Schritten von 15 Minuten.') + assert.equal(z.zahlung, 'Monatlich nach Aufwand.') + assert.deepEqual(z.termine, [{ label: 'Start', wert: '12.10.2026' }]) + assert.equal(z.hinweis, KLEINUNTERNEHMER) + assert.equal(zusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })).hinweis, '', 'keine eigene Ersatzkonstante: der Satz kommt von der API') + assert.deepEqual(zusammenfassung({}), { kopf: [], art: 'festpreis', positionen: [], summe: '', aufwand: '', zahlung: '', termine: [], hinweis: '' }) + assert.doesNotThrow(() => zusammenfassung(undefined)) +}) + +test('Bestätigt-Zeilen: Zeitpunkt, bei Privatpersonen Frühbeginn bzw. frühester Beginn, in beiden Anreden', () => { + assert.deepEqual(bestaetigtZeilen(ab()), []) + assert.deepEqual(bestaetigtZeilen(bestaetigt(ab())), ['Bestätigt am 07.10.2026, 10:15 Uhr']) + assert.deepEqual(bestaetigtZeilen(bestaetigt(ab({ fruehesterBeginn: '2026-10-21' }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'], 'Unternehmen: keine Zeile zum Beginn') + assert.deepEqual(bestaetigtZeilen(bestaetigt(privat())), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.']) + const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) + assert.deepEqual(bestaetigtZeilen(wunsch), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.deepEqual(bestaetigtZeilen(wunsch, ANREDE_DU), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.deepEqual(bestaetigtZeilen(bestaetigt(privat({ fruehesterBeginn: null }))), ['Bestätigt am 07.10.2026, 10:15 Uhr']) + assert.deepEqual(bestaetigtZeilen({ bestaetigtAm: 'kaputt' }), ['Bestätigt']) +}) + +test('Zustände aus der Serverantwort: feste Texte, der 410-Grund nur aus dem Stichwort', () => { + const kontakt = 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.' + assert.deepEqual(zustandAusAntwort(404, { error: 'Dieser Link ist ungültig oder abgelaufen.' }), { zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', text: kontakt }) + assert.deepEqual(zustandAusAntwort(410, { error: 'Diese Auftragsbestätigung wurde ersetzt.' }), { + zustand: 'beendet', titel: 'Diese Auftragsbestätigung wurde ersetzt.', text: 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.', + }) + for (const grund of ['storniert', 'widerrufen']) { + assert.deepEqual(zustandAusAntwort(410, { error: `Diese Auftragsbestätigung wurde ${grund}.` }), { + zustand: 'beendet', titel: `Diese Auftragsbestätigung wurde ${grund}.`, text: 'Bei Fragen melden Sie sich gern bei uns.', + }) + } + for (const body of [{ error: `Irgendwas mit ${TOKEN}` }, {}, undefined, null]) { // fremder Text (auch mit Token) wird nie angezeigt + const z = zustandAusAntwort(410, body) + assert.equal(z.titel, 'Diese Auftragsbestätigung ist nicht mehr gültig.') + assert.equal(z.text, 'Bei Fragen melden Sie sich gern bei uns.') + } + assert.deepEqual(zustandAusAntwort(429, { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }), { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' }) + for (const status of [0, 400, 401, 500, 502, 503]) { + assert.deepEqual(zustandAusAntwort(status, { error: 'Interner Fehler.' }), { + zustand: 'fehler', titel: 'Das hat leider nicht geklappt', text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.', + }) + } +}) + +test('Fehlermeldungen beim Bestätigen: feste eigene Texte nach Status, Sie auf der Seite und Du im Portal', () => { + assert.equal(fehlerMeldung(0), 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.') + assert.equal(fehlerMeldung(400), 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.') + assert.equal(fehlerMeldung(401), 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.') + for (const status of [403, 409]) assert.equal(fehlerMeldung(status), 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.') + assert.equal(fehlerMeldung(429), 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.') + for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.') + assert.equal(fehlerMeldung(0, ANREDE_DU), 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.') + assert.equal(fehlerMeldung(400, ANREDE_DU), 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.') + assert.equal(fehlerMeldung(401, ANREDE_DU), 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.') + for (const status of [403, 409]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.') + assert.equal(fehlerMeldung(429, ANREDE_DU), 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.') + for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.') +}) + +const werte = (objekt) => Object.values(objekt).flatMap((wert) => (wert && typeof wert === 'object' ? werte(wert) : [wert])).filter((wert) => typeof wert === 'string') + +test('Anrede: gemeinsame Texte neutral, öffentliche Seite Sie, Portal Du; jede Du-Variante hat ihr Sie-Gegenstück', () => { + assert.equal(ANREDE_SIE, 'sie') + assert.equal(ANREDE_DU, 'du') + for (const text of werte(TEXTE_GEMEINSAM)) assert.ok(!hatDuForm(text) && !hatSieForm(text), text) + for (const text of werte(TEXTE_SIE)) assert.ok(!hatDuForm(text), text) + for (const text of werte(TEXTE_DU)) assert.ok(!hatSieForm(text), text) + assert.deepEqual(Object.keys(TEXTE_DU).sort(), ['fehler', 'formular', 'fruehbeginnWunsch', 'speicherHinweis']) + for (const schluessel of Object.keys(TEXTE_DU)) assert.ok(schluessel in TEXTE_SIE, schluessel) + assert.deepEqual(Object.keys(TEXTE_DU.fehler), Object.keys(TEXTE_SIE.fehler)) + assert.equal(texte().formular, 'Ihre Bestätigung', 'ohne Angabe gilt Sie') + assert.equal(texte(ANREDE_SIE).speicherHinweis, 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.equal(texte(ANREDE_DU).formular, 'Deine Bestätigung') + assert.equal(texte(ANREDE_DU).speicherHinweis, 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.equal(texte(ANREDE_DU).knopf, 'Auftrag bestätigen', 'gemeinsame Texte gibt es in beiden Anreden') + assert.equal(texte(ANREDE_DU).ansichtsmodus, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') +}) + +test('AB-Nummer: Schreibweisen der Praxis werden zu AB-JJJJ-NNNN, alles andere zu leer', () => { + assert.equal(abNummerNormalisiert('AB-2026-0001'), 'AB-2026-0001') + assert.equal(abNummerNormalisiert(' ab-2026-0001 '), 'AB-2026-0001') + assert.equal(abNummerNormalisiert('ab 2026 0001'), 'AB-2026-0001') + assert.equal(abNummerNormalisiert('AB – 2026 – 0001'), 'AB-2026-0001') // Halbgeviertstrich mit Leerzeichen + assert.equal(abNummerNormalisiert('AB−2026−0001'), 'AB-2026-0001') // Minuszeichen + for (const kaputt of ['', ' ', 'AB-2026-01', 'AB-26-0001', 'RE-2026-0001', 'AB-2026-0001-1', 'AB20260001', undefined, null]) assert.equal(abNummerNormalisiert(kaputt), '', String(kaputt)) +}) + +test('Quelltext und Kontakt: reines Modul ohne Importe, fetch und DOM; Kontaktdaten wie in den Rechtstexten', () => { + pruefeQuelltext('auftrag-ansicht.js', { mitTexten: true }) + assert.deepEqual(KONTAKT, { email: 'support@webklar.com', telefon: '0176 23726355' }) +}) diff --git a/test/auftraegeKundenportalDaten.js b/test/auftraegeKundenportalDaten.js new file mode 100644 index 0000000..a55685a --- /dev/null +++ b/test/auftraegeKundenportalDaten.js @@ -0,0 +1,65 @@ +// Gemeinsame Testdaten und Quelltext-Regeln des Strangs L6 (kein Test, daher nicht *.test.js). +// Formen laut Übersicht V3 (AuftragOeffentlich) und V5 (Liste im Portal: plus id und kannBestaetigen). +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +export const TOKEN = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG' // 43 Zeichen wie ein echter Token (base64url) +export const BESTAETIGT_AM = '2026-10-07T08:15:00.000Z' // = 07.10.2026, 10:15 Uhr Berliner Zeit (MESZ) +export const KLEINUNTERNEHMER = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' // wie AB_TEXTE.kleinunternehmer; die API liefert ihn als texte.kleinunternehmer + +/** Festpreis-AB eines Unternehmens, gesendet, noch nicht bestätigt. */ +export const ab = (over = {}) => ({ + nummer: 'AB-2026-0002', datum: '2026-10-06', titel: 'Website Anelia Wolf', art: 'festpreis', + positionen: [ + { beschreibung: 'Erstellung der Website anelia-wolf.com', menge: 1, einzelpreisCents: 60000, summeCents: 60000 }, + { beschreibung: 'Texte und Bildbearbeitung', menge: 2, einzelpreisCents: 10000, summeCents: 20000 }, + ], + summeCents: 80000, stundensatzCents: null, schaetzungMinuten: null, aufwandText: null, + zahlungsplanText: 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.', + startAm: '2026-10-12', fertigBis: '2026-11-30', kundenart: 'unternehmen', status: 'versendet', + bestaetigtAm: null, fruehbeginn: false, fruehesterBeginn: null, agbUrl: '/rechtliches/agb-2026-09-30.pdf', + texte: { bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', fruehbeginn: null, kleinunternehmer: KLEINUNTERNEHMER }, + ...over, +}) + +/** Dieselbe AB für eine Privatperson (zweites, freiwilliges Häkchen; Texte sind Testtexte, nicht die der Rechtsrunde). */ +export const privat = (over = {}) => ab({ + kundenart: 'privat', fruehesterBeginn: '2026-10-21', + texte: { + bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', + fruehbeginn: 'Ich verlange, dass Sie schon vor Ablauf der Widerrufsfrist mit der Arbeit beginnen.', + kleinunternehmer: KLEINUNTERNEHMER, + }, + ...over, +}) + +/** Die übergebene AB im Zustand „bestätigt“. */ +export const bestaetigt = (auftrag) => ({ ...auftrag, status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM }) + +/** Eintrag der Portal-Liste (GET /api/auftraege): AB plus id und kannBestaetigen. */ +export const eintrag = (over = {}) => ({ id: 'ab1', ...ab(), kannBestaetigen: true, ...over }) + +const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1') +const DU = /\b(?:du|dein(?:e[nmrs]?)?|dich|dir)\b/i +const SIE = /\b(?:Sie|Ihr(?:e[nmrs]?)?|Ihnen)\b/ +/** Steht eine Du- bzw. Sie-Form im Text? (Anrede-Prüfungen der Tests) */ +export const hatDuForm = (text) => DU.test(String(text)) +export const hatSieForm = (text) => SIE.test(String(text)) + +/** + * Quelltext-Regeln für jedes Modul public/auftrag*.js; gibt den Quelltext ohne Kommentare zurück. + * mitTexten: nur auftrag-ansicht.js enthält Kundentexte (ihre Anrede prüft der Test selbst); alle anderen Module enthalten weder Du- noch Sie-Formen. + */ +export function pruefeQuelltext(datei, { fetchAnzahl = 0, importe = [], mitTexten = false } = {}) { + const quelle = ohneKommentare(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8')) + assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(|new Function/, `${datei}: Daten nie als HTML`) + assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie|indexedDB/, `${datei}: kein Browserspeicher`) + assert.doesNotMatch(quelle, /\bconsole\./, `${datei}: nichts ins Protokoll (der Token!)`) + assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: keine Browser-Rückfragen`) + assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`) + assert.ok(!quelle.includes(String.fromCodePoint(0xa0)), `${datei}: kein verstecktes geschütztes Leerzeichen im Quelltext`) + if (!mitTexten) assert.ok(!DU.test(quelle) && !hatSieForm(quelle), `${datei}: keine Kundentexte im Modul (sie stehen in auftrag-ansicht.js; Anrede über ANREDE_SIE/ANREDE_DU)`) + assert.equal([...quelle.matchAll(/\bfetch\(/g)].length, fetchAnzahl, `${datei}: Anzahl fetch`) + assert.deepEqual([...quelle.matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1]), importe, `${datei}: Importe`) + return quelle +}