Files
Webklar-Kundenbereich/test/termineEinstellungen.test.js
T
ClaudeandClaude Sonnet 5.5 bd65c9dc58 Terminplaner: Abschluss – Server (Widerruf auch bei Anfragen, Gastdaten löschen vollständig, formMinSekunden)
- R55: Widerrufsbelehrung, Anschrift-Pflicht und Knopf "Vertrag widerrufen" gelten für jede
  Verbraucher-Buchung mit sichtbarer Leistung, auch Anfragen (zeigtWiderruf); der Nachweis
  hält die Belehrung auch bei Anfragen fest. Damit bleibt die Annahme einer Anfrage nie mehr
  still ohne G02.
- "Gastdaten löschen" leert auch absageGrund und löscht noch nicht versandte Mails der Buchung
  (neu: verwerfeWartende in der Warteschlange).
- R53: GET /:siteKey/config nennt formMinSekunden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

998 lines
58 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
neueId,
normalisiereEinstellung,
oeffentlicheEinstellung,
knopfText,
istVerbraucher,
brauchtZustimmungVorzeitig,
preisText,
preisHinweis,
zahlungText,
fehlendePflichtangaben,
zeigtWiderruf,
} from '../server/services/termine/einstellungen.js'
import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
import { BUNDESLAENDER } from '../server/services/termine/feiertage.js'
const NOW = new Date('2026-10-02T10:00:00.000Z') // Berlin: 2026-10-02 12:00
const TAG = 86400000
const R1 = 'r_aaaaaaa1'
const R2 = 'r_aaaaaaa2'
const S1 = 's_bbbbbbb1'
const S2 = 's_bbbbbbb2'
/** Pflichtangaben fuer Gaeste (R13/R13a): Telefon immer, Anschrift bei Widerrufsbelehrung. */
const PFLICHT = { telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler' }
/** Kleinste gueltige Eingabe; alles andere kommt aus den Vorgaben. */
const basis = () => ({
betrieb: { name: 'Salon Müller', email: 'info@salon.de', ...PFLICHT },
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']] } }],
leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }],
})
const pruefe = (eingabe, optionen = {}) => normalisiereEinstellung(eingabe, { now: NOW, ...optionen })
const gueltig = (eingabe, optionen) => {
const erg = pruefe(eingabe, optionen)
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
const fehlerVon = (eingabe, optionen) => {
const erg = pruefe(eingabe, optionen)
assert.equal(erg.einstellung, undefined)
assert.ok(erg.fehler && typeof erg.fehler === 'object')
return erg.fehler
}
const BETRIEB = {
name: 'Salon Müller',
email: 'Info@Salon.de',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
kleinunternehmer: false,
websiteUrl: 'https://salon.de',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
}
// ---------------------------------------------------------------- IDs
test('neueId: Praefix, 8 Zeichen aus a-z0-9, unterschiedlich; unbekannter Praefix wirft', () => {
const ids = new Set()
for (const p of ['r', 's', 'a']) {
for (let i = 0; i < 50; i += 1) {
const id = neueId(p)
assert.match(id, new RegExp(`^${p}_[a-z0-9]{8}$`))
ids.add(id)
}
}
assert.equal(ids.size, 150)
assert.throws(() => neueId('x'))
assert.throws(() => neueId())
})
// ---------------------------------------------------------------- Vorlagen
test('Vorlagen: neun Schluessel in fester Reihenfolge, Liste mit Name und Beschreibung', () => {
const keys = ['salon', 'kosmetik', 'beratung', 'werkstatt', 'handwerk', 'tattoo', 'fotograf', 'hundesalon', 'sonstiges']
assert.deepEqual(Object.keys(VORLAGEN), keys)
const liste = vorlagenListe()
assert.deepEqual(liste.map((v) => v.key), keys)
for (const eintrag of liste) {
assert.deepEqual(Object.keys(eintrag).sort(), ['beschreibung', 'key', 'name'])
assert.ok(eintrag.name.length > 2 && eintrag.beschreibung.length > 10)
}
})
test('Vorlagen: jede ergibt eine gueltige, stabile Einstellung', () => {
for (const key of Object.keys(VORLAGEN)) {
const e = ausVorlage(key, BETRIEB, NOW)
assert.equal(e.vorlage, key)
assert.equal(e.betrieb.name, 'Salon Müller')
assert.equal(e.betrieb.email, 'info@salon.de')
assert.equal(e.betrieb.websiteUrl, 'https://salon.de/')
assert.ok(e.leistungen.length >= 2 && e.leistungen.length <= 5, `${key}: ${e.leistungen.length} Leistungen`)
assert.ok(e.ressourcen.length >= 1)
assert.ok(e.leistungen.every((l) => l.aktiv && l.ressourcen.length >= 1))
assert.ok(e.ressourcen.every((r) => Object.keys(r.plan).length >= 1 && r.feedToken.length === 43))
assert.equal(e.zahlung.modus, 'vorort')
// erneutes Normalisieren aendert nichts (Token und Storno-Version bleiben)
const nochmal = gueltig(e, { bisher: e })
assert.deepEqual(nochmal, e, key)
}
})
test('Vorlagen: Anfrage bei tattoo/handwerk/fotograf, sonst sofort; Werkstatt mit zwei Plaetzen', () => {
for (const key of Object.keys(VORLAGEN)) {
const e = ausVorlage(key, BETRIEB, NOW)
const erwartet = ['tattoo', 'handwerk', 'fotograf'].includes(key) ? 'anfrage' : 'sofort'
assert.ok(e.leistungen.every((l) => l.buchungsart === erwartet), key)
}
const werkstatt = ausVorlage('werkstatt', BETRIEB, NOW)
assert.equal(werkstatt.ressourcen.length, 2)
assert.ok(werkstatt.ressourcen.every((r) => r.typ === 'platz'))
})
test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt', () => {
for (const key of Object.keys(VORLAGEN)) {
const e = ausVorlage(key, BETRIEB, NOW)
const text = e.storno.text
assert.ok(text.startsWith(`Sie können Ihren Termin bis ${e.regeln.absageBisStd} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail).`), key)
assert.ok(text.includes('berechnen wir für den freigehaltenen Termin 50 % des Preises.'), key)
assert.ok(text.includes('Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist.'), key)
assert.ok(text.endsWith('Ihr gesetzliches Widerrufsrecht bleibt unberührt.'), key)
assert.ok(!text.includes('{{'), key)
assert.equal(e.storno.version, '2026-10-02-1')
}
})
test('Vorlagen: zwei Aufrufe ergeben verschiedene IDs und Feed-Token; Kleinunternehmer wird uebernommen', () => {
const a = ausVorlage('salon', BETRIEB, NOW)
const b = ausVorlage('salon', { ...BETRIEB, kleinunternehmer: true }, NOW)
assert.notEqual(a.ressourcen[0].id, b.ressourcen[0].id)
assert.notEqual(a.ressourcen[0].feedToken, b.ressourcen[0].feedToken)
assert.equal(a.betrieb.kleinunternehmer, false)
assert.equal(b.betrieb.kleinunternehmer, true)
})
test('Vorlagen: unbekannter Schluessel wirft, fehlende Betriebsangaben werfen mit Feldfehlern', () => {
assert.throws(() => ausVorlage('gibtsnicht', BETRIEB, NOW), /Unbekannte Vorlage/)
assert.throws(() => ausVorlage('__proto__', BETRIEB, NOW), /Unbekannte Vorlage/)
assert.throws(() => ausVorlage('salon', {}, NOW), (err) => {
assert.ok(err.fehler['betrieb.name'] && err.fehler['betrieb.email'])
return true
})
})
test('Vorlagen sind Entwuerfe: ohne Telefon und Anschrift gueltig, fehlendePflichtangaben nennt, was noch fehlt (R41)', () => {
const ohne = { name: 'Salon Müller', email: 'info@salon.de' }
const salon = ausVorlage('salon', ohne, NOW)
assert.equal(salon.betrieb.telefon, '')
assert.equal(salon.betrieb.adresse, '')
assert.deepEqual(fehlendePflichtangaben(salon), ['betrieb.telefon', 'betrieb.adresse'], 'Verbraucher + sofort: Telefon und Anschrift')
// Anfrage-Betriebe zeigen die Widerrufsbelehrung ebenfalls (R55: der Vertrag kommt mit der Annahme zustande, auch ein Fernabsatzvertrag)
assert.deepEqual(fehlendePflichtangaben(ausVorlage('handwerk', ohne, NOW)), ['betrieb.telefon', 'betrieb.adresse'])
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', { ...ohne, telefon: '06301 1234' }, NOW)), ['betrieb.adresse'])
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', BETRIEB, NOW)), [])
// der Entwurf selbst besteht die strenge Pruefung (wie PATCH sie macht) erst mit den Angaben
const streng = pruefe(salon, { bisher: salon })
assert.deepEqual(Object.keys(streng.fehler).sort(), ['betrieb.adresse', 'betrieb.telefon'])
assert.ok(pruefe({ ...salon, betrieb: { ...salon.betrieb, ...PFLICHT } }, { bisher: salon }).einstellung, 'mit den Angaben gueltig')
// Name und E-Mail bleiben Pflicht, auch im Entwurf
assert.throws(() => ausVorlage('salon', { telefon: '06301 1234' }, NOW), (err) => {
assert.ok(err.fehler['betrieb.name'] && err.fehler['betrieb.email'])
return true
})
})
test('Vorlagen: VORLAGEN ist eingefroren', () => {
assert.throws(() => { VORLAGEN.salon.leistungen.push({}) })
assert.throws(() => { VORLAGEN.neu = {} })
})
// ---------------------------------------------------------------- Normalisieren: Grundlagen
test('Vorgaben werden aufgefuellt', () => {
const e = gueltig(basis())
assert.equal(e.schema, 1)
assert.equal(e.vorlage, null)
assert.deepEqual(e.betrieb, {
name: 'Salon Müller', email: 'info@salon.de', telefon: '06301 1234', kleinunternehmer: false, adresse: 'Hauptstr. 1, 67722 Winnweiler',
impressumUrl: null, datenschutzUrl: null, agbUrl: null, websiteUrl: null,
})
assert.equal(e.anrede, 'sie')
assert.match(e.farbe, /^#[0-9a-f]{6}$/)
assert.ok(BUNDESLAENDER.includes(e.bundesland))
assert.equal(e.feiertageGeschlossen, true)
assert.deepEqual(e.sonderoeffnungen, {})
assert.deepEqual(e.abwesenheiten, [])
assert.deepEqual(e.regeln, { raster: 15, vorlaufMin: 120, horizontTage: 60, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true })
assert.deepEqual(e.gastFelder, { telefon: 'optional', nachricht: 'aus', zusatz: [] })
assert.deepEqual(e.erinnerung, { anbieten: true, stundenVorher: 24 })
assert.deepEqual(e.storno, { text: '', version: '' })
assert.deepEqual(e.recht, { gaesteSind: 'verbraucher', freizeit: false })
assert.deepEqual(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] })
assert.equal(e.aufbewahrungTage, 90)
assert.deepEqual(e.leistungen[0], {
id: S1, name: 'Damenschnitt', beschreibung: '', dauer: 45, pufferNach: 0, preisArt: 'keiner', preisCent: 0,
buchungsart: 'sofort', ressourcen: [R1], aktiv: true, reihenfolge: 1,
})
assert.deepEqual(e.ressourcen[0].plan, { 1: [['09:00', '12:30'], ['13:30', '18:00']] })
assert.equal(e.ressourcen[0].typ, 'person')
assert.equal(e.ressourcen[0].aktiv, true)
})
test('Eingabe wird nicht veraendert; Nicht-Objekte werden abgelehnt', () => {
const eingabe = basis()
const kopie = structuredClone(eingabe)
gueltig(eingabe)
assert.deepEqual(eingabe, kopie)
for (const unsinn of [null, undefined, 'x', 5, [], true]) {
assert.deepEqual(Object.keys(fehlerVon(unsinn)), ['_'])
}
})
test('now ist Pflicht', () => {
assert.throws(() => normalisiereEinstellung(basis(), {}), /now/)
assert.throws(() => normalisiereEinstellung(basis()), /now/)
assert.throws(() => normalisiereEinstellung(basis(), { now: 'heute' }), /now/)
})
test('Pflichtfelder: Betriebsname und Betriebs-E-Mail', () => {
const f = fehlerVon({})
assert.ok(f['betrieb.name'])
assert.ok(f['betrieb.email'])
assert.ok(fehlerVon({ betrieb: { ...PFLICHT, name: ' ', email: 'a@b.de' } })['betrieb.name'])
assert.ok(fehlerVon({ betrieb: { name: 'X' } })['betrieb.email'])
})
test('Betriebsname wird auf 80 Zeichen gekuerzt, Steuerzeichen fliegen raus', () => {
const e = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } })
assert.ok(e.betrieb.name.startsWith('Salon Müller xxx'))
assert.equal([...e.betrieb.name].length, 80)
assert.ok(!/[\u0000-\u001f\u202E]/.test(e.betrieb.name))
})
test('E-Mail: genau ein Postfach, klein und getrimmt', () => {
assert.equal(gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de')
for (const schlecht of ['a@b.de, c@d.de', 'a@b.de;c@d.de', '<a@b.de>', '"a"@b.de', 'a b@c.de', 'ohne-at.de', 'a@b', 'a@@b.de', '@b.de', 'a@.de', `${'x'.repeat(250)}@b.de`]) {
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: schlecht } })['betrieb.email'], schlecht)
}
})
test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeichen', () => {
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
const f = fehlerVon(mit('impressumUrl', 'javascript:alert(1)'))
assert.ok(f['betrieb.impressumUrl'])
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
for (const schlecht of ['http://salon.de', 'javascript:alert(1)', 'java\nscript:alert(1)', 'data:text/html,x', 'https://user:pw@salon.de', 'https://user@salon.de', '//salon.de', 'salon.de', `https://salon.de/${'a'.repeat(300)}`, 'https://sa lon.de', 5]) {
assert.ok(fehlerVon(mit(feld, schlecht))[`betrieb.${feld}`], `${feld}: ${schlecht}`)
}
assert.equal(gueltig(mit(feld, 'https://salon.de/impressum')).betrieb[feld], 'https://salon.de/impressum')
assert.equal(gueltig(mit(feld, '')).betrieb[feld], null)
assert.equal(gueltig(mit(feld, null)).betrieb[feld], null)
}
})
test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtick im Host abgelehnt', () => {
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
for (const schlecht of [
'https://exa"mple.com/', "https://a'b.de/", 'https://a`b.de/', "https://a'b.de/x'y", 'https://a<b.de/', 'https://a>b.de/',
'https://a|b.de/', 'https://a^b.de/', 'https://-a.de/', 'https://a..de/', 'https://a.de./', 'https://[zz]/x',
]) {
assert.ok(fehlerVon(mit(feld, schlecht))[`betrieb.${feld}`], `${feld}: ${schlecht}`)
}
}
})
test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycode, IPv6 in Klammern', () => {
const url = (wert) => gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl
assert.equal(url("https://ab.de/x'y"), 'https://ab.de/x%27y')
assert.equal(url("https://ab.de/?q='a'#f'g"), 'https://ab.de/?q=%27a%27#f%27g')
assert.equal(url('https://Salon.DE/Impressum'), 'https://salon.de/Impressum')
assert.equal(url('https://Bücher.de/Über'), 'https://xn--bcher-kva.de/%C3%9Cber')
assert.equal(url('https://sub.salon-müller.de/impressum'), 'https://sub.xn--salon-mller-zhb.de/impressum')
assert.equal(url('https://[::1]/x'), 'https://[::1]/x')
for (const gespeichert of [url("https://ab.de/x'y\"z`w"), url('https://a.de/?q="x"<y>')]) {
assert.ok(!/['"`<>]/.test(gespeichert), gespeichert)
}
// die 300 Zeichen gelten fuer die gespeicherte Schreibweise (%27 ist laenger als ')
const apostrophe = "'".repeat(110)
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl'])
assert.equal(url(`https://ab.de/${"'".repeat(80)}`).length, 'https://ab.de/'.length + 80 * 3)
})
test('Unsichtbare Formatzeichen: Name nur daraus ist leer (Pflichtfeld), sonst werden sie entfernt', () => {
const name = (wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: wert, email: 'a@b.de' } })
for (const leer of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u200B\u200E \uFEFF', ' \u200B\u200D\u200C ', '\u200D', '\u200C\u200D']) {
assert.ok(fehlerVon(name(leer))['betrieb.name'], JSON.stringify(leer))
}
assert.equal(gueltig(name('A\u200EB\u061CC\uFEFFD')).betrieb.name, 'ABCD')
for (const zeichen of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u00AD', '\u2061', '\u206A']) {
assert.equal(gueltig(name(`Sa${zeichen}lon`)).betrieb.name, 'Salon', JSON.stringify(zeichen))
}
// ZWJ (Emoji-Sequenzen) und ZWNJ (persische Schrift) innerhalb des Textes bleiben
assert.equal(gueltig(name('\u{1F469}\u200D\u{1F469}\u200D\u{1F467} Salon')).betrieb.name, '\u{1F469}\u200D\u{1F469}\u200D\u{1F467} Salon')
assert.equal(gueltig(name('می\u200Cخواهم')).betrieb.name, 'می\u200Cخواهم')
// am Rand sind sie wertlos und fallen weg
assert.equal(gueltig(name('\u200DSalon\u200C ')).betrieb.name, 'Salon')
})
test('Unsichtbare Formatzeichen: auch Leistungs-/Ressourcennamen, Storno, E-Mail und URL', () => {
const e = basis()
e.ressourcen[0].name = '\u200B'
e.leistungen[0].name = '\uFEFF\u200F'
const f = fehlerVon(e)
assert.ok(f['ressourcen.0.name'])
assert.ok(f['leistungen.0.name'])
const ok = basis()
ok.ressourcen[0].name = 'An\u200Bna'
ok.leistungen[0].beschreibung = 'Mit\u200E\u2060 Waschen'
ok.storno = { text: 'Zeile\u200B 1\n\u200EZeile 2' }
ok.betrieb = { ...PFLICHT, name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' }
const e2 = gueltig(ok)
assert.equal(e2.ressourcen[0].name, 'Anna')
assert.equal(e2.leistungen[0].beschreibung, 'Mit Waschen')
assert.equal(e2.storno.text, 'Zeile 1\nZeile 2')
assert.equal(e2.betrieb.email, 'info@salon.de')
assert.equal(e2.betrieb.impressumUrl, 'https://salon.de/impressum')
})
test('Farbe, Anrede, Bundesland werden geprueft', () => {
assert.equal(gueltig({ ...basis(), farbe: '#AABBCC' }).farbe, '#aabbcc')
for (const schlecht of ['red', '#abc', '#12345g', 'ffffff', 5]) assert.ok(fehlerVon({ ...basis(), farbe: schlecht }).farbe, String(schlecht))
assert.equal(gueltig({ ...basis(), anrede: 'du' }).anrede, 'du')
assert.ok(fehlerVon({ ...basis(), anrede: 'ihr' }).anrede)
for (const land of BUNDESLAENDER) assert.equal(gueltig({ ...basis(), bundesland: land }).bundesland, land)
for (const schlecht of ['XX', 'rp', 'Bayern', 'BUND', 'ALL', 5]) assert.ok(fehlerVon({ ...basis(), bundesland: schlecht }).bundesland, String(schlecht))
assert.ok(fehlerVon({ ...basis(), feiertageGeschlossen: 'ja' }).feiertageGeschlossen)
})
// ---------------------------------------------------------------- Wochenplan
test('Fenster: Minuten muessen Vielfache von 5 sein (09:07 => Fehler am Plan-Pfad)', () => {
const eingabe = basis()
eingabe.ressourcen[0].plan = { 1: [['09:07', '12:00']] }
assert.ok(fehlerVon(eingabe)['ressourcen.0.plan.1'])
})
test('Fenster: Format, Bereich, Ende nach Beginn, 24:00 nur als Ende', () => {
const mit = (fenster) => { const e = basis(); e.ressourcen[0].plan = { 3: fenster }; return e }
for (const schlecht of [
[['9:00', '12:00']], [['09:00', '12:00:00']], [['25:00', '26:00']], [['09:60', '12:00']], [['12:00', '12:00']], [['12:00', '09:00']],
[['24:00', '24:00']], [['09:00']], [['09:00', '12:00', '13:00']], ['09:00-12:00'], [[9, 12]], 'montags', [['09:00', '24:05']],
]) {
assert.ok(fehlerVon(mit(schlecht))['ressourcen.0.plan.3'], JSON.stringify(schlecht))
}
assert.deepEqual(gueltig(mit([['18:00', '24:00']])).ressourcen[0].plan, { 3: [['18:00', '24:00']] })
assert.deepEqual(gueltig(mit([['00:00', '00:05']])).ressourcen[0].plan, { 3: [['00:00', '00:05']] })
})
test('Fenster: Ueberlappung und falsche Reihenfolge => Fehler am Pfad ressourcen.0.plan.1', () => {
const mit = (fenster) => { const e = basis(); e.ressourcen[0].plan = { 1: [['08:00', '08:30']], 2: fenster }; return e }
const ueberlappt = basis()
ueberlappt.ressourcen[0].plan = { 1: [['09:00', '12:00'], ['11:00', '15:00']] }
assert.match(fehlerVon(ueberlappt)['ressourcen.0.plan.1'], /überlappen/)
const unsortiert = basis()
unsortiert.ressourcen[0].plan = { 1: [['13:00', '15:00'], ['09:00', '12:00']] }
assert.match(fehlerVon(unsortiert)['ressourcen.0.plan.1'], /aufsteigend/)
// direkt aneinander ist keine Ueberlappung
assert.deepEqual(gueltig(mit([['09:00', '12:00'], ['12:00', '15:00']])).ressourcen[0].plan[2], [['09:00', '12:00'], ['12:00', '15:00']])
})
test('Wochenplan: nur Wochentage 1 bis 7; leere Tage entfallen', () => {
const e = basis()
e.ressourcen[0].plan = { 0: [['09:00', '10:00']] }
assert.ok(fehlerVon(e)['ressourcen.0.plan.0'])
e.ressourcen[0].plan = { 8: [['09:00', '10:00']] }
assert.ok(fehlerVon(e)['ressourcen.0.plan.8'])
e.ressourcen[0].plan = { 1: [], 2: [['09:00', '10:00']] }
assert.deepEqual(gueltig(e).ressourcen[0].plan, { 2: [['09:00', '10:00']] })
e.ressourcen[0].plan = 'immer'
assert.ok(fehlerVon(e)['ressourcen.0.plan'])
})
// ---------------------------------------------------------------- Ressourcen und Leistungen
test('IDs: Format, Praefix, Eindeutigkeit; Eintraege ohne ID bekommen eine', () => {
const e = basis()
e.ressourcen.push({ name: 'Ben' })
e.leistungen.push({ name: 'Herrenschnitt', dauer: 30, ressourcen: [R1] })
const ok = gueltig(e)
assert.match(ok.ressourcen[1].id, /^r_[a-z0-9]{8}$/)
assert.match(ok.leistungen[1].id, /^s_[a-z0-9]{8}$/)
const falsch = basis()
falsch.ressourcen[0].id = 'x_aaaaaaa1'
assert.ok(fehlerVon(falsch)['ressourcen.0.id'])
const zuKurz = basis()
zuKurz.leistungen[0].id = 's_abc'
assert.ok(fehlerVon(zuKurz)['leistungen.0.id'])
const grossbuchstaben = basis()
grossbuchstaben.leistungen[0].id = 's_BBBBBBB1'
assert.ok(fehlerVon(grossbuchstaben)['leistungen.0.id'])
const doppelt = basis()
doppelt.ressourcen.push({ id: R1, name: 'Doppelt' })
assert.ok(fehlerVon(doppelt)['ressourcen.1.id'])
const doppelteLeistung = basis()
doppelteLeistung.leistungen.push({ id: S1, name: 'Nochmal', dauer: 30, ressourcen: [R1] })
assert.ok(fehlerVon(doppelteLeistung)['leistungen.1.id'])
})
test('Ressource: Name Pflicht, Typ person|platz, Reihenfolge der Liste bleibt', () => {
const e = basis()
e.ressourcen = [{ id: R1, name: '' }]
e.leistungen = []
assert.ok(fehlerVon(e)['ressourcen.0.name'])
e.ressourcen = [{ id: R1, name: 'A', typ: 'zimmer' }]
assert.ok(fehlerVon(e)['ressourcen.0.typ'])
e.ressourcen = [{ id: R2, name: 'Platz 2', typ: 'platz' }, { id: R1, name: 'Platz 1', typ: 'platz' }]
assert.deepEqual(gueltig(e).ressourcen.map((r) => r.id), [R2, R1])
})
test('Leistung: Pflichtfelder, Dauer und Pause in 5-Minuten-Schritten, Summe hoechstens 500', () => {
const mit = (aenderung) => { const e = basis(); Object.assign(e.leistungen[0], aenderung); return e }
assert.ok(fehlerVon(mit({ name: '' }))['leistungen.0.name'])
assert.ok(fehlerVon(mit({ dauer: undefined }))['leistungen.0.dauer'])
for (const dauer of [0, 4, 7, 485, 481, -5, 30.5, '30']) assert.ok(fehlerVon(mit({ dauer }))['leistungen.0.dauer'], String(dauer))
for (const dauer of [5, 480, 45]) assert.equal(gueltig(mit({ dauer })).leistungen[0].dauer, dauer)
for (const pufferNach of [-5, 3, 125, 121, '5']) assert.ok(fehlerVon(mit({ pufferNach }))['leistungen.0.pufferNach'], String(pufferNach))
for (const pufferNach of [0, 5, 120]) assert.equal(gueltig(mit({ pufferNach })).leistungen[0].pufferNach, pufferNach)
assert.ok(fehlerVon(mit({ dauer: 480, pufferNach: 25 }))['leistungen.0.dauer'])
assert.equal(gueltig(mit({ dauer: 480, pufferNach: 20 })).leistungen[0].pufferNach, 20)
})
test('Leistung: Preis, Preisart und Buchungsart', () => {
const mit = (aenderung) => { const e = basis(); Object.assign(e.leistungen[0], aenderung); return e }
assert.ok(fehlerVon(mit({ preisArt: 'gratis' }))['leistungen.0.preisArt'])
assert.ok(fehlerVon(mit({ buchungsart: 'online' }))['leistungen.0.buchungsart'])
for (const preisCent of [-1, 1000001, 4.5, '45']) assert.ok(fehlerVon(mit({ preisArt: 'fest', preisCent }))['leistungen.0.preisCent'], String(preisCent))
const fest = gueltig(mit({ preisArt: 'fest', preisCent: 4500 })).leistungen[0]
assert.equal(fest.preisCent, 4500)
assert.equal(gueltig(mit({ preisArt: 'ab', preisCent: 1000000 })).leistungen[0].preisCent, 1000000)
// ohne Preis wird die Zahl nicht mitgeschleppt
assert.equal(gueltig(mit({ preisArt: 'keiner', preisCent: 999 })).leistungen[0].preisCent, 0)
assert.equal(gueltig(mit({ buchungsart: 'anfrage' })).leistungen[0].buchungsart, 'anfrage')
})
test('Leistung: Beschreibung gekuerzt, Reihenfolge folgt der Liste', () => {
const e = basis()
e.leistungen[0].beschreibung = 'y'.repeat(500)
e.leistungen.push({ id: S2, name: 'Zweite', dauer: 15, ressourcen: [R1] })
const ok = gueltig(e)
assert.equal([...ok.leistungen[0].beschreibung].length, 300)
assert.deepEqual(ok.leistungen.map((l) => l.reihenfolge), [1, 2])
})
test('Leistung ohne aktive Ressource ist abgelehnt (aktiv, inaktiv, leer, unbekannt)', () => {
const ohne = basis()
ohne.leistungen[0].ressourcen = []
assert.ok(fehlerVon(ohne)['leistungen.0.ressourcen'])
const inaktiv = basis()
inaktiv.ressourcen[0].aktiv = false
assert.ok(fehlerVon(inaktiv)['leistungen.0.ressourcen'])
const unbekannt = basis()
unbekannt.leistungen[0].ressourcen = ['r_zzzzzzz9']
assert.ok(fehlerVon(unbekannt)['leistungen.0.ressourcen'])
const gemischt = basis()
gemischt.ressourcen.push({ id: R2, name: 'Ben', aktiv: false })
gemischt.leistungen[0].ressourcen = [R2, R1, R1]
assert.deepEqual(gueltig(gemischt).leistungen[0].ressourcen, [R2, R1])
// eine inaktive Leistung darf ohne aktive Ressource bleiben
const ruhend = basis()
ruhend.leistungen[0].aktiv = false
ruhend.leistungen[0].ressourcen = []
assert.equal(gueltig(ruhend).leistungen[0].aktiv, false)
})
// ---------------------------------------------------------------- Entfernen mit kuenftigen Terminen
test('Entfernen einer Leistung mit kuenftigen Terminen => Fehler mit Anzahl', () => {
const bisher = gueltig(basis())
const neu = basis()
neu.leistungen = []
const f = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 3 }, ressourcen: {} } })
assert.equal(f[`leistungen.${S1}`], 'Für diese Leistung gibt es noch 3 künftige Termine. Deaktiviere sie stattdessen.')
const einzahl = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 1 }, ressourcen: {} } })
assert.equal(einzahl[`leistungen.${S1}`], 'Für diese Leistung gibt es noch 1 künftigen Termin. Deaktiviere sie stattdessen.')
// ohne kuenftige Termine darf sie weg, ebenso ohne Angabe
assert.deepEqual(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 0 }, ressourcen: {} } }).leistungen, [])
assert.deepEqual(gueltig(neu, { bisher }).leistungen, [])
assert.deepEqual(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S2]: 9 } } }).leistungen, [])
})
test('Entfernen einer Ressource mit kuenftigen Terminen => Fehler mit Anzahl (Person und Platz)', () => {
const bisher = gueltig(basis())
const neu = basis()
neu.ressourcen = []
neu.leistungen[0].aktiv = false
neu.leistungen[0].ressourcen = []
const f = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: {}, ressourcen: { [R1]: 4 } } })
assert.equal(f[`ressourcen.${R1}`], 'Für diese Person gibt es noch 4 künftige Termine. Deaktiviere sie stattdessen.')
const platzBasis = basis()
platzBasis.ressourcen[0].typ = 'platz'
const platzBisher = gueltig(platzBasis)
const f2 = fehlerVon(neu, { bisher: platzBisher, kuenftigeTermine: { ressourcen: { [R1]: 2 } } })
assert.equal(f2[`ressourcen.${R1}`], 'Für diesen Platz gibt es noch 2 künftige Termine. Deaktiviere ihn stattdessen.')
assert.equal(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: {}, ressourcen: { [R1]: 0 } } }).ressourcen.length, 0)
})
test('Deaktivieren ist trotz kuenftiger Termine erlaubt', () => {
const bisher = gueltig(basis())
const neu = basis()
neu.leistungen[0].aktiv = false
neu.ressourcen.push({ id: R2, name: 'Ben' })
neu.ressourcen[0].aktiv = false
neu.leistungen[0].ressourcen = [R2]
const e = gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 5 }, ressourcen: { [R1]: 5 } } })
assert.equal(e.leistungen[0].aktiv, false)
assert.equal(e.ressourcen[0].aktiv, false)
})
// ---------------------------------------------------------------- Feed-Token
test('feedToken: Client-Wert wird ignoriert, bisheriger bleibt, neue Ressourcen bekommen einen', () => {
const eins = gueltig({ ...basis(), ressourcen: [{ ...basis().ressourcen[0], feedToken: 'vom-client' }] })
assert.notEqual(eins.ressourcen[0].feedToken, 'vom-client')
assert.match(eins.ressourcen[0].feedToken, /^[A-Za-z0-9_-]{43}$/)
const zwei = basis()
zwei.ressourcen[0].feedToken = 'vom-client'
zwei.ressourcen.push({ id: R2, name: 'Ben', feedToken: eins.ressourcen[0].feedToken })
const nachher = gueltig(zwei, { bisher: eins })
assert.equal(nachher.ressourcen[0].feedToken, eins.ressourcen[0].feedToken)
assert.match(nachher.ressourcen[1].feedToken, /^[A-Za-z0-9_-]{43}$/)
assert.notEqual(nachher.ressourcen[1].feedToken, eins.ressourcen[0].feedToken)
})
// ---------------------------------------------------------------- Abwesenheiten, Sonderoeffnungen
test('Abwesenheiten: von <= bis, gueltige Tage, bekannte Ressource oder null', () => {
const mit = (a) => ({ ...basis(), abwesenheiten: [a] })
const ok = gueltig(mit({ ressourceId: null, von: '2026-12-27', bis: '2026-12-31', titel: 'Betriebsurlaub' })).abwesenheiten
assert.equal(ok.length, 1)
assert.match(ok[0].id, /^a_[a-z0-9]{8}$/)
assert.deepEqual({ ...ok[0], id: 'x' }, { id: 'x', ressourceId: null, von: '2026-12-27', bis: '2026-12-31', titel: 'Betriebsurlaub' })
assert.equal(gueltig(mit({ ressourceId: R1, von: '2026-12-27', bis: '2026-12-27' })).abwesenheiten[0].ressourceId, R1)
assert.ok(fehlerVon(mit({ von: '2026-12-31', bis: '2026-12-27' }))['abwesenheiten.0.bis'])
assert.ok(fehlerVon(mit({ von: '2026-02-30', bis: '2026-03-01' }))['abwesenheiten.0.von'])
assert.ok(fehlerVon(mit({ von: '27.12.2026', bis: '2026-12-31' }))['abwesenheiten.0.von'])
assert.ok(fehlerVon(mit({ von: '2026-12-01' }))['abwesenheiten.0.bis'])
assert.ok(fehlerVon(mit({ ressourceId: 'r_zzzzzzz9', von: '2026-12-27', bis: '2026-12-31' }))['abwesenheiten.0.ressourceId'])
assert.ok(fehlerVon(mit({ id: 'r_aaaaaaa1', von: '2026-12-27', bis: '2026-12-31' }))['abwesenheiten.0.id'])
})
test('Sonderoeffnungen: gueltiger Tag, Fenster wie im Plan, leere Liste = geschlossen', () => {
const mit = (s) => ({ ...basis(), sonderoeffnungen: s })
assert.deepEqual(gueltig(mit({ '2026-12-24': [['09:00', '13:00']], '2026-12-31': [] })).sonderoeffnungen, {
'2026-12-24': [['09:00', '13:00']], '2026-12-31': [],
})
assert.deepEqual(gueltig(mit({ '2026-12-24': [['09:00', '24:00']] })).sonderoeffnungen['2026-12-24'], [['09:00', '24:00']])
assert.ok(fehlerVon(mit({ '2026-13-01': [['09:00', '13:00']] }))['sonderoeffnungen.2026-13-01'])
assert.ok(fehlerVon(mit({ 'morgen': [['09:00', '13:00']] }))['sonderoeffnungen.morgen'])
assert.ok(fehlerVon(mit({ '2026-12-24': [['09:03', '13:00']] }))['sonderoeffnungen.2026-12-24'])
assert.ok(fehlerVon(mit({ '2026-12-24': [['09:00', '13:00'], ['12:00', '14:00']] }))['sonderoeffnungen.2026-12-24'])
assert.ok(fehlerVon(mit([['09:00', '13:00']]))['sonderoeffnungen'])
})
// ---------------------------------------------------------------- Regeln, Gastfelder, Erinnerung, Zahlung
test('Regeln: Wertebereiche', () => {
const mit = (r) => ({ ...basis(), regeln: r })
assert.deepEqual(gueltig(mit({ raster: 30, vorlaufMin: 0, horizontTage: 365, absageBisStd: 336, maxOffeneJeEmail: 10, personWaehlbar: false })).regeln,
{ raster: 30, vorlaufMin: 0, horizontTage: 365, absageBisStd: 336, maxOffeneJeEmail: 10, personWaehlbar: false })
for (const raster of [5, 10, 15, 20, 30, 60]) assert.equal(gueltig(mit({ raster })).regeln.raster, raster)
for (const raster of [0, 1, 25, 45, 90, '15']) assert.ok(fehlerVon(mit({ raster }))['regeln.raster'], String(raster))
for (const vorlaufMin of [-1, 20161, 1.5]) assert.ok(fehlerVon(mit({ vorlaufMin }))['regeln.vorlaufMin'], String(vorlaufMin))
assert.equal(gueltig(mit({ vorlaufMin: 20160 })).regeln.vorlaufMin, 20160)
for (const horizontTage of [0, 366, -1]) assert.ok(fehlerVon(mit({ horizontTage }))['regeln.horizontTage'], String(horizontTage))
assert.equal(gueltig(mit({ horizontTage: 1 })).regeln.horizontTage, 1)
for (const absageBisStd of [-1, 337]) assert.ok(fehlerVon(mit({ absageBisStd }))['regeln.absageBisStd'], String(absageBisStd))
assert.equal(gueltig(mit({ absageBisStd: 0 })).regeln.absageBisStd, 0)
for (const maxOffeneJeEmail of [0, 11]) assert.ok(fehlerVon(mit({ maxOffeneJeEmail }))['regeln.maxOffeneJeEmail'], String(maxOffeneJeEmail))
assert.ok(fehlerVon(mit({ personWaehlbar: 'ja' }))['regeln.personWaehlbar'])
})
test('Gastfelder: aus|optional|pflicht, bis zu 5 Zusatzfelder', () => {
const mit = (g) => ({ ...basis(), gastFelder: g })
assert.deepEqual(gueltig(mit({ telefon: 'pflicht', nachricht: 'optional' })).gastFelder, { telefon: 'pflicht', nachricht: 'optional', zusatz: [] })
assert.ok(fehlerVon(mit({ telefon: 'vielleicht' }))['gastFelder.telefon'])
assert.ok(fehlerVon(mit({ nachricht: 'an' }))['gastFelder.nachricht'])
const zusatz = [
{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true },
{ key: 'reifen', label: 'Reifen', typ: 'auswahl', optionen: ['Sommer', 'Winter', 'Sommer'], pflicht: false },
]
const ok = gueltig(mit({ zusatz })).gastFelder.zusatz
assert.deepEqual(ok, [
{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', optionen: [], pflicht: true },
{ key: 'reifen', label: 'Reifen', typ: 'auswahl', optionen: ['Sommer', 'Winter'], pflicht: false },
])
const sechs = Array.from({ length: 6 }, (_, i) => ({ key: `feld${i}`, label: `Feld ${i}`, typ: 'text' }))
assert.ok(fehlerVon(mit({ zusatz: sechs }))['gastFelder.zusatz'])
assert.equal(gueltig(mit({ zusatz: sechs.slice(0, 5) })).gastFelder.zusatz.length, 5)
for (const key of ['Kennzeichen', 'a-b', '', 'x'.repeat(21), 'ä', '__proto__', 'constructor', 'prototype']) {
assert.ok(fehlerVon(mit({ zusatz: [{ key, label: 'X', typ: 'text' }] }))['gastFelder.zusatz.0.key'], key)
}
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X' }, { key: 'a', label: 'Y' }] }))['gastFelder.zusatz.1.key'])
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: '' }] }))['gastFelder.zusatz.0.label'])
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'datei' }] }))['gastFelder.zusatz.0.typ'])
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: [] }] }))['gastFelder.zusatz.0.optionen'])
const elf = Array.from({ length: 11 }, (_, i) => `Option ${i}`)
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: elf }] }))['gastFelder.zusatz.0.optionen'])
assert.equal(gueltig(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: elf.slice(0, 10) }] })).gastFelder.zusatz[0].optionen.length, 10)
})
test('Erinnerung, Recht, Aufbewahrung', () => {
for (const stundenVorher of [2, 12, 24, 48]) assert.equal(gueltig({ ...basis(), erinnerung: { stundenVorher } }).erinnerung.stundenVorher, stundenVorher)
for (const stundenVorher of [1, 6, 36, 72, '24']) assert.ok(fehlerVon({ ...basis(), erinnerung: { stundenVorher } })['erinnerung.stundenVorher'], String(stundenVorher))
assert.equal(gueltig({ ...basis(), erinnerung: { anbieten: false } }).erinnerung.anbieten, false)
assert.ok(fehlerVon({ ...basis(), erinnerung: { anbieten: 'nein' } })['erinnerung.anbieten'])
assert.deepEqual(gueltig({ ...basis(), recht: { gaesteSind: 'gemischt', freizeit: true } }).recht, { gaesteSind: 'gemischt', freizeit: true })
assert.ok(fehlerVon({ ...basis(), recht: { gaesteSind: 'firmen' } })['recht.gaesteSind'])
assert.ok(fehlerVon({ ...basis(), recht: { freizeit: 1 } })['recht.freizeit'])
for (const tage of [14, 365, 90]) assert.equal(gueltig({ ...basis(), aufbewahrungTage: tage }).aufbewahrungTage, tage)
for (const tage of [13, 366, 0, 90.5]) assert.ok(fehlerVon({ ...basis(), aufbewahrungTage: tage }).aufbewahrungTage, String(tage))
})
test('Zahlung: nur vorort; online/beides => "Online-Zahlung kommt bald"; Zahlarten geprueft', () => {
const mit = (z) => ({ ...basis(), zahlung: z })
assert.equal(fehlerVon(mit({ modus: 'online' }))['zahlung.modus'], 'Online-Zahlung kommt bald')
assert.equal(fehlerVon(mit({ modus: 'beides' }))['zahlung.modus'], 'Online-Zahlung kommt bald')
assert.ok(fehlerVon(mit({ modus: 'bitcoin' }))['zahlung.modus'])
assert.equal(gueltig(mit({ modus: 'vorort' })).zahlung.modus, 'vorort')
assert.deepEqual(gueltig(mit({ vorOrtArten: ['paypal', 'bar', 'bar', 'ueberweisung', 'ec', 'kreditkarte'] })).zahlung.vorOrtArten,
['bar', 'ec', 'kreditkarte', 'ueberweisung', 'paypal'])
assert.deepEqual(gueltig(mit({ vorOrtArten: [] })).zahlung.vorOrtArten, [])
assert.ok(fehlerVon(mit({ vorOrtArten: ['bar', 'bitcoin'] }))['zahlung.vorOrtArten'])
assert.ok(fehlerVon(mit({ vorOrtArten: 'bar' }))['zahlung.vorOrtArten'])
})
// ---------------------------------------------------------------- Storno
test('Storno: Version YYYY-MM-DD-n nur bei geaendertem Text (Tag aus now, Berlin)', () => {
const a = gueltig({ ...basis(), storno: { text: 'Absage bis 24 Stunden vorher.', version: 'vom-client' } })
assert.deepEqual(a.storno, { text: 'Absage bis 24 Stunden vorher.', version: '2026-10-02-1' })
// unveraenderter Text: Version bleibt, auch wenn der Client eine andere schickt
const b = gueltig({ ...basis(), storno: { text: 'Absage bis 24 Stunden vorher.', version: '1999-01-01-9' } }, { bisher: a })
assert.equal(b.storno.version, '2026-10-02-1')
// geaenderter Text am selben Tag: n + 1
const c = gueltig({ ...basis(), storno: { text: 'Absage bis 48 Stunden vorher.' } }, { bisher: b })
assert.equal(c.storno.version, '2026-10-02-2')
// naechster Tag: wieder 1; kurz nach Mitternacht Berlin zaehlt als neuer Tag
const d = gueltig({ ...basis(), storno: { text: 'Absage bis 12 Stunden vorher.' } }, { bisher: c, now: new Date('2026-10-02T22:30:00.000Z') })
assert.equal(d.storno.version, '2026-10-03-1')
const e = gueltig({ ...basis(), storno: { text: 'Absage bis 6 Stunden vorher.' } }, { bisher: d, now: new Date('2026-10-03T10:00:00.000Z') })
assert.equal(e.storno.version, '2026-10-03-2')
// Text entfernt: Text leer, Version bleibt; spaeter neuer Text zaehlt weiter
const f = gueltig(basis(), { bisher: e, now: new Date('2026-10-03T11:00:00.000Z') })
assert.deepEqual(f.storno, { text: '', version: '2026-10-03-2' })
const g = gueltig({ ...basis(), storno: { text: 'Wieder da.' } }, { bisher: f, now: new Date('2026-10-03T12:00:00.000Z') })
assert.equal(g.storno.version, '2026-10-03-3')
})
test('Storno: Zeilenumbrueche bleiben, andere Steuerzeichen nicht; auf 2000 Zeichen gekuerzt', () => {
const e = gueltig({ ...basis(), storno: { text: `Zeile 1\r\nZeile\u0007 2\n\n\n\nZeile 3 ${'x'.repeat(3000)}` } })
assert.ok(e.storno.text.startsWith('Zeile 1\nZeile 2\n\nZeile 3 xxx'))
assert.equal([...e.storno.text].length, 2000)
})
// ---------------------------------------------------------------- Obergrenzen
test('Obergrenzen: 20 Ressourcen, 50 Leistungen, 100 Abwesenheiten, 200 Sonderoeffnungen', () => {
const ressourcen = (n) => Array.from({ length: n }, (_, i) => ({ name: `R${i}` }))
assert.equal(gueltig({ ...basis(), ressourcen: ressourcen(20), leistungen: [] }).ressourcen.length, 20)
assert.ok(fehlerVon({ ...basis(), ressourcen: ressourcen(21), leistungen: [] }).ressourcen)
const leistungen = (n) => Array.from({ length: n }, (_, i) => ({ name: `L${i}`, dauer: 30, ressourcen: [R1] }))
assert.equal(gueltig({ ...basis(), leistungen: leistungen(50) }).leistungen.length, 50)
assert.ok(fehlerVon({ ...basis(), leistungen: leistungen(51) }).leistungen)
const abwesenheiten = (n) => Array.from({ length: n }, () => ({ von: '2026-12-01', bis: '2026-12-02' }))
assert.equal(gueltig({ ...basis(), abwesenheiten: abwesenheiten(100) }).abwesenheiten.length, 100)
assert.ok(fehlerVon({ ...basis(), abwesenheiten: abwesenheiten(101) }).abwesenheiten)
const tage = (n) => Object.fromEntries(Array.from({ length: n }, (_, i) => [new Date(Date.UTC(2027, 0, 1 + i)).toISOString().slice(0, 10), [['09:00', '12:00']]]))
assert.equal(Object.keys(gueltig({ ...basis(), sonderoeffnungen: tage(200) }).sonderoeffnungen).length, 200)
assert.ok(fehlerVon({ ...basis(), sonderoeffnungen: tage(201) }).sonderoeffnungen)
})
test('Obergrenze: Gesamtgroesse hoechstens 200 000 Zeichen (Fehler unter "_")', () => {
const uhr = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`
// 144 Fenster je Tag (jeweils 5 Minuten, dazwischen 5 Minuten Luecke)
const tag = Array.from({ length: 144 }, (_, i) => [uhr(i * 10), uhr(i * 10 + 5)])
const wochenplan = Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((n) => [n, tag]))
const ressourcen = (n) => Array.from({ length: n }, (_, i) => ({ name: `R${i}`, plan: wochenplan }))
const zuGross = { ...basis(), ressourcen: ressourcen(20), leistungen: [] }
const f = fehlerVon(zuGross)
assert.ok(f._)
assert.deepEqual(Object.keys(f), ['_'])
const noch = { ...basis(), ressourcen: ressourcen(8), leistungen: [] }
const e = gueltig(noch)
assert.ok(JSON.stringify(e).length <= 200000)
assert.ok(JSON.stringify(e).length > 100000)
// Die Grenzen der Einzellisten zusammen bleiben darunter
const gross = basis()
gross.leistungen = Array.from({ length: 50 }, () => ({ name: 'N'.repeat(100), beschreibung: 'b'.repeat(300), dauer: 30, ressourcen: [R1] }))
gross.abwesenheiten = Array.from({ length: 100 }, () => ({ titel: 't'.repeat(80), von: '2026-12-01', bis: '2026-12-02' }))
gross.storno = { text: 's'.repeat(2000) }
assert.ok(JSON.stringify(gueltig(gross)).length < 200000)
})
// ---------------------------------------------------------------- Texte
test('knopfText: verbindlich + Preis, verbindlich ohne Preis, Anfrage (und Bestaetigungsseite)', () => {
const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 }
const abPreis = { buchungsart: 'sofort', preisArt: 'ab', preisCent: 4500 }
const ohnePreis = { buchungsart: 'sofort', preisArt: 'keiner', preisCent: 0 }
const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 }
assert.equal(knopfText(mitPreis), 'Zahlungspflichtig buchen')
assert.equal(knopfText(abPreis), 'Zahlungspflichtig buchen')
assert.equal(knopfText(ohnePreis), 'Termin buchen')
assert.equal(knopfText(anfrage), 'Termin anfragen')
assert.equal(knopfText(mitPreis, { bestaetigen: true }), 'Zahlungspflichtig bestätigen')
assert.equal(knopfText(ohnePreis, { bestaetigen: true }), 'Termin bestätigen')
assert.equal(knopfText(anfrage, { bestaetigen: true }), 'Anfrage absenden')
assert.equal(knopfText(mitPreis, { bestaetigen: false }), 'Zahlungspflichtig buchen')
// Preis 0 gilt als kein Preis
assert.equal(knopfText({ buchungsart: 'sofort', preisArt: 'fest', preisCent: 0 }), 'Termin buchen')
})
test('istVerbraucher: nicht nur Unternehmer und keine Freizeitleistung', () => {
assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: false } }), true)
assert.equal(istVerbraucher({ recht: { gaesteSind: 'gemischt', freizeit: false } }), true)
assert.equal(istVerbraucher({ recht: { gaesteSind: 'unternehmer', freizeit: false } }), false)
assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: true } }), false)
})
test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn bis zum Ende der Widerrufsfrist (Berliner Kalender, §§ 187, 188, 193 BGB)', () => {
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
const unternehmer = { recht: { gaesteSind: 'unternehmer', freizeit: false } }
const freizeit = { recht: { gaesteSind: 'verbraucher', freizeit: true } }
const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 }
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
// NOW = Freitag 02.10.2026, 12:00 Berlin; Vertragsschluss spaetestens nach der Haltezeit (30 Minuten): Fristende
// Freitag 16.10.2026, 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC (MESZ)
const FRISTENDE = Date.parse('2026-10-16T21:59:59.999Z')
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(3), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(0), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false)
// Grenze: die letzte Millisekunde der Frist ja, die naechste nein
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE + 1), NOW), false)
// 14 mal 24 Stunden reichen nicht: 14 Tage nach der Buchung um 12:00 liegen noch in der Frist (bis 24 Uhr des Tages)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'ab' }, inTagen(3), NOW), true)
// Fristende auf einem Wochenende ⇒ naechster Werktag: Samstag 10.10.2026 + 14 = Samstag 24.10. ⇒ Montag 26.10., 23:59:59,999 Uhr Berlin (MEZ)
const SAMSTAG = new Date('2026-10-10T10:00:00.000Z')
const wochenendeEnde = Date.parse('2026-10-26T22:59:59.999Z')
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde), SAMSTAG), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde + 1), SAMSTAG), false)
// die Haltezeit zaehlt mit: wer um 23:45 Uhr bucht, schliesst den Vertrag erst nach Mitternacht (Fristende einen Tag spaeter)
// (Samstag 03.10. + 14 = Samstag 17.10. ⇒ Montag 19.10., 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC)
const spaet = new Date('2026-10-02T21:45:00.000Z') // 23:45 Uhr Berlin, Freitag; + 30 Minuten = Samstag 00:15 Uhr
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-16T21:59:59.999Z') + 1), spaet), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-19T21:59:59.999Z') + 1), spaet), false)
// Unternehmer, Freizeit, kein Preis: keine Zustimmung noetig
assert.equal(brauchtZustimmungVorzeitig(unternehmer, mitPreis, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(freizeit, mitPreis, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
// ungueltiger Beginn: lieber die Zustimmung verlangen
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date('kaputt'), NOW), true)
})
test('brauchtZustimmungVorzeitig: bei einer Anfrage mit Preis fuer Verbraucher immer (der Tag der Annahme ist unbekannt)', () => {
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 }
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(3), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(60), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, new Date('kaputt'), NOW), true)
// ohne Preis, Unternehmer, Freizeit: nein
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...anfrage, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'unternehmer', freizeit: false } }, anfrage, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'verbraucher', freizeit: true } }, anfrage, inTagen(3), NOW), false)
})
test('preisText, preisHinweis, zahlungText', () => {
assert.equal(preisText({ preisArt: 'fest', preisCent: 4500 }), '45,00 €')
assert.equal(preisText({ preisArt: 'ab', preisCent: 4500 }), 'ab 45,00 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 4505 }), '45,05 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 5 }), '0,05 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 123456 }), '1.234,56 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 1000000 }), '10.000,00 €')
assert.equal(preisText({ preisArt: 'keiner', preisCent: 0 }), '')
assert.equal(preisText({ preisArt: 'keiner', preisCent: 4500 }), '')
assert.equal(preisText({ preisArt: 'fest', preisCent: 0 }), '')
assert.equal(preisHinweis({ betrieb: { kleinunternehmer: false } }), 'inkl. USt')
assert.equal(preisHinweis({ betrieb: {} }), 'inkl. USt')
assert.equal(preisHinweis({ betrieb: { kleinunternehmer: true } }), 'kein Ausweis der USt nach § 19 UStG')
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] } }), 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte')
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: ['ueberweisung', 'paypal'] } }), 'Bezahlung vor Ort: Überweisung, PayPal')
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: [] } }), 'Bezahlung vor Ort')
})
test('R13: Telefon ist immer Pflicht (Pfad betrieb.telefon), auch ohne Widerrufsbelehrung; Leerraum und Steuerzeichen zaehlen als leer', () => {
const unternehmer = { ...basis(), recht: { gaesteSind: 'unternehmer' }, betrieb: { name: 'X', email: 'a@b.de', adresse: PFLICHT.adresse } }
assert.deepEqual(Object.keys(fehlerVon(unternehmer)), ['betrieb.telefon'])
for (const leer of [undefined, null, '', ' ', '\t\n', '\u200b\u200b', 42, {}]) {
const f = fehlerVon({ ...basis(), betrieb: { ...basis().betrieb, telefon: leer } })
assert.deepEqual(Object.keys(f), ['betrieb.telefon'], String(leer))
assert.match(f['betrieb.telefon'], /Telefonnummer/)
}
assert.equal(gueltig({ ...unternehmer, betrieb: { ...unternehmer.betrieb, telefon: ' 0171 2345678 ' } }).betrieb.telefon, '0171 2345678')
})
test('R13a/R55: Anschrift ist Pflicht (Pfad betrieb.adresse), sobald die Widerrufsbelehrung erscheint: Verbraucher + sichtbare Leistung (sofort oder Anfrage)', () => {
const ohneAdresse = (zusatz = {}) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', telefon: PFLICHT.telefon }, ...zusatz })
const f = fehlerVon(ohneAdresse())
assert.deepEqual(Object.keys(f), ['betrieb.adresse'])
assert.match(f['betrieb.adresse'], /Anschrift/)
for (const leer of ['', ' ', '\u200b']) {
assert.deepEqual(Object.keys(fehlerVon({ ...ohneAdresse(), betrieb: { ...ohneAdresse().betrieb, adresse: leer } })), ['betrieb.adresse'])
}
// gemischte Gaeste sind auch Verbraucher
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse({ recht: { gaesteSind: 'gemischt' } }))), ['betrieb.adresse'])
// ohne Widerrufsbelehrung keine Anschrift noetig: nur Unternehmer oder Freizeitleistung
assert.equal(gueltig(ohneAdresse({ recht: { gaesteSind: 'unternehmer' } })).betrieb.adresse, '')
assert.equal(gueltig(ohneAdresse({ recht: { freizeit: true } })).betrieb.adresse, '')
// R55: auch ein Betrieb mit nur Anfrage-Leistungen braucht die Anschrift (der Vertrag kommt mit der Annahme zustande)
const anfragen = { leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] }
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse(anfragen))), ['betrieb.adresse'])
assert.equal(gueltig(ohneAdresse({ ...anfragen, recht: { gaesteSind: 'unternehmer' } })).betrieb.adresse, '')
// eine Leistung, die Gaeste nicht sehen (inaktiv oder nur an inaktiver Ressource), loest nichts aus
const ruht = {
ressourcen: [{ id: R1, name: 'Anna', plan: {} }, { id: R2, name: 'Ben', aktiv: false, plan: {} }],
leistungen: [
{ id: S1, name: 'Anfrage', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage', aktiv: false },
{ id: S2, name: 'Ruht', dauer: 30, ressourcen: [R1], buchungsart: 'sofort', aktiv: false },
{ id: 's_bbbbbbb3', name: 'Nur Ben', dauer: 30, ressourcen: [R2], buchungsart: 'sofort', aktiv: false },
],
}
assert.equal(gueltig(ohneAdresse(ruht)).betrieb.adresse, '')
// mit einer sichtbaren Leistung daneben wieder Pflicht
const mitSofort = { ...ruht, leistungen: [...ruht.leistungen, { id: 's_bbbbbbb4', name: 'Sofort', dauer: 30, ressourcen: [R1], buchungsart: 'sofort' }] }
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse(mitSofort))), ['betrieb.adresse'])
})
test('R13/R13a: beide Pflichtangaben fehlen => beide Fehler; Reihenfolge der Pfade Telefon, Anschrift; andere Fehler bleiben daneben', () => {
const f = fehlerVon({ ...basis(), betrieb: { name: '', email: 'a@b.de' } })
assert.deepEqual(Object.keys(f), ['betrieb.name', 'betrieb.telefon', 'betrieb.adresse'])
})
test('R13/R13a: fehlendePflichtangaben stimmt mit oeffentlicheEinstellung.widerruf ueberein (alle Kombinationen)', () => {
for (const gaesteSind of ['verbraucher', 'unternehmer', 'gemischt']) {
for (const freizeit of [false, true]) {
for (const buchungsart of ['sofort', 'anfrage']) {
for (const aktiv of [true, false]) {
const e = gueltig({
...basis(),
recht: { gaesteSind, freizeit },
leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart, aktiv }],
})
const name = JSON.stringify({ gaesteSind, freizeit, buchungsart, aktiv })
const widerruf = oeffentlicheEinstellung(e, NOW).widerruf
assert.equal(widerruf, gaesteSind !== 'unternehmer' && !freizeit && aktiv, `R55: unabhaengig von der Buchungsart ${name}`)
assert.equal(zeigtWiderruf(e), widerruf, name)
const ohne = { ...e, betrieb: { ...e.betrieb, adresse: '', telefon: '' } }
assert.deepEqual(fehlendePflichtangaben(ohne), widerruf ? ['betrieb.telefon', 'betrieb.adresse'] : ['betrieb.telefon'], name)
assert.deepEqual(fehlendePflichtangaben(e), [], name)
}
}
}
}
})
test('R41: entwurf laesst nur die beiden Pflichtangaben aus; alle anderen Pruefungen gelten weiter; ohne entwurf strikt', () => {
const roh = { ...basis(), betrieb: { name: 'Salon Müller', email: 'info@salon.de' } }
assert.deepEqual(Object.keys(fehlerVon(roh)), ['betrieb.telefon', 'betrieb.adresse'], 'Vorgabe: strikt')
assert.deepEqual(Object.keys(fehlerVon(roh, { entwurf: false })), ['betrieb.telefon', 'betrieb.adresse'])
const e = gueltig(roh, { entwurf: true })
assert.equal(e.betrieb.telefon, '')
assert.equal(e.betrieb.adresse, '')
const kaputt = { betrieb: { name: '', email: 'kein-postfach', impressumUrl: 'javascript:alert(1)' }, ressourcen: [], leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1] }] }
const f = fehlerVon(kaputt, { entwurf: true })
assert.deepEqual(Object.keys(f).sort(), ['betrieb.email', 'betrieb.impressumUrl', 'betrieb.name', 'leistungen.0.ressourcen'])
assert.ok(!('betrieb.telefon' in f) && !('betrieb.adresse' in f))
})
// ---------------------------------------------------------------- Oeffentliche Sicht
test('oeffentlicheEinstellung: genau die vereinbarten Felder, ohne feedToken und interne Angaben', () => {
const e = ausVorlage('salon', { ...BETRIEB, agbUrl: 'https://salon.de/agb' }, NOW)
const oe = oeffentlicheEinstellung(e, NOW)
assert.deepEqual(Object.keys(oe).sort(), [
'anrede', 'betrieb', 'erinnerung', 'farbe', 'gastFelder', 'heute', 'leistungen', 'personWaehlbar', 'preisHinweis',
'regeln', 'ressourcen', 'storno', 'widerruf', 'zahlung',
])
assert.equal(oe.heute, '2026-10-02')
assert.deepEqual(oe.betrieb, {
name: 'Salon Müller', telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler',
impressumUrl: 'https://salon.de/impressum', datenschutzUrl: 'https://salon.de/datenschutz', agbUrl: 'https://salon.de/agb',
})
assert.equal(oeffentlicheEinstellung(ausVorlage('salon', BETRIEB, NOW), NOW).betrieb.agbUrl, null, 'ohne AGB-Adresse null')
assert.equal(oe.anrede, 'sie')
assert.equal(oe.farbe, e.farbe)
assert.deepEqual(oe.regeln, { horizontTage: e.regeln.horizontTage, absageBisStd: e.regeln.absageBisStd })
assert.equal(oe.personWaehlbar, e.regeln.personWaehlbar)
assert.deepEqual(oe.zahlung, { modus: 'vorort', text: 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte' })
assert.equal(oe.preisHinweis, 'inkl. USt')
assert.deepEqual(oe.storno, { text: e.storno.text, version: e.storno.version })
assert.equal(oe.widerruf, true)
assert.deepEqual(oe.erinnerung, { anbieten: true, stundenVorher: 24 })
assert.deepEqual(oe.gastFelder, e.gastFelder)
assert.deepEqual(oe.ressourcen, e.ressourcen.map((r) => ({ id: r.id, name: r.name, typ: r.typ })))
const leistung = oe.leistungen[0]
assert.deepEqual(Object.keys(leistung).sort(), [
'beschreibung', 'buchungsart', 'dauer', 'id', 'knopf', 'name', 'preis', 'preisArt', 'ressourcen', 'verbraucher',
])
assert.equal(leistung.knopf, knopfText(e.leistungen[0]))
assert.equal(leistung.preis, preisText(e.leistungen[0]))
assert.equal(leistung.verbraucher, true)
const json = JSON.stringify(oe)
for (const geheim of ['feedToken', e.ressourcen[0].feedToken, 'info@salon.de', 'customerId', 'projectId', 'featureId', 'pufferNach', 'preisCent', 'aufbewahrungTage', 'sonderoeffnungen', 'abwesenheiten', 'feiertage', 'vorOrtArten', 'vorlage', 'kleinunternehmer', 'vorlaufMin', 'maxOffeneJeEmail', 'raster']) {
assert.ok(!json.includes(geheim), `oeffentlich enthaelt ${geheim}`)
}
})
test('oeffentlicheEinstellung: nur aktive Leistungen mit aktiver Ressource, nur aktive Ressourcen', () => {
const e = gueltig({
...basis(),
ressourcen: [
{ id: R1, name: 'Anna', plan: { 1: [['09:00', '12:00']] } },
{ id: R2, name: 'Ben', typ: 'platz', aktiv: false, plan: { 1: [['09:00', '12:00']] } },
],
leistungen: [
{ id: S1, name: 'Beide', dauer: 30, ressourcen: [R2, R1] },
{ id: S2, name: 'Ruht', dauer: 30, ressourcen: [R1], aktiv: false },
{ id: 's_bbbbbbb3', name: 'Nur Ben', dauer: 30, ressourcen: [R2], aktiv: false },
],
})
const oe = oeffentlicheEinstellung(e, NOW)
assert.deepEqual(oe.ressourcen, [{ id: R1, name: 'Anna', typ: 'person' }])
assert.deepEqual(oe.leistungen.map((l) => l.id), [S1])
assert.deepEqual(oe.leistungen[0].ressourcen, [R1])
// Ressource nachtraeglich deaktiviert (Daten aus dem Speicher): Leistung ohne aktive Ressource verschwindet
const roh = structuredClone(e)
roh.ressourcen[0].aktiv = false
assert.deepEqual(oeffentlicheEinstellung(roh, NOW).leistungen, [])
assert.equal(oeffentlicheEinstellung(roh, NOW).widerruf, false)
})
test('oeffentlicheEinstellung: storno null ohne Text, widerruf bei Verbraucher mit sichtbarer Leistung (sofort und Anfrage, R55), Kleinunternehmer-Hinweis', () => {
const sofort = gueltig(basis())
assert.equal(oeffentlicheEinstellung(sofort, NOW).storno, null)
assert.equal(oeffentlicheEinstellung(sofort, NOW).widerruf, true)
const anfrage = gueltig({ ...basis(), leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] })
assert.equal(oeffentlicheEinstellung(anfrage, NOW).widerruf, true, 'R55: auch eine Anfrage ist ein Fernabsatzvertrag')
const unternehmer = gueltig({ ...basis(), recht: { gaesteSind: 'unternehmer' } })
assert.equal(oeffentlicheEinstellung(unternehmer, NOW).widerruf, false)
assert.equal(oeffentlicheEinstellung(unternehmer, NOW).leistungen[0].verbraucher, false)
const freizeit = gueltig({ ...basis(), recht: { freizeit: true } })
assert.equal(oeffentlicheEinstellung(freizeit, NOW).widerruf, false)
const klein = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', kleinunternehmer: true } })
assert.equal(oeffentlicheEinstellung(klein, NOW).preisHinweis, 'kein Ausweis der USt nach § 19 UStG')
})
test('oeffentlicheEinstellung: heute ist der Berliner Tag (kurz nach Mitternacht)', () => {
const e = gueltig(basis())
assert.equal(oeffentlicheEinstellung(e, new Date('2026-10-02T22:30:00.000Z')).heute, '2026-10-03')
assert.equal(oeffentlicheEinstellung(e, new Date('2026-10-02T21:59:00.000Z')).heute, '2026-10-02')
})