From bd65c9dc58c5fc20e875ee38d9ce0985cd1071ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:38:05 +0000 Subject: [PATCH] =?UTF-8?q?Terminplaner:=20Abschluss=20=E2=80=93=20Server?= =?UTF-8?q?=20(Widerruf=20auch=20bei=20Anfragen,=20Gastdaten=20l=C3=B6sche?= =?UTF-8?q?n=20vollst=C3=A4ndig,=20formMinSekunden)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/routes/publicTermine.js | 3 +- server/services/termine/ablauf.js | 2 +- server/services/termine/ablaufBetrieb.js | 8 +++- server/services/termine/einstellungen.js | 5 ++- server/services/termine/outbox.js | 36 ++++++++++++++++ test/publicTermine.test.js | 14 +++++- test/termineAblaufBetrieb.test.js | 47 ++++++++++++++++++++ test/termineAblaufGast.test.js | 2 + test/termineEinstellungen.test.js | 23 +++++----- test/termineOutbox.test.js | 39 ++++++++++++++++- test/termineRoutes.test.js | 55 ++++++++++++++++++++++++ 11 files changed, 216 insertions(+), 18 deletions(-) diff --git a/server/routes/publicTermine.js b/server/routes/publicTermine.js index 96a0ec7..e7c8816 100644 --- a/server/routes/publicTermine.js +++ b/server/routes/publicTermine.js @@ -395,7 +395,8 @@ router.get( // Das Widget zeigt die Belehrung vor "Zahlungspflichtig buchen" (R14); fehlt eine Angabe, wirft sie => 503 statt einer Buchung ohne Belehrung if (oeffentlich.widerruf) texte.widerrufsbelehrung = widerrufsbelehrung(e.betrieb) return { - body: { aktiv: true, ...oeffentlich, hinweis: formularHinweis(e), texte, formToken: formToken(req.params.siteKey, now) }, + // formMinSekunden (R53): so alt muss das Formular-Token sein, sonst antwortet POST ohne Speichern; das Widget wartet entsprechend + body: { aktiv: true, ...oeffentlich, hinweis: formularHinweis(e), texte, formToken: formToken(req.params.siteKey, now), formMinSekunden: config.termine.formMinSekunden }, cache: 'public, max-age=60', // das Formular-Token ist bei jedem Abruf neu: kein ETag } }), diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index ed88eaa..67c6a98 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -280,7 +280,7 @@ function baueNachweis(e, g, verbraucher, now) { const am = iso(now) const nachweis = { fassung: RECHTSTEXTE_VERSION, am, knopf: knopfText(g.leistung), anrede: e.anrede, datenschutz: sha256(formularHinweis(e)) } if (g.stornoNoetig) nachweis.storno = { version: e.storno.version, hash: sha256(e.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am } - if (verbraucher && g.leistung.buchungsart === 'sofort') nachweis.widerruf = { hash: sha256(widerrufsbelehrung(e.betrieb)) } + if (verbraucher) nachweis.widerruf = { hash: sha256(widerrufsbelehrung(e.betrieb)) } // R55: auch bei Anfragen (das Widget zeigt die Belehrung jeder Verbraucher-Buchung) if (g.vorzeitigNoetig) nachweis.vorzeitig = { hash: sha256(ZUSTIMMUNG_VORZEITIG(e.betrieb.name)), am } if (g.erinnerung) { const stunden = e.erinnerung.stundenVorher diff --git a/server/services/termine/ablaufBetrieb.js b/server/services/termine/ablaufBetrieb.js index fe1af1e..9e310cc 100644 --- a/server/services/termine/ablaufBetrieb.js +++ b/server/services/termine/ablaufBetrieb.js @@ -3,6 +3,7 @@ import { withLocks } from '../keyedLock.js' import { aendere, hole, legeAn, loesche } from './buchungen.js' import { text as bereinigterText } from './einstellungen.js' import { anGast } from './mails.js' +import { verwerfeWartende } from './outbox.js' import { MELDUNG, VERWALTBAR, @@ -172,18 +173,23 @@ async function nichterschienen(_ctx, frisch, _eingabe, now) { /** * Gastdaten auf Wunsch loeschen: Name "Gelöscht", E-Mail, Telefon, Nachricht und Zusatzfelder leer, keine Erinnerung, - * Links ungueltig (Hashes leer). Status und Zeiten bleiben. Bei abgesagt/abgelehnt bleibt updatedAt stehen, weil davon + * Links ungueltig (Hashes leer), absageGrund leer (er kann den vom Gast getippten Namen einer Widerrufserklaerung oder + * einen frei getippten Grund des Betriebs enthalten). Noch nicht versandte Mails der Buchung (Outbox, Status wartet) + * werden geloescht, sie halten Adresse und Inhalt; zuerst, damit ein Fehler dort noch nichts geaendert hat und ein + * zweiter Klick alles nachholt. Status und Zeiten bleiben. Bei abgesagt/abgelehnt bleibt updatedAt stehen, weil davon * die Loeschfrist (30 Tage) laeuft. Eine noch nicht bestaetigte Haltung bleibt unberuehrt (409). */ async function anonymisieren(_ctx, frisch, _eingabe, now) { if (frisch.status === 'gehalten') throw nichtMoeglich(MELDUNG_BETRIEB.nochOffen) const behalteFrist = frisch.status === 'abgesagt' || frisch.status === 'abgelehnt' + await verwerfeWartende(frisch.$id) const buchung = await aendere(frisch.$id, { gastName: GELOESCHT, gastEmail: '', gastTelefon: '', gastNachricht: '', gastZusatz: '', + absageGrund: '', erinnerung: false, verifyHash: '', manageHash: '', diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index 2190880..b0e6163 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -572,10 +572,11 @@ function sichtbareLeistungen(einstellung) { /** * Zeigt die oeffentliche Sicht eine Widerrufsbelehrung (oeffentlicheEinstellung.widerruf)? Verbraucher-Gaeste und - * mindestens eine sichtbare Leistung, die sofort gebucht wird. + * mindestens eine sichtbare Leistung, gleich ob sofort oder als Anfrage (Ruling R55: auch ein Anfrage-Vertrag, der mit + * der Annahme zustande kommt, ist ein Fernabsatzvertrag; die Belehrung steht im G02 jeder Verbraucher-Buchung). */ export function zeigtWiderruf(einstellung) { - return istVerbraucher(einstellung) && sichtbareLeistungen(einstellung).some((l) => l.buchungsart === 'sofort') + return istVerbraucher(einstellung) && sichtbareLeistungen(einstellung).length > 0 } /** diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index 15e04ae..ba8d159 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -316,6 +316,42 @@ export async function raeumeAuf(now) { return geloescht } +/** + * Gastdaten loeschen (Portal "Gastdaten löschen"): noch nicht versandte Mails einer Buchung (Status wartet) werden + * geloescht, denn sie halten Adresse und Inhalt. Je Eintrag unter der Sendesperre `termine-mail:` nach neuem Lesen: + * ein Eintrag, der inzwischen gesendet wurde (seine Felder sind dann schon leer), bleibt; ein inzwischen geloeschter + * stoert nicht. Gesendete und fehlgeschlagene Eintraege behandelt raeumeAuf. + * @param {string} bookingId + * @returns {Promise} Anzahl geloeschter Eintraege + */ +export async function verwerfeWartende(bookingId) { + pruefeText(bookingId, 'bookingId') + const wartend = await listAllDocuments(col(), [Query.equal('status', 'wartet'), Query.equal('bookingId', bookingId)]) + let geloescht = 0 + for (const eintrag of wartend) { + const weg = await withLocks([`termine-mail:${eintrag.$id}`], async () => { + let frisch + try { + frisch = await getDocument(col(), eintrag.$id) + } catch (err) { + if (err.status === 404) return false + throw err + } + if (frisch.status !== 'wartet') return false + try { + await deleteDocument(col(), frisch.$id) + } catch (err) { + if (err.status === 404) return false + throw err + } + unverbucht.delete(frisch.$id) + return true + }) + if (weg) geloescht += 1 + } + return geloescht +} + /** * Taeglicher Job, vor raeumeAuf: Eintraege, die laenger als 48 Stunden (ab createdAt) in `wartet` stehen, werden zu * `fehler` (das Portal zeigt dann den Zustellhinweis) und bekommen als Loeschtermin jetzt + 48 Stunden, damit raeumeAuf diff --git a/test/publicTermine.test.js b/test/publicTermine.test.js index fffbe85..93354c1 100644 --- a/test/publicTermine.test.js +++ b/test/publicTermine.test.js @@ -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`) diff --git a/test/termineAblaufBetrieb.test.js b/test/termineAblaufBetrieb.test.js index 756acc6..f0b88b3 100644 --- a/test/termineAblaufBetrieb.test.js +++ b/test/termineAblaufBetrieb.test.js @@ -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: "), 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: '

Hallo Max Mustermann

', 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 () => { diff --git a/test/termineAblaufGast.test.js b/test/termineAblaufGast.test.js index 959ff56..8b94c06 100644 --- a/test/termineAblaufGast.test.js +++ b/test/termineAblaufGast.test.js @@ -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 () => { diff --git a/test/termineEinstellungen.test.js b/test/termineEinstellungen.test.js index 9186c72..a07586c 100644 --- a/test/termineEinstellungen.test.js +++ b/test/termineEinstellungen.test.js @@ -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) diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index b729aab..f5a0aeb 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -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) diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js index 4927948..7de4444 100644 --- a/test/termineRoutes.test.js +++ b/test/termineRoutes.test.js @@ -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 () => {