Kundenportal Aufträge: reine Ansichtslogik (Token, Beträge, Zustände, Status, AB-Nummer)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:12:34 +00:00
1 parent 987268e2f0
commit e6a0fc9acf
3 files changed
+536

No files matched your search

+260
View File
@@ -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 : ''
}
+211
View File
@@ -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' })
})
+65
View File
@@ -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
}