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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 9e8fd33713
commit e3e198fb1a
10 files changed
+1766 -40

No files matched your search

+118 -7
View File
@@ -82,17 +82,17 @@ const eingabe = (over = {}) => ({
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
...over,
})
const einstellung = (over) => {
const erg = normalisiereEinstellung(eingabe(over), { now: JETZT })
const einstellung = (over, optionen = {}) => {
const erg = normalisiereEinstellung(eingabe(over), { now: JETZT, ...optionen })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
const betriebMit = (felder) => ({ ...eingabe().betrieb, ...felder })
const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }) => ({
const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }, optionen = {}) => ({
feature: feature(),
project: projekt,
einstellung: einstellung(over),
einstellung: einstellung(over, optionen),
version: 1,
})
@@ -264,6 +264,88 @@ test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); feh
})
})
test('R40 speichereEinstellung mit Funktion: laeuft nach der Versionspruefung, bekommt die bisherige Einstellung (erst null), Ergebnis wird gespeichert', async () => {
const aufrufe = []
const erste = einstellung({ betrieb: betriebMit({ name: 'Erste' }) })
const erg = await speichereEinstellung(feature(), async (bisher) => {
aufrufe.push(bisher)
return erste
}, { version: 0, by: 'kunde', now: JETZT })
assert.deepEqual(aufrufe, [null], 'noch nichts gespeichert')
assert.equal(erg.version, 1)
assert.deepEqual(erg.einstellung, erste)
assert.equal(gespeichert().updatedBy, 'kunde')
const zweite = einstellung({ betrieb: betriebMit({ name: 'Zweite' }) })
const erg2 = await speichereEinstellung(feature(), (bisher) => {
aufrufe.push(bisher) // auch eine synchrone Funktion
return zweite
}, { version: 1 })
assert.deepEqual(aufrufe[1], erste, 'bisher = die gespeicherte Einstellung')
assert.equal(erg2.version, 2)
assert.equal((await ladeEinstellung(FEATURE)).einstellung.betrieb.name, 'Zweite')
})
test('R40 speichereEinstellung mit Funktion: veraltete Version ruft die Funktion nie auf; ein Fehler der Funktion geht unveraendert durch und schreibt nichts', async () => {
await speichereEinstellung(feature(), einstellung(), { version: 0 })
let aufrufe = 0
await assert.rejects(
() => speichereEinstellung(feature(), async () => { aufrufe += 1; return einstellung() }, { version: 0 }),
(err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET',
)
assert.equal(aufrufe, 0, 'Version zuerst')
const eigener = Object.assign(new Error('Eingabe'), { status: 400, fehler: { 'leistungen.s_x': 'Noch 2 künftige Termine.' } })
await assert.rejects(() => speichereEinstellung(feature(), async () => { throw eigener }, { version: 1 }), (err) => err === eigener)
const geladen = await ladeEinstellung(FEATURE)
assert.equal(geladen.version, 1, 'nichts geschrieben')
// das Sperrschloss ist wieder frei
assert.equal((await speichereEinstellung(feature(), async () => einstellung(), { version: 1 })).version, 2)
})
test('R40 speichereEinstellung mit Funktion: Ergebnis ohne Objekt => TypeError und nichts geschrieben; Eingaben ohne Funktion bleiben TypeError', async () => {
for (const unsinn of [null, undefined, 'x', 5, []]) {
await assert.rejects(() => speichereEinstellung(feature(), async () => unsinn, { version: 0 }), TypeError, String(unsinn))
}
assert.equal(aw.docs('termineConfig').size, 0)
await assert.rejects(() => speichereEinstellung({}, async () => einstellung(), { version: 0 }), TypeError)
})
test('R40 speichereEinstellung mit version null: keine Versionspruefung, Version zaehlt trotzdem hoch (Feed-Link neu erzeugen)', async () => {
await speichereEinstellung(feature(), einstellung(), { version: 0 })
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Zwei' }) }), { version: 1 })
const erg = await speichereEinstellung(feature(), async (bisher) => ({ ...bisher, farbe: '#112233' }), { version: null })
assert.equal(erg.version, 3)
const geladen = await ladeEinstellung(FEATURE)
assert.equal(geladen.einstellung.farbe, '#112233')
assert.equal(geladen.einstellung.betrieb.name, 'Zwei', 'baut auf dem aktuellen Stand auf')
// nur null spart die Pruefung; undefined und falsche Versionen bleiben 409
await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, {}), (err) => err.status === 409)
await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, { version: 99 }), (err) => err.status === 409)
// ohne Dokument sieht die Funktion null (mit der Einstellung als Objekt bleibt version null auch erlaubt)
await deleteDocument('termineConfig', FEATURE)
let gesehen
await speichereEinstellung(feature(), async (bisher) => { gesehen = bisher; return einstellung() }, { version: null })
assert.equal(gesehen, null)
})
test('R40 speichereEinstellung mit Funktion: gleichzeitige Aufrufe laufen nacheinander, der zweite sieht das Ergebnis des ersten', async () => {
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Start' }) }), { version: 0 })
const gesehen = []
const aendere = (name) => async (bisher) => {
gesehen.push(bisher.betrieb.name)
await new Promise((resolve) => setTimeout(resolve, 20)) // der Zweite haette Zeit, dazwischenzufunken
return { ...bisher, betrieb: { ...bisher.betrieb, name } }
}
await Promise.all([
speichereEinstellung(feature(), aendere('A'), { version: null }),
speichereEinstellung(feature(), aendere('B'), { version: null }),
])
assert.equal(gesehen[0], 'Start')
assert.ok(['A', 'B'].includes(gesehen[1]), 'der Zweite baut auf dem Ergebnis des Ersten auf')
assert.equal((await ladeEinstellung(FEATURE)).version, 3)
})
// ---------------------------------------------------------------- Kontext
test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, project, einstellung, version }', async () => {
@@ -279,6 +361,32 @@ test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, proje
assert.equal(ctx.version, 1)
})
test('R41 kontextPerSiteKey: solange Pflichtangaben fehlen (Telefon; Anschrift bei Widerrufsbelehrung) ist die Funktion oeffentlich nicht buchbar (null); das Portal sieht sie weiter', async () => {
seedFeature()
seedProjekt()
const entwurf = { entwurf: true }
const ohneTelefon = einstellung({ betrieb: betriebMit({ telefon: '' }) }, entwurf)
await speichereEinstellung(feature(), ohneTelefon, { version: 0 })
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'Telefon fehlt')
const portal = await kontextPerFeature(FEATURE)
assert.ok(portal && portal.einstellung, 'kontextPerFeature (Portal, Gaesteseiten per Token) bleibt erreichbar')
assert.deepEqual(portal.einstellung.betrieb.telefon, '')
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }) }, entwurf), { version: 1 })
assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1)), null, 'Anschrift fehlt bei Verbraucher + sofort')
// ohne Widerrufsbelehrung (nur Unternehmer) reicht das Telefon
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, entwurf), { version: 2 })
assert.ok(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(2)), 'Unternehmer-Betrieb ohne Anschrift ist buchbar')
// vollstaendig: buchbar; die Speicherung leert den gemerkten null-Eintrag sofort
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '', telefon: '' }) }, entwurf), { version: 3 })
assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(3)), null)
await speichereEinstellung(feature(), einstellung(), { version: 4 })
const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(4))
assert.equal(ctx.einstellung.betrieb.telefon, '06301 1234')
})
test('kontextPerSiteKey: Modus off => null ohne Anfrage; ungueltiger siteKey => null ohne Anfrage', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung(), { version: 0 })
@@ -604,12 +712,14 @@ test('G02 Freizeitleistung mit festem Termin: kein_widerruf statt widerruf (nich
})
test('G02 mit Widerrufsrecht, aber ohne Anschrift oder Telefon des Betriebs: Fehler statt unvollstaendiger Belehrung', () => {
// Entwurf: nur so gibt es eine Einstellung ohne die Pflichtangaben (R41); die Mail prueft sie trotzdem selbst
const entwurf = { entwurf: true }
for (const felder of [{ adresse: '' }, { telefon: '' }]) {
const ctx = ctxMit({ betrieb: betriebMit(felder) })
const ctx = ctxMit({ betrieb: betriebMit(felder) }, undefined, entwurf)
assert.throws(() => werteFuer('G02', ctx, buchung(), {}), TypeError, JSON.stringify(felder))
}
// ohne Widerrufsrecht braucht die Mail beides nicht
const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } })
const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, undefined, entwurf)
assert.doesNotThrow(() => werteFuer('G02', ctx, buchung({ verbraucher: false }), {}))
})
@@ -686,7 +796,8 @@ test('Impressum und Datenschutz im Fuss: Angaben des Betriebs, sonst dessen Webs
assert.equal(nackt.impressum_url, config.publicBaseUrl)
assert.equal(nackt.datenschutz_url, config.publicBaseUrl)
const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }), buchung(), {}).values
// Entwurf (R41): nur so gibt es eine Einstellung ohne Telefon und Anschrift; die Mail setzt dann "–"
const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }, undefined, { entwurf: true }), buchung(), {}).values
assert.equal(leer.betrieb_telefon, '–')
assert.equal(leer.betrieb_adresse, '–')
assert.equal(mitAngaben.farbe, '#7c3aed')