Terminplaner: Kunden-Schnittstelle
/api/termine: Einstellungen (Vorlage, PATCH mit Version, Feed-Link), Buchungen (Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Protokoll der Kundenansicht. Pflichtangaben fuer Gaeste (Telefon immer, Anschrift bei Widerrufsbelehrung) in normalisiereEinstellung, Entwurf aus Vorlagen, kontextPerSiteKey ohne Pflichtangaben nicht buchbar; speichereEinstellung nimmt eine Funktion unter der Sperre. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e8fd33713
commit
e3e198fb1a
10 files changed
+1766
-40
No files matched your search
@@ -10,6 +10,8 @@ import {
|
||||
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'
|
||||
@@ -22,9 +24,12 @@ 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' },
|
||||
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] }],
|
||||
})
|
||||
@@ -142,6 +147,29 @@ test('Vorlagen: unbekannter Schluessel wirft, fehlende Betriebsangaben werfen mi
|
||||
})
|
||||
})
|
||||
|
||||
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 keine Widerrufsbelehrung: nur das Telefon fehlt
|
||||
assert.deepEqual(fehlendePflichtangaben(ausVorlage('handwerk', ohne, NOW)), ['betrieb.telefon'])
|
||||
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 = {} })
|
||||
@@ -154,7 +182,7 @@ test('Vorgaben werden aufgefuellt', () => {
|
||||
assert.equal(e.schema, 1)
|
||||
assert.equal(e.vorlage, null)
|
||||
assert.deepEqual(e.betrieb, {
|
||||
name: 'Salon Müller', email: 'info@salon.de', telefon: '', kleinunternehmer: false, adresse: '',
|
||||
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')
|
||||
@@ -199,26 +227,26 @@ test('Pflichtfelder: Betriebsname und Betriebs-E-Mail', () => {
|
||||
const f = fehlerVon({})
|
||||
assert.ok(f['betrieb.name'])
|
||||
assert.ok(f['betrieb.email'])
|
||||
assert.ok(fehlerVon({ betrieb: { name: ' ', email: 'a@b.de' } })['betrieb.name'])
|
||||
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: { name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } })
|
||||
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: { name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de')
|
||||
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: { name: 'X', email: schlecht } })['betrieb.email'], schlecht)
|
||||
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: { name: 'X', email: 'a@b.de', [feld]: wert } })
|
||||
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']) {
|
||||
@@ -232,7 +260,7 @@ test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeic
|
||||
})
|
||||
|
||||
test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtick im Host abgelehnt', () => {
|
||||
const mit = (feld, wert) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', [feld]: wert } })
|
||||
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/',
|
||||
@@ -244,7 +272,7 @@ test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtic
|
||||
})
|
||||
|
||||
test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycode, IPv6 in Klammern', () => {
|
||||
const url = (wert) => gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl
|
||||
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')
|
||||
@@ -256,12 +284,12 @@ test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycod
|
||||
}
|
||||
// die 300 Zeichen gelten fuer die gespeicherte Schreibweise (%27 ist laenger als ')
|
||||
const apostrophe = "'".repeat(110)
|
||||
assert.ok(fehlerVon({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl'])
|
||||
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: { name: wert, email: 'a@b.de' } })
|
||||
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))
|
||||
}
|
||||
@@ -288,7 +316,7 @@ test('Unsichtbare Formatzeichen: auch Leistungs-/Ressourcennamen, Storno, E-Mail
|
||||
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 = { name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' }
|
||||
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')
|
||||
@@ -791,6 +819,89 @@ test('preisText, preisHinweis, zahlungText', () => {
|
||||
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: Anschrift ist Pflicht (Pfad betrieb.adresse), sobald die Widerrufsbelehrung erscheint: Verbraucher + sichtbare sofort-Leistung', () => {
|
||||
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, Freizeitleistung, nur Anfragen
|
||||
assert.equal(gueltig(ohneAdresse({ recht: { gaesteSind: 'unternehmer' } })).betrieb.adresse, '')
|
||||
assert.equal(gueltig(ohneAdresse({ recht: { freizeit: true } })).betrieb.adresse, '')
|
||||
const anfragen = { leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] }
|
||||
assert.equal(gueltig(ohneAdresse(anfragen)).betrieb.adresse, '')
|
||||
// eine sofort-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' },
|
||||
{ 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 sofort-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(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', () => {
|
||||
@@ -871,7 +982,7 @@ test('oeffentlicheEinstellung: storno null ohne Text, widerruf nur bei sofort +
|
||||
const freizeit = gueltig({ ...basis(), recht: { freizeit: true } })
|
||||
assert.equal(oeffentlicheEinstellung(freizeit, NOW).widerruf, false)
|
||||
|
||||
const klein = gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', kleinunternehmer: true } })
|
||||
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')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user