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:
1 parent
987268e2f0
commit
e6a0fc9acf
3 files changed
+536
No files matched your search
@@ -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 : ''
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user