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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 4be886a4de
commit bd65c9dc58
11 files changed
+216 -18

No files matched your search

+12 -2
View File
@@ -244,10 +244,11 @@ test('config: Betriebsdaten, Leistungen, Regeln, Texte und Formular-Token; keine
const c = res.body
assert.deepEqual(Object.keys(c).sort(), [
'aktiv', 'anrede', 'betrieb', 'erinnerung', 'farbe', 'formToken', 'gastFelder', 'heute', 'hinweis', 'leistungen', 'personWaehlbar',
'preisHinweis', 'regeln', 'ressourcen', 'storno', 'texte', 'widerruf', 'zahlung',
'aktiv', 'anrede', 'betrieb', 'erinnerung', 'farbe', 'formMinSekunden', 'formToken', 'gastFelder', 'heute', 'hinweis', 'leistungen',
'personWaehlbar', 'preisHinweis', 'regeln', 'ressourcen', 'storno', 'texte', 'widerruf', 'zahlung',
])
assert.equal(c.aktiv, true)
assert.equal(c.formMinSekunden, config.termine.formMinSekunden, 'R53: das Widget wartet, bis das Formular-Token so alt ist')
assert.equal(c.heute, '2026-10-05')
assert.deepEqual(c.betrieb, {
name: 'Salon Müller',
@@ -286,6 +287,15 @@ test('config: Betriebsdaten, Leistungen, Regeln, Texte und Formular-Token; keine
for (const r of ctx.einstellung.ressourcen) assert.ok(!JSON.stringify(c).includes(r.feedToken))
})
test('config: formMinSekunden folgt der Einstellung zur Laufzeit (R53) und ist Zahl, auch 0', async () => {
await speichere()
for (const wert of [3, 0, 7.5]) {
config.termine.formMinSekunden = wert
const { body } = await api('GET', `/${SITE_KEY}/config`)
assert.equal(body.formMinSekunden, wert)
}
})
test('config: Knopf je Fall (Zahlungspflichtig buchen, Termin anfragen, Termin buchen)', async () => {
await speichere()
const { body } = await api('GET', `/${SITE_KEY}/config`)
+47
View File
@@ -572,6 +572,53 @@ test('anonymisieren: bei abgesagten Terminen bleibt updatedAt (die Loeschfrist l
assert.equal(buchung(gehalten.$id).gastEmail, 'zwei@example.com')
})
test('anonymisieren: absageGrund wird neutralisiert (auch der Vermerk "Widerruf erklärt von: <Name>"), Betriebsgründe ebenso', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
const abgesagtUm = iso(plus(JETZT, MIN))
// Vermerk einer Widerrufserklaerung nach Fristablauf (haltWiderrufFest): der Gast hat den Namen selbst getippt
aw.seed('termineBookings', { ...buchung(id), absageGrund: 'Widerruf erklärt von: Max Mustermann am 20.10.2026, 11:00 Uhr (nach Fristablauf)', updatedAt: abgesagtUm })
assert.match(buchung(id).absageGrund, /Max Mustermann/)
await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 5 * MIN))
assert.equal(buchung(id).absageGrund, '')
assert.doesNotMatch(JSON.stringify(buchung(id)), /Mustermann|max@example\.com/)
// abgesagt vom Betrieb: der Grund war frei getippt und kann Namen enthalten; die Frist (updatedAt) bleibt
const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
await aktion(ctx, zwei.id, { aktion: 'absagen', grund: 'Frau Meier ist krank' }, JETZT)
assert.equal(buchung(zwei.id).absageGrund, 'Frau Meier ist krank')
const abgesagtZwei = iso(buchung(zwei.id).updatedAt)
await aktion(ctx, zwei.id, { aktion: 'anonymisieren' }, plus(JETZT, 10 * 24 * 60 * MIN))
assert.equal(buchung(zwei.id).absageGrund, '')
assert.equal(buchung(zwei.id).status, 'abgesagt')
assert.equal(iso(buchung(zwei.id).updatedAt), abgesagtZwei)
})
test('anonymisieren: noch nicht versandte Mails dieser Buchung (wartet) werden gelöscht; gesendete, fehlgeschlagene und fremde Einträge bleiben', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
const { id: anderer } = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
await warteAufVersand()
const vorher = eintraege().length
const voll = { featureId: FEATURE, art: 'G05', an: 'max@example.com', fromName: 'Salon Müller', subject: 'Erinnerung', html: '<p>Hallo Max Mustermann</p>', text: 'Hallo Max Mustermann', ics: '', icsMethod: '', versuche: 0, naechsterVersuch: iso(JETZT), fehler: '', createdAt: iso(JETZT) }
aw.seed('termineOutbox', { ...voll, $id: 'o_wartet1', bookingId: id, status: 'wartet' })
aw.seed('termineOutbox', { ...voll, $id: 'o_wartet2', bookingId: id, status: 'wartet', art: 'G03' })
aw.seed('termineOutbox', { ...voll, $id: 'o_gesendet', bookingId: id, status: 'gesendet', an: '', html: '', text: '' })
aw.seed('termineOutbox', { ...voll, $id: 'o_fehler', bookingId: id, status: 'fehler' })
aw.seed('termineOutbox', { ...voll, $id: 'o_fremd', bookingId: anderer, status: 'wartet', an: 'zwei@example.com' })
await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 5 * MIN))
const ids = eintraege().map((d) => d.$id)
assert.ok(!ids.includes('o_wartet1') && !ids.includes('o_wartet2'), 'wartende Mails mit Adresse und Inhalt sind weg')
assert.ok(ids.includes('o_gesendet') && ids.includes('o_fehler') && ids.includes('o_fremd'))
assert.equal(eintraege().length, vorher + 3)
assert.equal(buchung(id).gastName, 'Gelöscht')
// zweiter Klick: nichts mehr zu loeschen, kein Fehler
assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 6 * MIN)), { status: 'bestaetigt' })
assert.equal(eintraege().length, vorher + 3)
})
// ---------------------------------------------------------------- aktion allgemein
test('aktion: fremde Funktion, Sperren, unbekannte Nummern und kaputte Eingaben aendern nichts', async () => {
+2
View File
@@ -276,6 +276,8 @@ test('halte: Anfrage und Leistung ohne Preis bekommen ihre Knoepfe und brauchen
const anfrage = buchungen().find((b) => b.buchungsart === 'anfrage')
assert.equal(anfrage.preisCent, 0)
assert.equal(anfrage.preisArt, 'keiner')
// R55: das Widget zeigt die Belehrung auch vor einer Anfrage, der Nachweis haelt sie fest
assert.match(JSON.parse(anfrage.nachweis).widerruf.hash, /^[0-9a-f]{64}$/)
})
test('halte: egal waehlt die Ressource mit den wenigsten Buchungen des Tages, eine feste Ressource bleibt sie', async () => {
+13 -10
View File
@@ -153,8 +153,8 @@ test('Vorlagen sind Entwuerfe: ohne Telefon und Anschrift gueltig, fehlendePflic
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'])
// 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)), [])
@@ -830,7 +830,7 @@ test('R13: Telefon ist immer Pflicht (Pfad betrieb.telefon), auch ohne Widerrufs
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', () => {
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'])
@@ -841,22 +841,24 @@ test('R13a: Anschrift ist Pflicht (Pfad betrieb.adresse), sobald die Widerrufsbe
// 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
// 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.equal(gueltig(ohneAdresse(anfragen)).betrieb.adresse, '')
// eine sofort-Leistung, die Gaeste nicht sehen (inaktiv oder nur an inaktiver Ressource), loest nichts aus
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' },
{ 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 sofort-Leistung daneben wieder Pflicht
// 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'])
})
@@ -878,6 +880,7 @@ test('R13/R13a: fehlendePflichtangaben stimmt mit oeffentlicheEinstellung.widerr
})
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)
@@ -968,13 +971,13 @@ test('oeffentlicheEinstellung: nur aktive Leistungen mit aktiver Ressource, nur
assert.equal(oeffentlicheEinstellung(roh, NOW).widerruf, false)
})
test('oeffentlicheEinstellung: storno null ohne Text, widerruf nur bei sofort + Verbraucher, Kleinunternehmer-Hinweis', () => {
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, false)
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)
+38 -1
View File
@@ -23,7 +23,7 @@ const { setMailTransportForTests } = await import('../server/services/mailer.js'
const { loadTemplate } = await import('../server/services/mailTemplates.js')
const { icsTermin } = await import('../server/services/termine/ics.js')
const O = await import('../server/services/termine/outbox.js')
const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, pausiertBis, resetOutboxForTests } = O
const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O
after(() => setMailTransportForTests(null))
const MIN = 60000
@@ -598,6 +598,43 @@ test('beendeFestgefahrene: ein Eintrag, der inzwischen gesendet wurde, bleibt ge
assert.equal((await lies(doc.$id)).status, 'gesendet')
})
test('verwerfeWartende: löscht nur die wartenden Einträge dieser Buchung (auch über mehrere Seiten); gesendete, fehlgeschlagene und fremde bleiben', async () => {
for (let i = 0; i < 30; i++) await legeAn({ bookingId: 'b_weg', art: 'G05' })
const gesendet = await legeAn({ bookingId: 'b_weg', status: 'gesendet', an: '', html: '', text: '' })
const fehler = await legeAn({ bookingId: 'b_weg', status: 'fehler' })
const fremd = await legeAn({ bookingId: 'b_fremd' })
assert.equal(await verwerfeWartende('b_weg'), 30)
assert.deepEqual(alle().map((d) => d.$id).sort(), [gesendet.$id, fehler.$id, fremd.$id].sort())
assert.equal(await verwerfeWartende('b_weg'), 0, 'zweiter Aufruf findet nichts')
assert.equal(await verwerfeWartende('b_gibtsnicht'), 0)
for (const ungueltig of [undefined, null, '', 5]) await assert.rejects(() => verwerfeWartende(ungueltig), TypeError)
})
test('verwerfeWartende: ein Eintrag, der inzwischen gesendet wurde, bleibt (Sperre, neu lesen); ein Eintrag, der inzwischen weg ist, stört nicht', async () => {
const doc = await legeAn({ bookingId: 'b_race' })
const weg = await legeAn({ bookingId: 'b_race' })
const echt = globalThis.fetch
let einmal = true
// zwischen Auflisten und Sperre sendet ein anderer Lauf den ersten Eintrag und ein anderer Lauf loescht den zweiten
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen)
if (einmal && (optionen?.method ?? 'GET') === 'GET' && String(url).includes('/documents?')) {
einmal = false
await sendeEintrag(doc.$id, JETZT)
await deleteDocument(col(), weg.$id)
}
return antwort
}
try {
assert.equal(await verwerfeWartende('b_race'), 0)
} finally {
globalThis.fetch = echt
}
assert.equal(einmal, false, 'der Versand lief zwischen Auflisten und Sperre')
assert.equal((await lies(doc.$id)).status, 'gesendet')
assert.equal(await lies(weg.$id), undefined)
})
test('raeumeAuf löscht mehr als eine Seite (über 25 Einträge)', async () => {
for (let i = 0; i < 40; i++) await legeAn({ bookingId: `b_${i}`, status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: JETZT.toISOString() })
assert.equal(await raeumeAuf(JETZT), 40)
+55
View File
@@ -373,6 +373,61 @@ test('POST vorlage ohne Telefon und Anschrift im Konto: Entwurf mit "fehlt"; oef
assert.equal((await oeffentlich('defghijklmnopqrs')).aktiv, true)
})
test('R55: Betrieb nur mit Anfrage-Leistungen (Privatpersonen) braucht die Anschrift: PATCH 400, "fehlt", oeffentlich inaktiv; danach Belehrung im Widget und G02 mit Belehrung nach "annehmen"', async () => {
const c2 = customerCookie('c2')
const oeffentlich = (siteKey) => fetch(`${app.url}/api/public/termine/v1/${siteKey}/config`).then((r) => r.json())
const SCHLUESSEL = 'defghijklmnopqrs'
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie: c2, body: { projectId: 'p6', vorlage: 'handwerk' } })
assert.equal(res.status, 201, JSON.stringify(res.body))
const entwurf = res.body.einstellung
assert.ok(entwurf.leistungen.every((l) => l.buchungsart === 'anfrage'), 'Vorlage: nur Anfragen')
assert.equal(entwurf.recht.gaesteSind, 'gemischt', 'Verbraucher sind dabei')
assert.deepEqual(res.body.fehlt, ['betrieb.telefon', 'betrieb.adresse'], 'auch ohne sofort-Leistung fehlt die Anschrift')
assert.deepEqual(await oeffentlich(SCHLUESSEL), { aktiv: false })
// nur das Telefon reicht nicht: PATCH nennt die Anschrift
const nurTelefon = { ...entwurf, betrieb: { ...entwurf.betrieb, telefon: '0151 2345678' } }
const abgelehnt = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: nurTelefon } })
assert.equal(abgelehnt.status, 400)
assert.deepEqual(Object.keys(abgelehnt.body.fehler), ['betrieb.adresse'])
assert.deepEqual(await oeffentlich(SCHLUESSEL), { aktiv: false })
// mit Anschrift: speichert, Widget bekommt Belehrung (widerruf true)
const komplett = { ...nurTelefon, betrieb: { ...nurTelefon.betrieb, adresse: 'Weg 1, 12345 Ort' } }
const ok = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: komplett } })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual(ok.body.fehlt ?? [], [])
const config = await oeffentlich(SCHLUESSEL)
assert.equal(config.aktiv, true)
assert.equal(config.widerruf, true)
assert.match(config.texte.widerrufsbelehrung, /Widerrufsbelehrung/)
assert.match(config.texte.widerrufsbelehrung, /Weg 1, 12345 Ort/)
// annehmen: die Anfrage wird bestaetigt und G02 mit Belehrung geht raus (nicht still verschluckt)
const leistung = ok.body.einstellung.leistungen[0]
seedBuchung('b_handwerk', {
featureId: 'f_p6_termine',
customerId: 'c2',
projectId: 'p6',
status: 'angefragt',
leistungId: leistung.id,
leistungName: leistung.name,
ressourceId: leistung.ressourcen[0],
preisCent: 0,
preisArt: 'keiner',
buchungsart: 'anfrage',
})
const angenommen = await send('POST', '/api/termine/buchungen/b_handwerk/aktion', { cookie: c2, body: { projectId: 'p6', aktion: 'annehmen' } })
assert.equal(angenommen.status, 200, JSON.stringify(angenommen.body))
assert.equal(aw.docs('termineBookings').get('b_handwerk').status, 'bestaetigt')
await warteAufVersand()
assert.deepEqual(eintraege().map((m) => m.art), ['G02'])
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
assert.match(sink.sent[0].text, /Widerrufsbelehrung/)
assert.match(sink.sent[0].text, /Weg 1, 12345 Ort/)
})
// ---------------------------------------------------------------- Einstellungen: PATCH
test('PATCH einstellungen: speichert, Version zaehlt hoch, Feed-Token bleiben (auch bei Wert vom Client), neue Personen bekommen einen', async () => {