diff --git a/server/services/zeiten/pruefen.js b/server/services/zeiten/pruefen.js new file mode 100644 index 0000000..9af6b3c --- /dev/null +++ b/server/services/zeiten/pruefen.js @@ -0,0 +1,601 @@ +import { randomInt } from 'node:crypto' +import { text } from '../termine/einstellungen.js' +import { BUNDESLAENDER } from '../termine/feiertage.js' +import { heute, plusTage } from '../termine/zeit.js' + +/** + * Oeffnungszeiten live: Pruefen und Normalisieren (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 2.1). + * Rein: kein Appwrite, keine Config, kein eigener Zeitgeber ('now' ist Pflicht). Jede Speicherung (Kunde, Kundenansicht, + * Team) geht durch normalisiereZeiten / normalisiereTeam; gespeichert wird nur das Ergebnis. + * + * Ergebnis: { daten } bzw. { team } oder { fehler: { [pfad]: text } } (je Pfad die erste Meldung, Texte in Du-Form). + * Pfade tragen die IDs der Eintraege: 'plaene..name', 'plaene..tage.3', 'ausnahmen..von', + * 'hinweise..knopf.ziel', 'kontakt.telefon', 'daten' (zu gross). Eintraege ohne Objektform haben nur einen Index + * ('ausnahmen.4'); ein Eintrag ohne gueltige ID bekommt eine neue und der Pfad nennt diese. + * + * Kanonische Form (gespeichert wird nur sie, damit gleiche Zeiten gleich aussehen und wochenplanHash stabil bleibt): + * - je Tag/Eintrag modus 'zu'|'zeiten'|'rund'|'vereinbarung'; fenster nur bei 'zeiten' (dort 1-2 Fenster, nach Beginn + * sortiert), sonst immer []; alle sieben Wochentage sind da (fehlende = zu); + * - unbekannte Schluessel fallen ueberall weg; Texte ohne Steuerzeichen, Zeitpunkte als toISOString; + * - ausnahmen und hinweise BLEIBEN in Eingabereihenfolge (Gleichstand in der Zeitlogik: der spaetere Index gewinnt), + * feiertage ist nach Datum sortiert; Plaene behalten ihre Reihenfolge. + * + * Ausnahmen/Hinweise mit Ende vor heute - 30 Tagen und feiertage-Schluessel vor diesem Tag werden entfernt (nach der Pruefung). + */ + +export const ZEITEN_LIMITS = Object.freeze({ plaene: 5, fensterJeTag: 2, ausnahmen: 100, hinweise: 20, urlaubTage: 366, vergangenTage: 30, daten: 32768 }) +export const ZEITEN_MAX = Object.freeze({ hinweisText: 140, name: 60, ausnahmeText: 60, knopfText: 30, starttext: 200, adresse: 200, telefon: 30, link: 300, kurz: 24, navSelektor: 120 }) + +const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung']) +const AUSNAHME_ARTEN = Object.freeze(['urlaub', 'sondertag']) +const HINWEIS_ARTEN = Object.freeze(['info', 'urlaub', 'aktion', 'wichtig']) +const KNOPF_ARTEN = Object.freeze(['link', 'anruf']) +const STANDARD_LAND = 'RP' +const DATEN_ZU_GROSS = 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.' + +// Obergrenzen fuer Rohdaten, damit uebergrosse Eingaben nie teuer werden (Pruefung kommt danach mit der echten Grenze). +const ROH_TEXT = 5000 +const ROH_LISTE = 1000 + +const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' +const ID_RE = /^[A-Za-z0-9_-]{1,40}$/ +const KURZ_RE = /^[a-z0-9-]{1,24}$/ +const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ +// Zeitpunkt mit Zeitzone ('Z' oder +hh:mm), Sekunden optional; der Kalendertag wird gesondert geprueft (Date rollt 30.02. um). +const ZEITPUNKT_RE = /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/ +const NAV_RE = /^[A-Za-z0-9 #.\-_>:[\]="']+$/ + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const fehltWert = (wert) => wert === undefined || wert === null + +/** Ist t ein echter Kalendertag 'YYYY-MM-DD' (kein 30.02.)? */ +const istTag = (t) => typeof t === 'string' && TAG_RE.test(t) && plusTage(t, 0) === t + +/** Kalendertage von bis zu (jeweils 'YYYY-MM-DD'), ohne Tages-Millisekunden-Annahmen ueber die Umstellung zu brauchen (UTC-Mitternacht). */ +const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000) + +const hhmm = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}` + +// ---------------------------------------------------------------- IDs und Kurznamen + +function neueId(praefix) { + let zeichen = '' + for (let i = 0; i < 8; i += 1) zeichen += ID_ALPHABET[randomInt(ID_ALPHABET.length)] + return `${praefix}_${zeichen}` +} + +/** + * IDs fuer eine Liste: gueltige, in der Liste noch nicht gesehene bleiben, alle anderen (fehlend, falsches Format, + * doppelt) werden neu erzeugt (kryptografisch, praefix_ + 8 Zeichen a-z0-9) und kollidieren nie mit einer gegebenen. + */ +function ausweisen(liste, praefix) { + const gesehen = new Set() + const gegeben = liste.map((e) => { + const id = istObjekt(e) ? e.id : undefined + if (typeof id === 'string' && ID_RE.test(id) && !gesehen.has(id)) { + gesehen.add(id) + return id + } + return null + }) + return gegeben.map((id) => { + if (id) return id + let neu + do { neu = neueId(praefix) } while (gesehen.has(neu)) + gesehen.add(neu) + return neu + }) +} + +/** + * Kurzname [a-z0-9-]{1,24} aus dem Namen: Umlaute ae/oe/ue/ss, Akzente weg, alles Uebrige ein Bindestrich; leer = 'plan'; + * ist er in vorhandene (Array oder Set), kommt -2, -3, ... davor (immer <= 24 Zeichen). Gleiche Regel im Editor (Task 27). + */ +export function kurzname(name, vorhandene = []) { + const belegt = vorhandene instanceof Set ? vorhandene : new Set(vorhandene) + let basis = (typeof name === 'string' ? name : '') + .toLowerCase() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .normalize('NFKD').replace(/\p{M}+/gu, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + basis = basis.slice(0, ZEITEN_MAX.kurz).replace(/-+$/, '') + if (basis === '') basis = 'plan' + if (!belegt.has(basis)) return basis + for (let n = 2; ; n += 1) { + const endung = `-${n}` + const kandidat = `${basis.slice(0, ZEITEN_MAX.kurz - endung.length).replace(/-+$/, '')}${endung}` + if (!belegt.has(kandidat)) return kandidat + } +} + +// ---------------------------------------------------------------- Bausteine zum Pruefen + +/** + * Bereinigter Text (text() aus dem Terminplaner: ohne Steuerzeichen, getrimmt, Umbrueche = Leerzeichen); zu lang ist ein + * Fehler, nie ein stilles Kuerzen. Fehlt der Wert, ist das Ergebnis '' (mit pflicht ein Fehler). Fehler liefern ''. + */ +function feldText(fehl, pfad, wert, max, { pflicht = false, leer = 'Bitte fülle dieses Feld aus.' } = {}) { + if (fehltWert(wert)) { + if (pflicht) fehl(pfad, leer) + return '' + } + if (typeof wert !== 'string') { + fehl(pfad, 'Bitte gib einen Text an.') + return '' + } + const s = wert.length > ROH_TEXT ? null : text(wert, ROH_TEXT) + if (s === null || [...s].length > max) { + fehl(pfad, `Höchstens ${max} Zeichen.`) + return '' + } + if (pflicht && s === '') fehl(pfad, leer) + return s +} + +function wahrheit(fehl, pfad, wert, vorgabe) { + if (fehltWert(wert)) return vorgabe + if (typeof wert !== 'boolean') { + fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.') + return vorgabe + } + return wert +} + +/** Kalendertag oder null: fehlend/leer = null; ungueltig = Fehler (und null). */ +function tagOptional(fehl, pfad, wert) { + if (fehltWert(wert) || wert === '') return null + if (!istTag(wert)) { + fehl(pfad, 'Bitte gib ein gültiges Datum an.') + return null + } + return wert +} + +/** Zeitpunkt (ISO mit Zeitzone) als toISOString oder null. */ +function zeitpunkt(wert) { + const m = typeof wert === 'string' ? ZEITPUNKT_RE.exec(wert) : null + if (!m || !istTag(m[1])) return null + const ms = Date.parse(wert) + return Number.isFinite(ms) ? new Date(ms).toISOString() : null +} + +// ---------------------------------------------------------------- Telefon und Links + +/** + * Telefonnummer -> nur Ziffern mit Laendervorwahl (deutsche Nummern mit fuehrendem 49), sonst null. + * '0171 1234567', '+49 171 1234567', '0049 171 1234567', '+49 (0) 171 1234567' -> '491711234567'; '(06 31) 12 34' -> '496311234'. + * Erlaubt sind Ziffern, Leerzeichen, ( ) / . - und ein '+' ganz vorn; Buchstaben und alles andere = null. Eine Nummer ohne + * 0, 00 oder + vorn gilt als schon international (so bleibt ein gespeichertes '491711234567' beim erneuten Speichern gleich); + * 6 bis 15 Ziffern insgesamt (E.164). + */ +export function telefonZiffern(eingabe) { + if (typeof eingabe !== 'string' || eingabe.length > 100) return null + let s = eingabe.replace(/\s+/g, ' ').trim() + if (!/^\+?[0-9 ()/.-]+$/.test(s)) return null + s = s.replace(/^(\+ ?49|00 ?49) ?\( ?0 ?\)/, '$1').replace(/[()]/g, '') + const ziffern = s.replace(/\D/g, '') + let nummer + if (s.startsWith('+')) { + nummer = ziffern + } else if (ziffern.startsWith('00')) { + nummer = ziffern.slice(2) + } else if (ziffern.startsWith('0')) { + nummer = `49${ziffern.slice(1)}` + } else { + nummer = ziffern + } + if (nummer.startsWith('0')) return null + if (nummer.startsWith('490')) nummer = `49${nummer.slice(3)}` // '+49 0171 …' (haeufiger Tippfehler) + return nummer.length >= 6 && nummer.length <= 15 ? nummer : null +} + +const URL_MELDUNG = 'Bitte gib eine vollständige Adresse mit https:// an (höchstens 300 Zeichen, ohne Zugangsdaten).' + +// Hostname wie ihn URL liefert (klein, Umlaute als Punycode): Labels aus a-z, 0-9 und Bindestrich, oder IPv6 in Klammern. +const HOST_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])$/ + +/** + * https-URL (<= 300, ohne Zugangsdaten, nur ASCII-Hostname) in kanonischer Schreibweise. Diese Adressen landen als href + * auf oeffentlichen Seiten: Anfuehrungszeichen, Apostroph und Backtick im Host werden abgelehnt, ein Apostroph im Pfad + * wird als %27 gespeichert (URL kodiert ihn dort nicht selbst). Wie urlFeld in termine/einstellungen.js (dort nicht exportiert). + */ +function linkZiel(fehl, pfad, wert) { + const s = typeof wert === 'string' && wert.length <= ROH_TEXT ? text(wert, ROH_TEXT) : '' + let url = null + if (s !== '' && s.length <= ZEITEN_MAX.link && !/\s/.test(s)) { + try { url = new URL(s) } catch { url = null } + } + const href = url ? url.href.replace(/'/g, '%27') : '' + if (!url || url.protocol !== 'https:' || url.username || url.password || !HOST_RE.test(url.hostname) || href.length > ZEITEN_MAX.link) { + fehl(pfad, URL_MELDUNG) + return '' + } + return href +} + +/** Rufnummer eines Anruf-Knopfs: nur Ziffern, Leerzeichen, / und -, ein '+' nur vorn, mindestens eine Ziffer, <= 30 Zeichen; so gespeichert wie getippt. */ +function anrufZiel(fehl, pfad, wert) { + const s = typeof wert === 'string' && wert.length <= ROH_TEXT ? text(wert, ROH_TEXT) : '' + if (s === '' || [...s].length > ZEITEN_MAX.telefon || !/^\+?[0-9 /-]+$/.test(s) || !/\d/.test(s)) { + fehl(pfad, 'Bitte gib eine Telefonnummer an (nur Ziffern, Leerzeichen, + / und -, höchstens 30 Zeichen).') + return '' + } + return s +} + +/** Telefonfeld des Kontakts: leer = ''; sonst Ziffern mit Laendervorwahl. */ +function telefonFeld(fehl, pfad, wert, { pflicht = false } = {}) { + if (fehltWert(wert) && !pflicht) return '' + const s = feldText(fehl, pfad, wert, ZEITEN_MAX.telefon, { pflicht, leer: 'Bitte gib die Nummer an.' }) + if (s === '') return '' + const ziffern = telefonZiffern(s) + if (ziffern === null) { + fehl(pfad, 'Bitte gib eine gültige Telefonnummer an (nur Ziffern, Leerzeichen, + ( ) / . und -).') + return '' + } + return ziffern +} + +// ---------------------------------------------------------------- Zeitfenster + +/** 'HH:MM' -> Minuten (5er-Raster; als Ende bis 24:00), sonst null. */ +function minuten(wert, istEnde) { + const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null + if (!m) return null + const h = Number(m[1]) + const min = Number(m[2]) + if (h > 24 || min > 59 || min % 5 !== 0 || (h === 24 && (min !== 0 || !istEnde))) return null + return h * 60 + min +} + +/** + * Fenster eines Tages: { fehler } oder { fenster (sortiert, 'HH:MM'), ersteVon (Minuten|null), nachtBis (Minuten|null) }. + * Regeln: <= 2 Fenster, 5er-Raster, bis == von ungueltig, ueberlappen verboten (Beruehren ist erlaubt), bis < von + * (ueber Mitternacht) nur beim letzten Fenster. nachtBis = Ende des letzten Fensters, wenn es ueber Mitternacht geht + * (der Aufrufer prueft ggf. gegen den Folgetag). + */ +function pruefeFenster(wert) { + if (!Array.isArray(wert)) return { fehler: 'Ungültige Zeitfenster.' } + if (wert.length > ZEITEN_LIMITS.fensterJeTag) return { fehler: `Pro Tag sind höchstens ${ZEITEN_LIMITS.fensterJeTag} Zeitfenster möglich.` } + const f = [] + for (const eintrag of wert) { + const von = Array.isArray(eintrag) && eintrag.length === 2 ? minuten(eintrag[0], false) : null + const bis = von === null ? null : minuten(eintrag[1], true) + if (von === null || bis === null) return { fehler: 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.' } + if (bis === von) return { fehler: 'Beginn und Ende dürfen nicht gleich sein.' } + f.push({ von, bis }) + } + f.sort((a, b) => a.von - b.von) + for (let i = 0; i < f.length; i += 1) { + if (f[i].bis < f[i].von && i < f.length - 1) return { fehler: 'Ein Fenster über Mitternacht ist nur als letztes Fenster des Tages möglich.' } + if (i > 0 && f[i].von < f[i - 1].bis) return { fehler: 'Die Zeitfenster überschneiden sich.' } + } + const letztes = f[f.length - 1] + return { + fenster: f.map((w) => [hhmm(w.von), hhmm(w.bis)]), + ersteVon: f.length ? f[0].von : null, + nachtBis: letztes && letztes.bis < letztes.von ? letztes.bis : null, + } +} + +/** + * Modus + Fenster eines Tages/Eintrags: { modus, fenster, ersteVon, nachtBis } oder null (Fehler gemeldet). + * Ein gueltiger Modus ist Pflicht; 'zeiten' braucht mindestens ein Fenster; bei jedem anderen Modus wird fenster []. + */ +function modusUndFenster(fehl, modusPfad, fensterPfad, modus, fensterRoh) { + if (!MODI.includes(modus)) { + fehl(modusPfad, 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.') + return null + } + if (modus !== 'zeiten') return { modus, fenster: [], ersteVon: null, nachtBis: null } + const r = pruefeFenster(fensterRoh) + if (r.fehler) { + fehl(fensterPfad, r.fehler) + return null + } + if (r.fenster.length === 0) { + fehl(fensterPfad, 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.') + return null + } + return { modus, fenster: r.fenster, ersteVon: r.ersteVon, nachtBis: r.nachtBis } +} + +// ---------------------------------------------------------------- Abschnitte + +function normalisierePlaene(fehl, roh, alt) { + const keine = { liste: [], ids: null } + if (!Array.isArray(roh) || roh.length === 0) { + fehl('plaene', 'Bitte lege mindestens einen Zeitplan an.') + return keine + } + if (roh.length > ZEITEN_LIMITS.plaene) { + fehl('plaene', `Es sind höchstens ${ZEITEN_LIMITS.plaene} Zeitpläne möglich.`) + return keine + } + if (roh.filter((p) => istObjekt(p) && p.haupt === true).length !== 1) fehl('plaene', 'Genau ein Zeitplan muss der Hauptplan sein.') + + // Kurznamen sind unveraenderlich (Platzhalter auf Kundenseiten nennen sie): bekannte ID = bisheriger Kurzname. + // Auch die Kurznamen frueherer, inzwischen geloeschter Plaene bleiben belegt, damit ein neuer Plan nie unter einem + // alten Platzhalter erscheint (kein stilles Umschalten). + const bisherKurz = new Map() + const belegt = new Set() + for (const p of Array.isArray(alt?.plaene) ? alt.plaene : []) { + if (istObjekt(p) && typeof p.id === 'string' && typeof p.kurz === 'string' && KURZ_RE.test(p.kurz)) { + bisherKurz.set(p.id, p.kurz) + belegt.add(p.kurz) + } + } + + const idListe = ausweisen(roh, 'p') + const liste = [] + roh.forEach((p, i) => { + const id = idListe[i] + if (!istObjekt(p)) return fehl(`plaene.${i}`, 'Ungültiger Eintrag.') + const pfad = `plaene.${id}` + const name = feldText(fehl, `${pfad}.name`, p.name, ZEITEN_MAX.name, { pflicht: true, leer: 'Bitte gib dem Zeitplan einen Namen.' }) + let kurz = bisherKurz.get(id) + if (kurz === undefined) { + kurz = kurzname(name, belegt) + belegt.add(kurz) + } + liste.push({ id, kurz, name, haupt: p.haupt === true, tage: normalisiereWoche(fehl, pfad, p.tage) }) + }) + return { liste, ids: new Set(liste.map((p) => p.id)) } +} + +/** Wochenplan eines Plans: alle Tage 1..7, Fehler je Tag; Fenster ueber Mitternacht werden gegen das erste Fenster des Folgetags geprueft (7 -> 1). */ +function normalisiereWoche(fehl, pfad, roh) { + const quelle = fehltWert(roh) ? {} : roh + if (!istObjekt(quelle)) { + fehl(`${pfad}.tage`, 'Ungültiger Wochenplan.') + return {} + } + const tage = {} + const geprueft = {} + for (let d = 1; d <= 7; d += 1) { + const tagPfad = `${pfad}.tage.${d}` + const eintrag = quelle[d] + if (fehltWert(eintrag)) { + tage[d] = { modus: 'zu', fenster: [] } + geprueft[d] = null + } else if (!istObjekt(eintrag)) { + fehl(tagPfad, 'Ungültiger Eintrag.') + tage[d] = { modus: 'zu', fenster: [] } + geprueft[d] = null + } else { + const r = modusUndFenster(fehl, tagPfad, tagPfad, eintrag.modus, eintrag.fenster) + tage[d] = r ? { modus: r.modus, fenster: r.fenster } : { modus: 'zu', fenster: [] } + geprueft[d] = r + } + } + for (let d = 1; d <= 7; d += 1) { + const heutiger = geprueft[d] + const folgetag = geprueft[(d % 7) + 1] + if (heutiger && heutiger.nachtBis !== null && folgetag && folgetag.ersteVon !== null && heutiger.nachtBis > folgetag.ersteVon) { + fehl(`${pfad}.tage.${d}`, 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.') + } + } + return tage +} + +function normalisiereFeiertage(fehl, roh, grenze) { + if (fehltWert(roh)) return {} + if (!istObjekt(roh)) { + fehl('feiertage', 'Ungültige Feiertage.') + return {} + } + const aus = {} + for (const tag of Object.keys(roh).sort()) { + const pfad = `feiertage.${tag.slice(0, 20)}` + if (!istTag(tag)) { + fehl(pfad, 'Bitte gib ein gültiges Datum an.') + continue + } + const e = roh[tag] + if (!istObjekt(e) || !MODI.includes(e.modus)) continue // nur Platzhalter ohne Abweichung: faellt weg + const r = modusUndFenster(fehl, pfad, pfad, e.modus, e.fenster) + if (r && tag >= grenze) aus[tag] = { modus: r.modus, fenster: r.fenster } + } + return aus +} + +/** Liste aus der Eingabe: fehlend = leer; Nicht-Liste oder uebergrosse Rohliste = Fehler am Listenpfad. */ +function rohListe(fehl, pfad, wert, max, meldung) { + if (fehltWert(wert)) return [] + if (!Array.isArray(wert)) { + fehl(pfad, 'Ungültige Liste.') + return [] + } + if (wert.length > ROH_LISTE) { + fehl(pfad, meldung) + return [] + } + return wert +} + +function normalisiereAusnahmen(fehl, roh, planIds, grenze) { + const meldung = `Es sind höchstens ${ZEITEN_LIMITS.ausnahmen} Ausnahmen möglich.` + const eingang = rohListe(fehl, 'ausnahmen', roh, ZEITEN_LIMITS.ausnahmen, meldung) + const idListe = ausweisen(eingang, 'x') + const aus = [] + eingang.forEach((a, i) => { + if (!istObjekt(a)) return fehl(`ausnahmen.${i}`, 'Ungültiger Eintrag.') + const id = idListe[i] + const pfad = `ausnahmen.${id}` + const art = AUSNAHME_ARTEN.includes(a.art) ? a.art : null + if (art === null) fehl(`${pfad}.art`, 'Bitte wähle „Urlaub“ oder „Sondertag“.') + const vonOk = istTag(a.von) + if (!vonOk) fehl(`${pfad}.von`, 'Bitte gib ein gültiges Datum an.') + // bis fehlt = ein Tag (bis = von) + const bisFehlt = fehltWert(a.bis) || a.bis === '' + const bisOk = bisFehlt ? vonOk : istTag(a.bis) + if (!bisFehlt && !bisOk) fehl(`${pfad}.bis`, 'Bitte gib ein gültiges Datum an.') + const von = vonOk ? a.von : '' + const bis = bisOk ? (bisFehlt ? a.von : a.bis) : '' + if (vonOk && bisOk) { + if (von > bis) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.') + else if (art === 'urlaub' && tageZwischen(von, bis) + 1 > ZEITEN_LIMITS.urlaubTage) fehl(`${pfad}.bis`, `Ein Betriebsurlaub kann höchstens ${ZEITEN_LIMITS.urlaubTage} Tage dauern.`) + } + const eintragText = feldText(fehl, `${pfad}.text`, a.text, ZEITEN_MAX.ausnahmeText) + let tag = { modus: 'zu', fenster: [] } + if (art === 'sondertag') { + const r = modusUndFenster(fehl, `${pfad}.modus`, `${pfad}.fenster`, a.modus, a.fenster) + if (r) tag = { modus: r.modus, fenster: r.fenster } + } // Urlaub ist immer geschlossen + let plan = null + if (!fehltWert(a.plan) && a.plan !== '') { + if (typeof a.plan === 'string' && (planIds === null || planIds.has(a.plan))) plan = a.plan + else fehl(`${pfad}.plan`, 'Dieser Zeitplan existiert nicht (mehr).') + } + if (bisOk && bis < grenze) return // vergangen (> 30 Tage): wird nicht mehr gespeichert + aus.push({ id, art, von, bis, text: eintragText, modus: tag.modus, fenster: tag.fenster, plan }) + }) + if (aus.length > ZEITEN_LIMITS.ausnahmen) { + fehl('ausnahmen', meldung) + return [] + } + return aus +} + +function normalisiereKnopf(fehl, pfad, roh) { + if (fehltWert(roh)) return null + if (!istObjekt(roh)) { + fehl(pfad, 'Ungültiger Knopf.') + return null + } + const leer = (w) => fehltWert(w) || (typeof w === 'string' && text(w, ROH_TEXT) === '') + if (leer(roh.ziel) && leer(roh.text)) return null // ein leerer Knopf ist kein Knopf + const art = KNOPF_ARTEN.includes(roh.art) ? roh.art : null + if (art === null) fehl(`${pfad}.art`, 'Bitte wähle „Link“ oder „Anrufen“.') + const beschriftung = feldText(fehl, `${pfad}.text`, roh.text, ZEITEN_MAX.knopfText, { pflicht: true, leer: 'Bitte gib dem Knopf eine Beschriftung.' }) + let ziel = '' + if (art === 'link') ziel = linkZiel(fehl, `${pfad}.ziel`, roh.ziel) + else if (art === 'anruf') ziel = anrufZiel(fehl, `${pfad}.ziel`, roh.ziel) + return { art, ziel, text: beschriftung } +} + +function normalisiereHinweise(fehl, roh, grenze) { + const meldung = `Es sind höchstens ${ZEITEN_LIMITS.hinweise} Hinweise möglich.` + const eingang = rohListe(fehl, 'hinweise', roh, ZEITEN_LIMITS.hinweise, meldung) + const idListe = ausweisen(eingang, 'h') + const aus = [] + eingang.forEach((h, i) => { + if (!istObjekt(h)) return fehl(`hinweise.${i}`, 'Ungültiger Eintrag.') + const id = idListe[i] + const pfad = `hinweise.${id}` + const art = HINWEIS_ARTEN.includes(h.art) ? h.art : null + if (art === null) fehl(`${pfad}.art`, 'Bitte wähle Info, Urlaub, Aktion oder Wichtig.') + const hinweisText = feldText(fehl, `${pfad}.text`, h.text, ZEITEN_MAX.hinweisText, { pflicht: true, leer: 'Bitte gib einen Text ein.' }) + const knopf = normalisiereKnopf(fehl, `${pfad}.knopf`, h.knopf) + const von = tagOptional(fehl, `${pfad}.von`, h.von) + const bisRoh = tagOptional(fehl, `${pfad}.bis`, h.bis) + const bisKaputt = !fehltWert(h.bis) && h.bis !== '' && bisRoh === null + if (von !== null && bisRoh !== null) { + if (von > bisRoh) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.') + else if (art === 'urlaub' && tageZwischen(von, bisRoh) + 1 > ZEITEN_LIMITS.urlaubTage) fehl(`${pfad}.bis`, `Ein Urlaubshinweis kann höchstens ${ZEITEN_LIMITS.urlaubTage} Tage gelten.`) + } + if (art === 'aktion' && bisRoh === null && !bisKaputt) fehl(`${pfad}.bis`, 'Eine Aktion braucht ein Enddatum.') + if (bisRoh !== null && bisRoh < grenze) return // vergangen (> 30 Tage) + aus.push({ id, art, text: hinweisText, knopf, von, bis: bisRoh }) + }) + if (aus.length > ZEITEN_LIMITS.hinweise) { + fehl('hinweise', meldung) + return [] + } + return aus +} + +function normalisiereKontakt(fehl, roh) { + const vorgabe = { an: false, telefon: '', adresse: '', whatsapp: null } + if (fehltWert(roh)) return vorgabe + if (!istObjekt(roh)) { + fehl('kontakt', 'Ungültige Kontaktangaben.') + return vorgabe + } + return { + an: wahrheit(fehl, 'kontakt.an', roh.an, false), + telefon: telefonFeld(fehl, 'kontakt.telefon', roh.telefon), + adresse: feldText(fehl, 'kontakt.adresse', roh.adresse, ZEITEN_MAX.adresse), + whatsapp: normalisiereWhatsapp(fehl, roh.whatsapp), + } +} + +/** + * WhatsApp: Nummer (Ziffern mit Vorwahl) und der Zeitpunkt der Datenschutz-Bestaetigung sind Pflicht. bestaetigtAt wird hier + * nur geprueft und uebernommen (ISO-Zeitpunkt mit Zeitzone, gespeichert als toISOString, beim erneuten Speichern unveraendert); + * ob die Kundenansicht ihn neu setzen oder aendern darf, entscheidet die Route (Vergleich mit bisher.kontakt.whatsapp.bestaetigtAt). + */ +function normalisiereWhatsapp(fehl, roh) { + if (fehltWert(roh)) return null + if (!istObjekt(roh)) { + fehl('kontakt.whatsapp', 'Ungültige WhatsApp-Angaben.') + return null + } + const nummer = telefonFeld(fehl, 'kontakt.whatsapp.nummer', roh.nummer, { pflicht: true }) + const starttext = feldText(fehl, 'kontakt.whatsapp.starttext', roh.starttext, ZEITEN_MAX.starttext) + const bestaetigtAt = zeitpunkt(roh.bestaetigtAt) + if (bestaetigtAt === null) fehl('kontakt.whatsapp.bestaetigtAt', 'Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast.') + return { nummer, starttext, bestaetigtAt } +} + +// ---------------------------------------------------------------- Oeffentliche Funktionen + +/** + * Prueft und normalisiert die Zeiten-Daten (Eingabe vom Client). bisher = zuletzt gespeicherte Daten (nur fuer die + * unveraenderlichen Kurznamen der Plaene); now = Pflicht (Vergangenes entfernen: Berliner Kalendertag). + * => { daten } oder { fehler: { [pfad]: text } }. Nach dem Normalisieren darf JSON.stringify(daten) hoechstens + * 32768 Zeichen lang sein (sonst fehler.daten). + */ +export function normalisiereZeiten(eingabe, { bisher = null, now } = {}) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereZeiten: now fehlt') + const fehler = {} + const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung } + if (!istObjekt(eingabe)) return { fehler: { daten: 'Die Zeiten sind ungültig.' } } + const alt = istObjekt(bisher) ? bisher : null + const grenze = plusTage(heute(now), -ZEITEN_LIMITS.vergangenTage) + + let land = STANDARD_LAND + if (!fehltWert(eingabe.land)) { + if (typeof eingabe.land === 'string' && BUNDESLAENDER.includes(eingabe.land)) land = eingabe.land + else fehl('land', 'Bitte wähle ein Bundesland.') + } + const { liste: plaene, ids } = normalisierePlaene(fehl, eingabe.plaene, alt) + const daten = { + land, + plaene, + feiertage: normalisiereFeiertage(fehl, eingabe.feiertage, grenze), + ausnahmen: normalisiereAusnahmen(fehl, eingabe.ausnahmen, ids, grenze), + hinweise: normalisiereHinweise(fehl, eingabe.hinweise, grenze), + kontakt: normalisiereKontakt(fehl, eingabe.kontakt), + } + if (Object.keys(fehler).length === 0 && JSON.stringify(daten).length > ZEITEN_LIMITS.daten) fehl('daten', DATEN_ZU_GROSS) + return Object.keys(fehler).length ? { fehler } : { daten } +} + +/** + * Prueft und normalisiert die Team-Einstellungen (nur ueber WOMS): { navSelektor: null|string, kontaktleisteAus, hinweisleisteAus }. + * navSelektor: <= 120 Zeichen aus [A-Za-z0-9 #.\-_>:\[\]="'] (also nie < { } ;); leer = null. + * => { team } oder { fehler: { [pfad]: text } }. + */ +export function normalisiereTeam(eingabe) { + const fehler = {} + const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung } + if (!istObjekt(eingabe)) return { fehler: { team: 'Die Team-Einstellungen sind ungültig.' } } + + let navSelektor = null + if (!fehltWert(eingabe.navSelektor)) { + const s = typeof eingabe.navSelektor === 'string' ? eingabe.navSelektor.trim() : null + if (s === null || s.length > ZEITEN_MAX.navSelektor || (s !== '' && !NAV_RE.test(s))) { + fehl('navSelektor', `Der Selektor darf höchstens ${ZEITEN_MAX.navSelektor} Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " ' enthalten.`) + } else if (s !== '') { + navSelektor = s + } + } + const team = { + navSelektor, + kontaktleisteAus: wahrheit(fehl, 'kontaktleisteAus', eingabe.kontaktleisteAus, false), + hinweisleisteAus: wahrheit(fehl, 'hinweisleisteAus', eingabe.hinweisleisteAus, false), + } + return Object.keys(fehler).length ? { fehler } : { team } +} diff --git a/test/zeitenPruefen.test.js b/test/zeitenPruefen.test.js new file mode 100644 index 0000000..f167587 --- /dev/null +++ b/test/zeitenPruefen.test.js @@ -0,0 +1,767 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + normalisiereZeiten, + normalisiereTeam, + telefonZiffern, + kurzname, +} from '../server/services/zeiten/pruefen.js' +import { BUNDESLAENDER } from '../server/services/termine/feiertage.js' +import { wochenplanHash } from '../server/services/zeiten/ersatz.js' +import { hauptplan, gueltigeHinweise, statusZu, tagesbild } from '../server/services/zeiten/zeitlogik.js' + +const NOW = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00 +const P1 = 'p_aaaaaaa1' +const P2 = 'p_aaaaaaa2' + +// Wochentage 1..7 (Mo..So). Wert: ['09:00','18:00'] = ein Fenster, [[..],[..]] = zwei Fenster, 'rund' / 'vereinbarung' = Modus, fehlt = zu. +function tage(woche = {}) { + const t = {} + for (let d = 1; d <= 7; d += 1) { + const w = woche[d] + if (!w) t[d] = { modus: 'zu', fenster: [] } + else if (typeof w === 'string') t[d] = { modus: w, fenster: [] } + else t[d] = { modus: 'zeiten', fenster: Array.isArray(w[0]) ? w : [w] } + } + return t +} + +function plan(extra = {}) { + return { id: P1, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }), ...extra } +} + +function eingabe(extra = {}) { + return { + land: 'RP', + plaene: [plan()], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, + ...extra, + } +} + +function urlaub(extra = {}) { + return { id: 'x_aaaaaaa1', art: 'urlaub', von: '2026-10-12', bis: '2026-10-16', text: 'Betriebsurlaub', modus: 'zu', fenster: [], plan: null, ...extra } +} + +function hinweis(extra = {}) { + return { id: 'h_aaaaaaa1', art: 'info', text: 'Wir haben neue Öffnungszeiten.', knopf: null, von: null, bis: null, ...extra } +} + +/** Erwartet { daten } und gibt sie zurück. */ +function gut(e, optionen = {}) { + const r = normalisiereZeiten(e, { now: NOW, ...optionen }) + assert.equal(r.fehler, undefined, `unerwartete Fehler: ${JSON.stringify(r.fehler)}`) + assert.ok(r.daten) + return r.daten +} + +/** Erwartet { fehler } und gibt sie zurück. */ +function schlecht(e, optionen = {}) { + const r = normalisiereZeiten(e, { now: NOW, ...optionen }) + assert.equal(r.daten, undefined, 'es sollten Fehler kommen') + assert.ok(r.fehler && typeof r.fehler === 'object') + return r.fehler +} + +const nurPfad = (fehler, pfad) => assert.ok(Object.hasOwn(fehler, pfad), `Pfad ${pfad} fehlt in ${JSON.stringify(fehler)}`) + +// ------------------------------------------------------------------ Grundform, IDs, Kurznamen + +test('gültiger Plan bleibt, IDs und Kurznamen bleiben unverändert', () => { + const e = eingabe({ + plaene: [ + plan({ tage: tage({ 1: ['09:00', '12:30'], 2: [['09:00', '12:30'], ['13:30', '18:00']], 3: 'rund', 4: 'vereinbarung' }) }), + plan({ id: P2, kurz: 'kueche', name: 'Küche', haupt: false, tage: tage({ 5: ['11:00', '14:00'] }) }), + ], + }) + const daten = gut(e, { bisher: { plaene: [{ id: P1, kurz: 'standard' }, { id: P2, kurz: 'kueche' }] } }) + assert.deepEqual(daten, e) + assert.deepEqual(Object.keys(daten), ['land', 'plaene', 'feiertage', 'ausnahmen', 'hinweise', 'kontakt']) +}) + +test('Eingabe wird nicht verändert', () => { + const e = eingabe({ plaene: [plan({ tage: tage({ 1: [['13:00', '18:00'], ['09:00', '12:00']] }) })] }) + const kopie = JSON.parse(JSON.stringify(e)) + gut(e) + assert.deepEqual(e, kopie) +}) + +test('Nicht-Objekt als Eingabe ist ein Fehler an daten', () => { + for (const x of [null, undefined, 'x', 5, [], true]) { + const f = schlecht(x) + nurPfad(f, 'daten') + } +}) + +test('now ist Pflicht', () => { + assert.throws(() => normalisiereZeiten(eingabe(), {}), TypeError) + assert.throws(() => normalisiereZeiten(eingabe()), TypeError) + assert.throws(() => normalisiereZeiten(eingabe(), { now: 'heute' }), TypeError) + assert.throws(() => normalisiereZeiten(eingabe(), { now: new Date('x') }), TypeError) +}) + +test('kurz ist unveränderlich: bei bekannter Plan-ID gilt der Kurzname von bisher', () => { + const e = eingabe({ plaene: [plan({ kurz: 'ganz-anders', name: 'Umbenannt' })] }) + const daten = gut(e, { bisher: { plaene: [{ id: P1, kurz: 'standard', name: 'Standard' }] } }) + assert.equal(daten.plaene[0].kurz, 'standard') + assert.equal(daten.plaene[0].name, 'Umbenannt') +}) + +test('neuer Plan bekommt Kurznamen aus dem Namen', () => { + const e = eingabe({ + plaene: [ + plan({ id: P1, kurz: 'egal', name: 'Küche', haupt: true }), + plan({ id: P2, kurz: 'egal', name: 'Küche', haupt: false }), + plan({ id: 'p_aaaaaaa3', kurz: 'egal', name: 'Küche', haupt: false }), + ], + }) + const daten = gut(e) + assert.deepEqual(daten.plaene.map((p) => p.kurz), ['kueche', 'kueche-2', 'kueche-3']) +}) + +test('ein neuer Plan nimmt keinen Kurznamen eines Plans, den es gab (auch eines gelöschten)', () => { + const e = eingabe({ plaene: [plan({ id: P2, name: 'Standard', kurz: 'x' })] }) + const daten = gut(e, { bisher: { plaene: [{ id: P1, kurz: 'standard' }] } }) + assert.equal(daten.plaene[0].kurz, 'standard-2') +}) + +test('kurzname: Umlaute, Sonderzeichen, Länge, Dubletten', () => { + assert.equal(kurzname('Küche', []), 'kueche') + assert.equal(kurzname('Küche', ['kueche']), 'kueche-2') + assert.equal(kurzname('Küche', new Set(['kueche', 'kueche-2'])), 'kueche-3') + assert.equal(kurzname('Straße & Café Öffnung', []), 'strasse-cafe-oeffnung') + assert.equal(kurzname(' --Sommer 2026!! ', []), 'sommer-2026') + assert.equal(kurzname('ÄÖÜ', []), 'aeoeue') + assert.equal(kurzname('!!!', []), 'plan') + assert.equal(kurzname('', ['plan']), 'plan-2') + assert.equal(kurzname(undefined, []), 'plan') + const lang = kurzname('a'.repeat(30), []) + assert.equal(lang.length, 24) + assert.equal(lang, 'a'.repeat(24)) + const dublette = kurzname('a'.repeat(30), [lang]) + assert.equal(dublette.length <= 24, true) + assert.match(dublette, /^a+-2$/) + assert.match(kurzname('Ein sehr langer Name mit Bindestrich am Ende hier', []), /^[a-z0-9]+(-[a-z0-9]+)*$/) + assert.ok(kurzname('Ein sehr langer Name mit Bindestrich am Ende hier', []).length <= 24) +}) + +test('fehlende oder ungültige IDs werden neu erzeugt, doppelte ebenfalls', () => { + const e = eingabe({ + plaene: [ + plan({ id: undefined, name: 'Eins' }), + plan({ id: P2, name: 'Zwei', haupt: false }), + plan({ id: P2, name: 'Drei', haupt: false }), + plan({ id: 'hat.punkt', name: 'Vier', haupt: false }), + ], + ausnahmen: [urlaub({ id: undefined }), urlaub({ id: '' })], + hinweise: [hinweis({ id: 42 })], + }) + const daten = gut(e) + const ids = daten.plaene.map((p) => p.id) + assert.match(ids[0], /^p_[a-z0-9]{8}$/) + assert.equal(ids[1], P2) + assert.match(ids[2], /^p_[a-z0-9]{8}$/) + assert.match(ids[3], /^p_[a-z0-9]{8}$/) + assert.equal(new Set(ids).size, 4) + assert.match(daten.ausnahmen[0].id, /^x_[a-z0-9]{8}$/) + assert.match(daten.ausnahmen[1].id, /^x_[a-z0-9]{8}$/) + assert.notEqual(daten.ausnahmen[0].id, daten.ausnahmen[1].id) + assert.match(daten.hinweise[0].id, /^h_[a-z0-9]{8}$/) +}) + +test('land: Vorgabe RP, nur die 16 Bundesländer', () => { + assert.equal(gut(eingabe({ land: undefined })).land, 'RP') + assert.equal(gut(eingabe({ land: null })).land, 'RP') + for (const land of BUNDESLAENDER) assert.equal(gut(eingabe({ land })).land, land) + for (const land of ['XX', 'rp', 'BUND', 5, '']) nurPfad(schlecht(eingabe({ land })), 'land') +}) + +test('Normalisieren ist idempotent', () => { + const e = eingabe({ + land: 'BY', + plaene: [ + plan({ tage: tage({ 1: [['13:00', '18:00'], ['08:00', '12:00']], 5: ['20:00', '02:00'], 6: 'rund' }) }), + plan({ id: undefined, name: 'Mittagstisch', haupt: false }), + ], + feiertage: { '2026-12-24': { modus: 'zeiten', fenster: [['09:00', '12:00']] }, '2026-12-25': { modus: 'zu', fenster: [] } }, + ausnahmen: [urlaub(), { id: undefined, art: 'sondertag', von: '2026-11-01', bis: '2026-11-01', text: 'Tag der offenen Tür', modus: 'zeiten', fenster: [['10:00', '16:00']], plan: P1 }], + hinweise: [hinweis({ knopf: { art: 'link', ziel: 'HTTPS://Beispiel.DE/Angebot', text: 'Mehr' }, von: '2026-10-01', bis: '2026-10-31' }), hinweis({ id: undefined, art: 'aktion', bis: '2026-11-30' })], + kontakt: { an: true, telefon: '0171 1234567', adresse: ' Hauptstr. 1 ', whatsapp: { nummer: '+49 171 1234567', starttext: 'Hallo', bestaetigtAt: '2026-10-04T08:00:00Z' } }, + }) + const einmal = gut(e) + const zweimal = gut(einmal, { bisher: einmal }) + assert.deepEqual(zweimal, einmal) + assert.equal(JSON.stringify(zweimal), JSON.stringify(einmal)) +}) + +test('Das Ergebnis passt zur Zeitlogik: Wochenplan-Hash ist stabil, Reihenfolge der Fenster egal', () => { + const a = gut(eingabe({ plaene: [plan({ tage: tage({ 1: [['13:30', '18:00'], ['09:00', '12:30']], 2: 'rund' }) })] })) + const b = gut(eingabe({ plaene: [plan({ tage: { 2: { modus: 'rund', fenster: [['01:00', '02:00']] }, 1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] } } })] })) + assert.equal(wochenplanHash(a), wochenplanHash(b)) + assert.deepEqual(a.plaene, b.plaene) +}) + +test('Das Ergebnis lässt sich direkt in der Zeitlogik verwenden', () => { + const daten = gut(eingabe({ + plaene: [plan({ tage: tage({ 1: ['09:00', '18:00'] }) })], + ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-10-16' })], + hinweise: [hinweis({ von: '2026-10-01', bis: '2026-10-31' })], + })) + assert.equal(hauptplan(daten).id, P1) + assert.equal(tagesbild(daten, hauptplan(daten), '2026-10-05').modus, 'zeiten') + assert.equal(tagesbild(daten, hauptplan(daten), '2026-10-13').art, 'urlaub') + assert.equal(statusZu(daten, hauptplan(daten), NOW).offen, true) + assert.equal(gueltigeHinweise(daten, NOW).length, 1) +}) + +// ------------------------------------------------------------------ Pläne + +test('genau ein Hauptplan, höchstens 5 Pläne, mindestens einer', () => { + nurPfad(schlecht(eingabe({ plaene: [plan({ haupt: false })] })), 'plaene') + nurPfad(schlecht(eingabe({ plaene: [plan(), plan({ id: P2, haupt: true })] })), 'plaene') + nurPfad(schlecht(eingabe({ plaene: [] })), 'plaene') + nurPfad(schlecht(eingabe({ plaene: undefined })), 'plaene') + nurPfad(schlecht(eingabe({ plaene: 'x' })), 'plaene') + + const fuenf = Array.from({ length: 5 }, (_, i) => plan({ id: `p_bbbbbbb${i}`, name: `Plan ${i}`, haupt: i === 0 })) + assert.equal(gut(eingabe({ plaene: fuenf })).plaene.length, 5) + const sechs = [...fuenf, plan({ id: 'p_bbbbbbb9', name: 'Plan 9', haupt: false })] + nurPfad(schlecht(eingabe({ plaene: sechs })), 'plaene') +}) + +test('Planname ist Pflicht und höchstens 60 Zeichen', () => { + nurPfad(schlecht(eingabe({ plaene: [plan({ name: ' ' })] })), `plaene.${P1}.name`) + nurPfad(schlecht(eingabe({ plaene: [plan({ name: undefined })] })), `plaene.${P1}.name`) + nurPfad(schlecht(eingabe({ plaene: [plan({ name: 'x'.repeat(61) })] })), `plaene.${P1}.name`) + assert.equal(gut(eingabe({ plaene: [plan({ name: 'x'.repeat(60) })] })).plaene[0].name.length, 60) +}) + +test('Plan ohne Wochentage: alle sieben Tage geschlossen; fehlende Tage werden ergänzt', () => { + const daten = gut(eingabe({ plaene: [plan({ tage: undefined })] })) + assert.deepEqual(Object.keys(daten.plaene[0].tage), ['1', '2', '3', '4', '5', '6', '7']) + for (let d = 1; d <= 7; d += 1) assert.deepEqual(daten.plaene[0].tage[d], { modus: 'zu', fenster: [] }) + const teil = gut(eingabe({ plaene: [plan({ tage: { 3: { modus: 'rund' } } })] })) + assert.deepEqual(teil.plaene[0].tage[3], { modus: 'rund', fenster: [] }) + assert.deepEqual(teil.plaene[0].tage[1], { modus: 'zu', fenster: [] }) +}) + +test('Wochentage: Modus muss gültig sein, unbekannte Schlüssel und Tage fallen weg', () => { + nurPfad(schlecht(eingabe({ plaene: [plan({ tage: { 1: { modus: 'offen', fenster: [] } } })] })), `plaene.${P1}.tage.1`) + nurPfad(schlecht(eingabe({ plaene: [plan({ tage: { 2: {} } })] })), `plaene.${P1}.tage.2`) + nurPfad(schlecht(eingabe({ plaene: [plan({ tage: { 4: 'zu' } })] })), `plaene.${P1}.tage.4`) + nurPfad(schlecht(eingabe({ plaene: [plan({ tage: 'x' })] })), `plaene.${P1}.tage`) + nurPfad(schlecht(eingabe({ plaene: [plan({ tage: { 3: { modus: 'zeiten', fenster: [] } } })] })), `plaene.${P1}.tage.3`) + const daten = gut(eingabe({ plaene: [plan({ tage: { ...tage({ 1: ['09:00', '12:00'] }), 0: { modus: 'zu' }, 8: { modus: 'zu' }, x: 1 } })] })) + assert.deepEqual(Object.keys(daten.plaene[0].tage), ['1', '2', '3', '4', '5', '6', '7']) +}) + +test('Kanonische Form: Fenster sortiert, fenster [] außer bei zeiten, unbekannte Schlüssel fallen weg', () => { + const e = eingabe({ + plaene: [{ + id: P1, kurz: 'standard', name: 'Standard', haupt: true, farbe: 'rot', extra: { a: 1 }, + tage: { + 1: { modus: 'zeiten', fenster: [['13:30', '18:00'], ['09:00', '12:30']], notiz: 'x' }, + 2: { modus: 'rund', fenster: [['09:00', '10:00']] }, + 3: { modus: 'zu', fenster: 'kaputt' }, + 4: { modus: 'vereinbarung', fenster: [['99:99', '100:00']] }, + }, + }], + kontakt: { an: true, telefon: '0171 1234567', adresse: '', whatsapp: null, fax: '1' }, + zusatz: 1, + }) + const daten = gut(e) + assert.deepEqual(daten.plaene[0].tage[1], { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] }) + assert.deepEqual(daten.plaene[0].tage[2], { modus: 'rund', fenster: [] }) + assert.deepEqual(daten.plaene[0].tage[3], { modus: 'zu', fenster: [] }) + assert.deepEqual(daten.plaene[0].tage[4], { modus: 'vereinbarung', fenster: [] }) + assert.deepEqual(Object.keys(daten.plaene[0]), ['id', 'kurz', 'name', 'haupt', 'tage']) + assert.deepEqual(Object.keys(daten.kontakt), ['an', 'telefon', 'adresse', 'whatsapp']) + assert.equal(daten.zusatz, undefined) +}) + +// ------------------------------------------------------------------ Fenster + +function wochenfehler(woche) { + return schlecht(eingabe({ plaene: [plan({ tage: tage(woche) })] })) +} + +test('Fensterregeln: jede Regel hat ihren eigenen Fehlerpfad', () => { + const f = wochenfehler({ + 1: [['09:00', '12:00'], ['11:00', '14:00']], // Überschneidung + 2: ['10:00', '10:00'], // bis == von + 3: [['22:00', '02:00'], ['23:00', '23:30']], // Mitternacht nicht als letztes + 4: ['22:00', '03:00'], // ragt in den Folgetag + 5: ['09:03', '12:00'], // kein 5-Minuten-Raster + 6: [['08:00', '09:00'], ['10:00', '11:00'], ['12:00', '13:00']], // drei Fenster + 7: ['9:00', '12:00'], // kein HH:MM + }) + // Tag 4 ragt in Tag 5 hinein, nur wenn Tag 5 gültige Fenster hat; hier ist Tag 5 kaputt -> kein Folgefehler + for (const t of [1, 2, 3, 5, 6, 7]) nurPfad(f, `plaene.${P1}.tage.${t}`) + assert.equal(Object.hasOwn(f, `plaene.${P1}.tage.4`), false) + const meldungen = new Set(Object.values(f)) + assert.ok(meldungen.size >= 5, 'unterschiedliche Meldungen je Regel') +}) + +test('Fenster über Mitternacht: nur zuletzt und nicht in das erste Fenster des Folgetags hinein', () => { + // ragt in den Dienstag hinein (erstes Fenster 01:00) + const f = wochenfehler({ 1: ['22:00', '03:00'], 2: ['01:00', '05:00'] }) + nurPfad(f, `plaene.${P1}.tage.1`) + assert.equal(Object.keys(f).length, 1) + // Sonntag -> Montag springt um + const w = wochenfehler({ 7: ['20:00', '10:00'], 1: ['09:00', '12:00'] }) + nurPfad(w, `plaene.${P1}.tage.7`) + // genau bis zum Beginn des Folgefensters ist erlaubt + const ok = gut(eingabe({ plaene: [plan({ tage: tage({ 7: ['20:00', '09:00'], 1: ['09:00', '12:00'] }) })] })) + assert.deepEqual(ok.plaene[0].tage[7].fenster, [['20:00', '09:00']]) + // Folgetag zu, rund oder ohne Fenster: nichts zu prüfen + assert.ok(gut(eingabe({ plaene: [plan({ tage: tage({ 1: ['22:00', '03:00'] }) })] }))) + assert.ok(gut(eingabe({ plaene: [plan({ tage: tage({ 1: ['22:00', '03:00'], 2: 'rund' }) })] }))) + // Nur das zweite Fenster darf über Mitternacht gehen + assert.ok(gut(eingabe({ plaene: [plan({ tage: tage({ 1: [['11:00', '14:00'], ['18:00', '01:00']], 2: ['09:00', '12:00'] }) })] }))) + // Das erste Fenster eines Folgetags zählt, auch wenn es das zweite eingegebene ist + nurPfad(wochenfehler({ 1: ['22:00', '03:00'], 2: [['13:00', '14:00'], ['02:00', '04:00']] }), `plaene.${P1}.tage.1`) +}) + +test('Fensterende 24:00 und 00:00 sind gültig, Beginn 24:00 nicht', () => { + const ok = gut(eingabe({ plaene: [plan({ tage: tage({ 1: ['18:00', '24:00'], 2: ['00:00', '24:00'], 3: ['18:00', '00:00'] }) })] })) + assert.deepEqual(ok.plaene[0].tage[1].fenster, [['18:00', '24:00']]) + assert.deepEqual(ok.plaene[0].tage[2].fenster, [['00:00', '24:00']]) + nurPfad(wochenfehler({ 1: ['24:00', '24:00'] }), `plaene.${P1}.tage.1`) + nurPfad(wochenfehler({ 1: ['12:00', '24:05'] }), `plaene.${P1}.tage.1`) + nurPfad(wochenfehler({ 1: ['12:00', '25:00'] }), `plaene.${P1}.tage.1`) + nurPfad(wochenfehler({ 1: ['12:00', '12:60'] }), `plaene.${P1}.tage.1`) +}) + +test('Fenster: Berührung ist keine Überschneidung, Reihenfolge der Eingabe egal', () => { + const daten = gut(eingabe({ plaene: [plan({ tage: tage({ 1: [['12:00', '18:00'], ['09:00', '12:00']] }) })] })) + assert.deepEqual(daten.plaene[0].tage[1].fenster, [['09:00', '12:00'], ['12:00', '18:00']]) +}) + +test('Fenster: kaputte Formen sind Fehler, keine Abstürze', () => { + for (const kaputt of ['x', 5, null, {}, [['09:00']], [['09:00', '12:00', '13:00']], [null], [[9, 12]], [['09:00', 12]]]) { + const f = schlecht(eingabe({ plaene: [plan({ tage: { ...tage(), 3: { modus: 'zeiten', fenster: kaputt } } })] })) + nurPfad(f, `plaene.${P1}.tage.3`) + } +}) + +test('Feiertage: Fenster nach denselben Regeln, Mitternacht nur zuletzt', () => { + nurPfad(schlecht(eingabe({ feiertage: { '2026-12-24': { modus: 'zeiten', fenster: [['09:00', '12:00'], ['11:00', '13:00']] } } })), 'feiertage.2026-12-24') + nurPfad(schlecht(eingabe({ feiertage: { '2026-12-24': { modus: 'zeiten', fenster: [['22:00', '02:00'], ['23:00', '23:30']] } } })), 'feiertage.2026-12-24') + nurPfad(schlecht(eingabe({ feiertage: { '2026-12-24': { modus: 'zeiten', fenster: [['09:07', '12:00']] } } })), 'feiertage.2026-12-24') + nurPfad(schlecht(eingabe({ feiertage: { '2026-12-24': { modus: 'zeiten', fenster: [] } } })), 'feiertage.2026-12-24') + assert.ok(gut(eingabe({ feiertage: { '2026-12-31': { modus: 'zeiten', fenster: [['18:00', '03:00']] } } }))) +}) + +test('Feiertage: Einträge ohne gültigen Modus fallen weg, Schlüssel werden geprüft und sortiert', () => { + const daten = gut(eingabe({ + feiertage: { + '2026-12-26': { modus: 'rund', fenster: [['09:00', '10:00']] }, + '2026-12-25': {}, + '2026-12-24': { modus: 'zeiten', fenster: [['09:00', '12:00']], text: 'x' }, + '2026-12-27': { modus: 'kaputt' }, + '2026-12-28': null, + '2026-12-29': 'zu', + '2026-12-30': { modus: 'zu', fenster: [['09:00', '12:00']] }, + }, + })) + assert.deepEqual(daten.feiertage, { + '2026-12-24': { modus: 'zeiten', fenster: [['09:00', '12:00']] }, + '2026-12-26': { modus: 'rund', fenster: [] }, + '2026-12-30': { modus: 'zu', fenster: [] }, + }) + assert.deepEqual(Object.keys(daten.feiertage), ['2026-12-24', '2026-12-26', '2026-12-30']) + nurPfad(schlecht(eingabe({ feiertage: { '2026-02-30': { modus: 'zu' } } })), 'feiertage.2026-02-30') + nurPfad(schlecht(eingabe({ feiertage: { morgen: { modus: 'zu' } } })), 'feiertage.morgen') + nurPfad(schlecht(eingabe({ feiertage: [] })), 'feiertage') + nurPfad(schlecht(eingabe({ feiertage: 'x' })), 'feiertage') + assert.deepEqual(gut(eingabe({ feiertage: undefined })).feiertage, {}) +}) + +// ------------------------------------------------------------------ Ausnahmen + +test('Ausnahmen: höchstens 100, Reihenfolge bleibt erhalten', () => { + const liste = Array.from({ length: 100 }, (_, i) => urlaub({ id: `x_ccccc${String(i).padStart(3, '0')}`, von: '2026-11-01', bis: '2026-11-02', text: '' })) + assert.equal(gut(eingabe({ ausnahmen: liste })).ausnahmen.length, 100) + nurPfad(schlecht(eingabe({ ausnahmen: [...liste, urlaub({ id: 'x_ccccc999', text: '' })] })), 'ausnahmen') + nurPfad(schlecht(eingabe({ ausnahmen: 'x' })), 'ausnahmen') + + const umgekehrt = [urlaub({ id: 'x_zzzzzzz9', von: '2026-12-01', bis: '2026-12-02' }), urlaub({ id: 'x_aaaaaaa1', von: '2026-11-01', bis: '2026-11-02' })] + assert.deepEqual(gut(eingabe({ ausnahmen: umgekehrt })).ausnahmen.map((a) => a.id), ['x_zzzzzzz9', 'x_aaaaaaa1']) +}) + +test('Ausnahmen: Art, Datum, von <= bis, bis fehlt = ein Tag', () => { + const id = 'x_aaaaaaa1' + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ art: 'feiertag' })] })), `ausnahmen.${id}.art`) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ von: '2026-13-01' })] })), `ausnahmen.${id}.von`) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ von: undefined })] })), `ausnahmen.${id}.von`) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ bis: '2026-02-30' })] })), `ausnahmen.${id}.bis`) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ von: '2026-10-16', bis: '2026-10-12' })] })), `ausnahmen.${id}.bis`) + nurPfad(schlecht(eingabe({ ausnahmen: [null] })), 'ausnahmen.0') + const eintag = gut(eingabe({ ausnahmen: [urlaub({ bis: undefined })] })).ausnahmen[0] + assert.equal(eintag.bis, eintag.von) + assert.equal(gut(eingabe({ ausnahmen: [urlaub({ bis: null })] })).ausnahmen[0].bis, '2026-10-12') +}) + +test('Urlaub höchstens 366 Tage (einschließlich beider Endtage)', () => { + const id = 'x_aaaaaaa1' + const ok = gut(eingabe({ ausnahmen: [urlaub({ von: '2026-10-05', bis: '2027-10-05' })] })) + assert.equal(ok.ausnahmen.length, 1) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ von: '2026-10-05', bis: '2027-10-06' })] })), `ausnahmen.${id}.bis`) + // über den Jahreswechsel gerechnet, kein Vielfaches von 24 h + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ von: '2026-03-28', bis: '2027-03-30' })] })), `ausnahmen.${id}.bis`) + assert.ok(gut(eingabe({ ausnahmen: [urlaub({ von: '2026-03-28', bis: '2027-03-27' })] }))) +}) + +test('Urlaub ist immer geschlossen, Sondertag behält seine Zeiten', () => { + const u = gut(eingabe({ ausnahmen: [urlaub({ modus: 'rund', fenster: [['09:00', '10:00']] })] })).ausnahmen[0] + assert.equal(u.modus, 'zu') + assert.deepEqual(u.fenster, []) + const sonder = { id: 'x_aaaaaaa2', art: 'sondertag', von: '2026-11-01', bis: '2026-11-01', text: 'Tag der offenen Tür', modus: 'zeiten', fenster: [['10:00', '16:00'], ['17:00', '18:00']], plan: null } + assert.deepEqual(gut(eingabe({ ausnahmen: [sonder] })).ausnahmen[0], sonder) +}) + +test('Sondertag: Modus Pflicht, Fenster nach den Regeln', () => { + const basis = { id: 'x_aaaaaaa2', art: 'sondertag', von: '2026-11-01', bis: '2026-11-01', text: 'Sonder', plan: null } + nurPfad(schlecht(eingabe({ ausnahmen: [{ ...basis, modus: 'kaputt', fenster: [] }] })), 'ausnahmen.x_aaaaaaa2.modus') + nurPfad(schlecht(eingabe({ ausnahmen: [{ ...basis, fenster: [] }] })), 'ausnahmen.x_aaaaaaa2.modus') + nurPfad(schlecht(eingabe({ ausnahmen: [{ ...basis, modus: 'zeiten', fenster: [] }] })), 'ausnahmen.x_aaaaaaa2.fenster') + nurPfad(schlecht(eingabe({ ausnahmen: [{ ...basis, modus: 'zeiten', fenster: [['10:00', '12:00'], ['11:00', '13:00']] }] })), 'ausnahmen.x_aaaaaaa2.fenster') + const zu = gut(eingabe({ ausnahmen: [{ ...basis, modus: 'zu', fenster: [['10:00', '12:00']] }] })).ausnahmen[0] + assert.deepEqual(zu.fenster, []) + const sortiert = gut(eingabe({ ausnahmen: [{ ...basis, modus: 'zeiten', fenster: [['13:00', '15:00'], ['10:00', '12:00']] }] })).ausnahmen[0] + assert.deepEqual(sortiert.fenster, [['10:00', '12:00'], ['13:00', '15:00']]) +}) + +test('Ausnahme gilt für einen bekannten Plan oder für alle', () => { + const id = 'x_aaaaaaa1' + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ plan: 'p_gibtsnicht' })] })), `ausnahmen.${id}.plan`) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ plan: 5 })] })), `ausnahmen.${id}.plan`) + assert.equal(gut(eingabe({ ausnahmen: [urlaub({ plan: P1 })] })).ausnahmen[0].plan, P1) + assert.equal(gut(eingabe({ ausnahmen: [urlaub({ plan: undefined })] })).ausnahmen[0].plan, null) + assert.equal(gut(eingabe({ ausnahmen: [urlaub({ plan: '' })] })).ausnahmen[0].plan, null) +}) + +// ------------------------------------------------------------------ Hinweise + +test('Hinweise: höchstens 20, Art Pflicht, Text Pflicht, Reihenfolge bleibt', () => { + const liste = Array.from({ length: 20 }, (_, i) => hinweis({ id: `h_ddddd${String(i).padStart(3, '0')}` })) + assert.equal(gut(eingabe({ hinweise: liste })).hinweise.length, 20) + nurPfad(schlecht(eingabe({ hinweise: [...liste, hinweis({ id: 'h_ddddd999' })] })), 'hinweise') + nurPfad(schlecht(eingabe({ hinweise: 'x' })), 'hinweise') + nurPfad(schlecht(eingabe({ hinweise: [null] })), 'hinweise.0') + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ art: 'laut' })] })), 'hinweise.h_aaaaaaa1.art') + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ art: undefined })] })), 'hinweise.h_aaaaaaa1.art') + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ text: ' ' })] })), 'hinweise.h_aaaaaaa1.text') + for (const art of ['info', 'urlaub', 'aktion', 'wichtig']) assert.ok(gut(eingabe({ hinweise: [hinweis({ art, bis: '2026-12-01' })] }))) + const umgekehrt = [hinweis({ id: 'h_zzzzzzz9' }), hinweis({ id: 'h_aaaaaaa1' })] + assert.deepEqual(gut(eingabe({ hinweise: umgekehrt })).hinweise.map((h) => h.id), ['h_zzzzzzz9', 'h_aaaaaaa1']) +}) + +test('Hinweis: von und bis sind optional (Kalendertage), bis >= von', () => { + const id = 'h_aaaaaaa1' + const ohne = gut(eingabe({ hinweise: [hinweis({ von: undefined, bis: undefined })] })).hinweise[0] + assert.equal(ohne.von, null) + assert.equal(ohne.bis, null) + const leer = gut(eingabe({ hinweise: [hinweis({ von: '', bis: '' })] })).hinweise[0] + assert.equal(leer.von, null) + assert.equal(leer.bis, null) + assert.equal(gut(eingabe({ hinweise: [hinweis({ von: '2026-10-10' })] })).hinweise[0].von, '2026-10-10') + assert.equal(gut(eingabe({ hinweise: [hinweis({ bis: '2026-10-10' })] })).hinweise[0].bis, '2026-10-10') + assert.ok(gut(eingabe({ hinweise: [hinweis({ von: '2026-10-10', bis: '2026-10-10' })] }))) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ von: '2026-10-11', bis: '2026-10-10' })] })), `hinweise.${id}.bis`) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ von: '10.10.2026' })] })), `hinweise.${id}.von`) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ bis: '2026-02-30' })] })), `hinweise.${id}.bis`) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ von: 5 })] })), `hinweise.${id}.von`) +}) + +test('Aktion braucht Enddatum, Urlaub höchstens 366 Tage', () => { + const id = 'h_aaaaaaa1' + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ art: 'aktion', bis: null })] })), `hinweise.${id}.bis`) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ art: 'aktion', bis: '' })] })), `hinweise.${id}.bis`) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ art: 'aktion', von: '2026-10-10' })] })), `hinweise.${id}.bis`) + assert.equal(gut(eingabe({ hinweise: [hinweis({ art: 'aktion', bis: '2026-10-31' })] })).hinweise[0].bis, '2026-10-31') + // Hinweis der Art Urlaub: ebenfalls höchstens 366 Tage + assert.ok(gut(eingabe({ hinweise: [hinweis({ art: 'urlaub', von: '2026-10-05', bis: '2027-10-05' })] }))) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ art: 'urlaub', von: '2026-10-05', bis: '2027-10-06' })] })), `hinweise.${id}.bis`) + assert.ok(gut(eingabe({ hinweise: [hinweis({ art: 'info', von: '2026-10-05', bis: '2028-10-06' })] }))) // andere Arten sind nicht begrenzt + // Urlaub-Ausnahme: 366 Tage gehen, 367 nicht + assert.ok(gut(eingabe({ ausnahmen: [urlaub({ von: '2026-10-05', bis: '2027-10-05' })] }))) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ von: '2026-10-05', bis: '2027-10-06' })] })), 'ausnahmen.x_aaaaaaa1.bis') +}) + +test('Knopf: null bleibt null, ein leerer Knopf zählt als kein Knopf', () => { + assert.equal(gut(eingabe({ hinweise: [hinweis({ knopf: null })] })).hinweise[0].knopf, null) + assert.equal(gut(eingabe({ hinweise: [hinweis({ knopf: undefined })] })).hinweise[0].knopf, null) + assert.equal(gut(eingabe({ hinweise: [hinweis({ knopf: { art: 'link', ziel: '', text: '' } })] })).hinweise[0].knopf, null) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ knopf: 'x' })] })), 'hinweise.h_aaaaaaa1.knopf') + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ knopf: { art: 'mail', ziel: 'https://example.de/', text: 'Los' } })] })), 'hinweise.h_aaaaaaa1.knopf.art') + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ knopf: { art: 'link', ziel: 'https://example.de/', text: '' } })] })), 'hinweise.h_aaaaaaa1.knopf.text') + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ knopf: { art: 'link', ziel: '', text: 'Los' } })] })), 'hinweise.h_aaaaaaa1.knopf.ziel') +}) + +test('Link nur https ohne Benutzerdaten, höchstens 300 Zeichen', () => { + const link = (ziel) => eingabe({ hinweise: [hinweis({ knopf: { art: 'link', ziel, text: 'Mehr' } })] }) + const pfad = 'hinweise.h_aaaaaaa1.knopf.ziel' + for (const schlechtes of ['javascript:alert(1)', 'http://example.de/', 'https://u:p@example.de/', 'https://u@example.de/', 'ftp://example.de/', 'example.de', '//example.de', 'https://', 'https://exa mple.de/', 'https://example.de/\nx', "https://exa'mple.de/", `https://example.de/${'a'.repeat(300)}`, 5, {}]) { + nurPfad(schlecht(link(schlechtes)), pfad) + } + const genau300 = `https://example.de/${'a'.repeat(300 - 'https://example.de/'.length)}` + assert.equal(genau300.length, 300) + assert.equal(gut(link(genau300)).hinweise[0].knopf.ziel, genau300) + assert.equal(gut(link('https://example.de/angebot?x=1')).hinweise[0].knopf.ziel, 'https://example.de/angebot?x=1') + assert.equal(gut(link(' HTTPS://Beispiel.DE ')).hinweise[0].knopf.ziel, 'https://beispiel.de/') + assert.deepEqual(gut(link('https://example.de/')).hinweise[0].knopf, { art: 'link', ziel: 'https://example.de/', text: 'Mehr' }) + nurPfad(schlecht(link(`https://example.de/${'a'.repeat(282)}`)), pfad) // 301 Zeichen +}) + +test('Knopf „anruf“: nur Ziffern, Leerzeichen, +, /, -', () => { + const anruf = (ziel) => eingabe({ hinweise: [hinweis({ knopf: { art: 'anruf', ziel, text: 'Anrufen' } })] }) + const pfad = 'hinweise.h_aaaaaaa1.knopf.ziel' + for (const schlechtes of ['0171 ABC', '0171-12345; rm', 'tel:0171', '(06 31) 12 34', '+++', '- / -', '', 5, `0171${' 1'.repeat(15)}`]) { + nurPfad(schlecht(anruf(schlechtes)), pfad) + } + for (const gutes of ['0171 1234567', '+49 171 1234567', '0631/12 34-56']) { + assert.equal(gut(anruf(gutes)).hinweise[0].knopf.ziel, gutes) + } + assert.equal(gut(anruf(' 0171 1234567 ')).hinweise[0].knopf.ziel, '0171 1234567') +}) + +test('Längen aus der Spec', () => { + const l = (n) => 'x'.repeat(n) + // Hinweistext 140 / 141 + assert.ok(gut(eingabe({ hinweise: [hinweis({ text: l(140) })] }))) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ text: l(141) })] })), 'hinweise.h_aaaaaaa1.text') + // Zeichen zählen als Codepoints, nicht als UTF-16-Einheiten + assert.ok(gut(eingabe({ hinweise: [hinweis({ text: '😀'.repeat(140) })] }))) + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ text: '😀'.repeat(141) })] })), 'hinweise.h_aaaaaaa1.text') + // ein Text, der nur durch Leerraum nach Zeichen 140 „passt“, ist trotzdem zu lang + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ text: `${l(140)} y` })] })), 'hinweise.h_aaaaaaa1.text') + // Ausnahmetext 60 / 61 + assert.ok(gut(eingabe({ ausnahmen: [urlaub({ text: l(60) })] }))) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ text: l(61) })] })), 'ausnahmen.x_aaaaaaa1.text') + // Knopftext 30 / 31 + const knopf = (n) => eingabe({ hinweise: [hinweis({ knopf: { art: 'link', ziel: 'https://example.de/', text: l(n) } })] }) + assert.ok(gut(knopf(30))) + nurPfad(schlecht(knopf(31)), 'hinweise.h_aaaaaaa1.knopf.text') + // Starttext 200 / 201 + const wa = (starttext) => eingabe({ kontakt: { an: true, telefon: '', adresse: '', whatsapp: { nummer: '0171 1234567', starttext, bestaetigtAt: '2026-10-04T08:00:00.000Z' } } }) + assert.ok(gut(wa(l(200)))) + nurPfad(schlecht(wa(l(201))), 'kontakt.whatsapp.starttext') + // Adresse 200 / 201 + const adr = (adresse) => eingabe({ kontakt: { an: true, telefon: '', adresse, whatsapp: null } }) + assert.ok(gut(adr(l(200)))) + nurPfad(schlecht(adr(l(201))), 'kontakt.adresse') + // Telefon 30 / 31 + const tel = (telefon) => eingabe({ kontakt: { an: true, telefon, adresse: '', whatsapp: null } }) + assert.equal(gut(tel('0171 1234567'.padEnd(30, '-'))).kontakt.telefon, '491711234567') + nurPfad(schlecht(tel('0171 1234567'.padEnd(31, '-'))), 'kontakt.telefon') + // riesige Texte sind ein Fehler, kein Absturz + nurPfad(schlecht(eingabe({ hinweise: [hinweis({ text: l(1000000) })] })), 'hinweise.h_aaaaaaa1.text') +}) + +test('Texte werden bereinigt: Steuerzeichen und unsichtbare Zeichen raus, Umbrüche werden Leerzeichen', () => { + const daten = gut(eingabe({ + plaene: [plan({ name: ' Mittags​tisch\n\tneu ' })], + ausnahmen: [urlaub({ text: 'Be\u0000triebs‮urlaub' })], + hinweise: [hinweis({ text: 'Zeile eins\r\nZeile zwei' })], + })) + assert.equal(daten.plaene[0].name, 'Mittagstisch neu') + assert.equal(daten.ausnahmen[0].text, 'Betriebsurlaub') + assert.equal(daten.hinweise[0].text, 'Zeile eins Zeile zwei') +}) + +test('Vergangenes (> 30 Tage) wird entfernt', () => { + // heute (Berlin) = 2026-10-05, Grenze = 2026-09-05 + const daten = gut(eingabe({ + ausnahmen: [ + urlaub({ id: 'x_alt00001', von: '2026-07-20', bis: '2026-08-01' }), + urlaub({ id: 'x_alt00002', von: '2026-09-01', bis: '2026-09-04' }), + urlaub({ id: 'x_neu00001', von: '2026-09-01', bis: '2026-09-05' }), + urlaub({ id: 'x_neu00002', von: '2026-09-20', bis: '2026-09-25' }), + urlaub({ id: 'x_neu00003', von: '2027-01-01', bis: '2027-01-05' }), + ], + hinweise: [ + hinweis({ id: 'h_alt00001', bis: '2026-08-31' }), + hinweis({ id: 'h_neu00001', bis: '2026-09-05' }), + hinweis({ id: 'h_neu00002', bis: null }), + hinweis({ id: 'h_neu00003', von: '2026-01-01' }), // ohne Ende bleibt, bis es gelöscht wird + ], + feiertage: { + '2026-08-01': { modus: 'zu', fenster: [] }, + '2026-09-04': { modus: 'zu', fenster: [] }, + '2026-09-05': { modus: 'zu', fenster: [] }, + '2026-12-25': { modus: 'zu', fenster: [] }, + }, + })) + assert.deepEqual(daten.ausnahmen.map((a) => a.id), ['x_neu00001', 'x_neu00002', 'x_neu00003']) + assert.deepEqual(daten.hinweise.map((h) => h.id), ['h_neu00001', 'h_neu00002', 'h_neu00003']) + assert.deepEqual(Object.keys(daten.feiertage), ['2026-09-05', '2026-12-25']) +}) + +test('Vergangenes zählt nicht gegen die Höchstzahl, wird aber vorher geprüft', () => { + const alt = Array.from({ length: 100 }, (_, i) => urlaub({ id: `x_eeeee${String(i).padStart(3, '0')}`, von: '2026-01-01', bis: '2026-01-02', text: '' })) + const neu = urlaub({ id: 'x_neuneuneu', von: '2026-11-01', bis: '2026-11-02' }) + const daten = gut(eingabe({ ausnahmen: [...alt, neu] })) + assert.deepEqual(daten.ausnahmen.map((a) => a.id), ['x_neuneuneu']) + // Fehler in einem alten Eintrag bleiben Fehler (kein stilles Verschlucken) + nurPfad(schlecht(eingabe({ ausnahmen: [urlaub({ von: '2026-01-02', bis: '2026-01-01' })] })), 'ausnahmen.x_aaaaaaa1.bis') +}) + +test('Der Tag „heute“ ist der Berliner Kalendertag', () => { + // 2026-10-05T22:30Z = Berlin Dienstag 2026-10-06 00:30 -> Grenze 2026-09-06 + const spaet = new Date('2026-10-05T22:30:00.000Z') + const e = eingabe({ ausnahmen: [urlaub({ id: 'x_aaaaaaa1', von: '2026-09-05', bis: '2026-09-05' }), urlaub({ id: 'x_aaaaaaa2', von: '2026-09-06', bis: '2026-09-06' })] }) + assert.deepEqual(gut(e, { now: spaet }).ausnahmen.map((a) => a.id), ['x_aaaaaaa2']) + assert.deepEqual(gut(e, { now: NOW }).ausnahmen.map((a) => a.id), ['x_aaaaaaa1', 'x_aaaaaaa2']) +}) + +// ------------------------------------------------------------------ Kontakt und Telefon + +test('Telefon in allen Schreibweisen', () => { + for (const s of ['0171 1234567', '+49 171 1234567', '0049 171 1234567', '0171/1234567', '0171-123 45 67', '+49 (0) 171 1234567', '+49 (0)171 1234567', '(0171) 1234567', '491711234567', '+491711234567', ' 0171 1234567 ']) { + assert.equal(telefonZiffern(s), '491711234567', s) + } + assert.equal(telefonZiffern('(06 31) 12 34'), '496311234') + assert.equal(telefonZiffern('0631 12 34'), '496311234') + assert.equal(telefonZiffern('+43 660 1234567'), '436601234567') // Ausland bleibt Ausland + assert.equal(telefonZiffern('0043 660 1234567'), '436601234567') + assert.equal(telefonZiffern('+49 0171 1234567'), '491711234567') // häufiger Tippfehler + for (const s of ['0171 ABC', 'abc', '', ' ', null, undefined, 5, '+', '0', '12', '+49', '0171 1234567 ext', '0171+1234567', '++49 171 1234567', '0171 1234567890123456', '0171 123;456']) { + assert.equal(telefonZiffern(s), null, String(s)) + } +}) + +test('Telefon im Kontakt wird auf Ziffern gebracht, Fehler am Pfad kontakt.telefon', () => { + const kontakt = (telefon) => eingabe({ kontakt: { an: true, telefon, adresse: '', whatsapp: null } }) + assert.equal(gut(kontakt('0171 1234567')).kontakt.telefon, '491711234567') + assert.equal(gut(kontakt('+49 171 1234567')).kontakt.telefon, '491711234567') + assert.equal(gut(kontakt('0049 171 1234567')).kontakt.telefon, '491711234567') + assert.equal(gut(kontakt('(06 31) 12 34')).kontakt.telefon, '496311234') + assert.equal(gut(kontakt('')).kontakt.telefon, '') + assert.equal(gut(kontakt(null)).kontakt.telefon, '') + assert.equal(gut(kontakt(undefined)).kontakt.telefon, '') + assert.equal(gut(kontakt(' ')).kontakt.telefon, '') + nurPfad(schlecht(kontakt('0171 ABC')), 'kontakt.telefon') + nurPfad(schlecht(kontakt(1234)), 'kontakt.telefon') +}) + +test('Kontakt: Standardwerte, an muss ein Wahrheitswert sein, Adresse einzeilig', () => { + const leer = gut(eingabe({ kontakt: undefined })).kontakt + assert.deepEqual(leer, { an: false, telefon: '', adresse: '', whatsapp: null }) + nurPfad(schlecht(eingabe({ kontakt: { an: 'ja' } })), 'kontakt.an') + nurPfad(schlecht(eingabe({ kontakt: 'x' })), 'kontakt') + assert.equal(gut(eingabe({ kontakt: { an: true, adresse: 'Hauptstraße 1\n67655 Kaiserslautern' } })).kontakt.adresse, 'Hauptstraße 1 67655 Kaiserslautern') + // an: true ohne Angaben ist erlaubt (die öffentliche Antwort zeigt dann keine Leiste) + assert.equal(gut(eingabe({ kontakt: { an: true } })).kontakt.an, true) +}) + +test('WhatsApp verlangt Nummer und bestaetigtAt', () => { + const wa = (whatsapp) => eingabe({ kontakt: { an: true, telefon: '', adresse: '', whatsapp } }) + const ok = { nummer: '0171 1234567', starttext: 'Hallo!', bestaetigtAt: '2026-10-04T08:00:00Z' } + const daten = gut(wa(ok)).kontakt.whatsapp + assert.deepEqual(daten, { nummer: '491711234567', starttext: 'Hallo!', bestaetigtAt: '2026-10-04T08:00:00.000Z' }) + assert.equal(gut(wa(null)).kontakt.whatsapp, null) + assert.equal(gut(wa(undefined)).kontakt.whatsapp, null) + nurPfad(schlecht(wa({ ...ok, nummer: '' })), 'kontakt.whatsapp.nummer') + nurPfad(schlecht(wa({ ...ok, nummer: undefined })), 'kontakt.whatsapp.nummer') + nurPfad(schlecht(wa({ ...ok, nummer: '0171 ABC' })), 'kontakt.whatsapp.nummer') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: undefined })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: null })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: '' })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: 'gestern' })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: '2026-10-04' })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: '2026-13-04T08:00:00Z' })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: '2026-02-30T08:00:00Z' })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: '2026-10-04T24:00:00Z' })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: '2026-10-04T08:00:00' })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa({ ...ok, bestaetigtAt: 1759564800000 })), 'kontakt.whatsapp.bestaetigtAt') + nurPfad(schlecht(wa('ja')), 'kontakt.whatsapp') + assert.equal(gut(wa({ ...ok, starttext: undefined })).kontakt.whatsapp.starttext, '') +}) + +test('WhatsApp: bestaetigtAt wird aus der Eingabe übernommen und bleibt beim erneuten Speichern gleich', () => { + const wa = (bestaetigtAt) => eingabe({ kontakt: { an: true, telefon: '', adresse: '', whatsapp: { nummer: '0171 1234567', starttext: '', bestaetigtAt } } }) + const erst = gut(wa('2026-10-04T10:00:00+02:00')).kontakt.whatsapp.bestaetigtAt + assert.equal(erst, '2026-10-04T08:00:00.000Z') + const nochmal = gut(wa(erst), { bisher: { kontakt: { whatsapp: { bestaetigtAt: erst } } } }).kontakt.whatsapp.bestaetigtAt + assert.equal(nochmal, erst) +}) + +// ------------------------------------------------------------------ Größe + +test('zu viele Daten: verständliche Meldung statt Absturz', () => { + const langerLink = `https://example.de/${'a'.repeat(281)}` + const ausnahmen = Array.from({ length: 100 }, (_, i) => ({ + id: `x_fffff${String(i).padStart(3, '0')}`, art: 'sondertag', von: '2026-11-01', bis: '2026-11-02', text: 'ä'.repeat(60), + modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']], plan: P1, + })) + const hinweise = Array.from({ length: 20 }, (_, i) => hinweis({ id: `h_fffff${String(i).padStart(3, '0')}`, text: 'ö'.repeat(140), knopf: { art: 'link', ziel: langerLink, text: 'm'.repeat(30) } })) + const gross = eingabe({ ausnahmen, hinweise }) + const f = schlecht(gross) + assert.equal(f.daten, 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.') + assert.deepEqual(Object.keys(f), ['daten']) + + // dieselben 100 Ausnahmen allein passen noch + const klein = gut(eingabe({ ausnahmen })) + assert.ok(JSON.stringify(klein).length <= 32768) + assert.equal(klein.ausnahmen.length, 100) +}) + +test('Größenprüfung: genau 32768 Zeichen gehen, eines mehr nicht', () => { + // Feiertage mit festem Platzbedarf je Eintrag füllen auf, die Adresse (je 1 Zeichen) macht den Rest. + const feiertage = (n) => { + const f = {} + const t = new Date('2026-11-01T00:00:00Z') + for (let i = 0; i < n; i += 1) { + f[t.toISOString().slice(0, 10)] = { modus: 'zu', fenster: [] } + t.setUTCDate(t.getUTCDate() + 1) + } + return f + } + const e = (n, laenge) => eingabe({ feiertage: feiertage(n), kontakt: { an: true, telefon: '', adresse: 'a'.repeat(laenge), whatsapp: null } }) + const groesse = (n, laenge) => { + const r = normalisiereZeiten(e(n, laenge), { now: NOW }) + return r.daten ? JSON.stringify(r.daten).length : Infinity + } + let n = 700 + assert.ok(groesse(n, 0) < 32768) + while (groesse(n + 1, 0) <= 32768) n += 1 + const luecke = 32768 - groesse(n, 0) + assert.ok(luecke >= 0 && luecke < 200, `Lücke ${luecke}`) + assert.equal(groesse(n, luecke), 32768) + const f = schlecht(e(n, luecke + 1)) + assert.equal(f.daten, 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.') +}) + +// ------------------------------------------------------------------ Team + +test('navSelektor-Regeln', () => { + const ok = (s) => normalisiereTeam({ navSelektor: s, kontaktleisteAus: false, hinweisleisteAus: false }) + assert.deepEqual(ok('header.nav'), { team: { navSelektor: 'header.nav', kontaktleisteAus: false, hinweisleisteAus: false } }) + for (const gutes of ['#main-nav', 'nav > ul', 'a[href="x"]', "div[data-x='y']", '.a_b-c:first-child', 'header .nav #x']) { + assert.equal(ok(gutes).team.navSelektor, gutes, gutes) + } + for (const schlechtes of ['a{}', 'a;b', '', 'a{b', 'a}', 'x'.repeat(121), 'a,b', 'a\nb', 'a\\b', 'a*b', 'a(b)', 'ä', 'a`b', 'a$b', 5, {}, []]) { + const r = ok(schlechtes) + assert.equal(r.team, undefined, String(schlechtes)) + nurPfad(r.fehler, 'navSelektor') + } + assert.equal(ok('x'.repeat(120)).team.navSelektor, 'x'.repeat(120)) + for (const leer of [null, undefined, '', ' ']) assert.equal(ok(leer).team.navSelektor, null) + assert.equal(ok(' header.nav ').team.navSelektor, 'header.nav') +}) + +test('Team: Schalter sind Wahrheitswerte mit Vorgabe aus, unbekannte Schlüssel fallen weg', () => { + assert.deepEqual(normalisiereTeam({}), { team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false } }) + assert.deepEqual(normalisiereTeam({ navSelektor: 'nav', kontaktleisteAus: true, hinweisleisteAus: true, extra: 1 }), { + team: { navSelektor: 'nav', kontaktleisteAus: true, hinweisleisteAus: true }, + }) + nurPfad(normalisiereTeam({ kontaktleisteAus: 'ja' }).fehler, 'kontaktleisteAus') + nurPfad(normalisiereTeam({ hinweisleisteAus: 1 }).fehler, 'hinweisleisteAus') + for (const x of [null, undefined, 'x', 5, []]) nurPfad(normalisiereTeam(x).fehler, 'team') + const doppelt = normalisiereTeam({ navSelektor: 'a{}', kontaktleisteAus: 'x' }) + assert.deepEqual(Object.keys(doppelt.fehler).sort(), ['kontaktleisteAus', 'navSelektor']) +})