import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { mailSink } from './support/mailSink.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' // Gast-Verwaltung des Terminplaners: absagen, verschieben, Zeiten fuer das Verschieben, Widerruf (mit und ohne Link). // Aufbau wie test/termineAblaufGast.test.js: Nachbau von Appwrite mit Schema (Standard-Limit 25), Mails in der Senke, // feste Zeiten per Parameter. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings'), termineClaims: schemaKeys('termineClaims'), termineOutbox: schemaKeys('termineOutbox'), }, datetimes: TERMINE_DATETIMES, defaultLimit: 25, }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') const { hashToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js') const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') const { zellenFuer, zellenId } = await import('../server/services/termine/zellen.js') const { monatInfo } = await import('../server/services/termine/zeit.js') const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') const { halte, vorschau, bestaetige, kannWiderrufen, sageAbGast, verschiebeGast, zeitenFuerToken, widerrufeGast, widerrufeOhneLink } = await import( '../server/services/termine/ablauf.js' ) after(() => setMailTransportForTests(null)) const MIN = 60000 const TAG_MS = 24 * 60 * MIN const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ) const TAG_NAH = '2026-10-12' // Montag, 7 Tage spaeter (MESZ): vorzeitiger Beginn zustimmungspflichtig const TAG_FERN = '2026-10-26' // Montag, 21 Tage spaeter (MEZ) const KUNDE = 'c_kunde01' const PROJEKT = 'p_projekt01' const FEATURE = 'f_p_projekt01_termine' const R1 = 'r_aaaaaaa1' const R2 = 'r_aaaaaaa2' const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 + 5 Minuten, 45 Euro, sofort const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage const ABSENDER_VORHER = config.smtp.from const MODUS_VORHER = config.termine.mode const BASIS_VORHER = config.publicBaseUrl const iso = (date) => new Date(date).toISOString() const plus = (date, ms) => new Date(new Date(date).getTime() + ms) const feature = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: true, siteKey: 'abcdefghijklmnop' }) const eingabeEinstellung = (over = {}) => ({ betrieb: { name: 'Salon Müller', email: 'info@salon.de', telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler', websiteUrl: 'https://salon.de', impressumUrl: 'https://salon.de/impressum', datenschutzUrl: 'https://salon.de/datenschutz', }, anrede: 'sie', bundesland: 'RP', ressourcen: [ { id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }, { id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } }, ], leistungen: [ { id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] }, { id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] }, ], regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true }, gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] }, erinnerung: { anbieten: true, stundenVorher: 24 }, storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' }, recht: { gaesteSind: 'verbraucher', freizeit: false }, zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] }, ...over, }) const einstellung = (over) => { const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT }) assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) return erg.einstellung } const ctxMit = (e = einstellung()) => ({ feature: feature(), project: { $id: PROJEKT, subdomain: 'salon' }, einstellung: e, version: 1 }) const eingabe = (over = {}) => ({ leistung: S_SOFORT, ressource: R1, tag: TAG_FERN, zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', telefon: '0171 1234567', nachricht: '', zusatz: {}, erinnerung: false, zustimmungen: { storno: true, vorzeitig: false }, ...over, }) const NAH = { tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true } } let sink let log /** Die Einstellung wird gespeichert, damit die Token-Funktionen (kontextPerFeature) sie finden. */ async function speichere(e = einstellung()) { resetKontextForTests() const doc = aw.docs('termineConfig').get(FEATURE) await speichereEinstellung(feature(), e, { version: doc ? doc.version : 0, now: JETZT }) return ctxMit(e) } beforeEach(async () => { for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } resetKontextForTests() resetOutboxForTests() resetTermineLimitsForTests() config.termine.mode = 'pilot' config.smtp.from = 'noreply@webklar.com' config.publicBaseUrl = 'https://project.webklar.com' sink = mailSink() setMailTransportForTests(sink) log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } aw.seed('portalFeatures', feature()) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' }) aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) }) afterEach(async () => { await warteAufVersand() mock.restoreAll() config.termine.mode = MODUS_VORHER config.smtp.from = ABSENDER_VORHER config.publicBaseUrl = BASIS_VORHER }) const buchungen = () => [...aw.docs('termineBookings').values()] const buchung = (id) => aw.docs('termineBookings').get(id) const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId) const eintraege = () => [...aw.docs('termineOutbox').values()] const arten = () => eintraege().map((d) => d.art).sort() const entfalte = (ics) => ics.replace(/\r\n /g, '') const claimsStand = () => JSON.stringify([...aw.docs('termineClaims').entries()]) const an = (liste, adresse) => liste.filter((m) => m.to.some((t) => t.address === adresse)) const zeitenIso = (zellen) => zellen.map((c) => iso(c.zelle)).sort() /** Fehler eines Aufrufs (status, fehler, code); schlaegt fehl, wenn nichts geworfen wird. */ async function fehlerVon(fn) { try { await fn() } catch (err) { return err } assert.fail('es wurde kein Fehler geworfen') } /** Haelt und bestaetigt einen Termin; => { id, buchung (frisch gelesen), verwaltung (Verwaltungstoken), token, mailStart }. */ async function gebucht(ctx, over = {}, now = JETZT) { await warteAufVersand() // Mails frueherer Aufrufe sind raus, bevor der Index der neuen gilt const vorher = sink.sent.length const erg = await halte(ctx, eingabe(over), now) await warteAufVersand() assert.equal(erg.status, 'gehalten') const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(sink.sent[vorher].text)[1] const gehalten = buchungen().find((b) => b.verifyHash === hashToken(token)) assert.ok(gehalten, 'Buchung zum Token') const mailStart = sink.sent.length await bestaetige(token, now) await warteAufVersand() return { id: gehalten.$id, buchung: buchung(gehalten.$id), verwaltung: verwaltungsToken(gehalten.$id), token, mailStart } } /** Fehler im Postausgang simulieren: jede neue Mail scheitert beim Einreihen (Appwrite 500). */ async function mitDefektemPostausgang(fn) { const echt = globalThis.fetch globalThis.fetch = async (url, optionen) => { if (/\/collections\/termineOutbox\/documents/.test(String(url)) && optionen?.method === 'POST') { return new Response(JSON.stringify({ message: 'Server error', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } }) } return echt(url, optionen) } try { return await fn() } finally { globalThis.fetch = echt } } // ---------------------------------------------------------------- Absagen test('sageAbGast: bis absageBisStd vor Beginn; abgesagt von gast, Zellen frei, G06 mit CANCEL (Sequenz + 1), K21 abgesagt', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx) assert.equal(claims(id).length, 10) const jetzt = plus(JETZT, 3 * 60 * MIN) assert.deepEqual(await sageAbGast(verwaltung, jetzt), { status: 'abgesagt' }) await warteAufVersand() const nachher = buchung(id) assert.equal(nachher.status, 'abgesagt') assert.equal(nachher.abgesagtVon, 'gast') assert.equal(iso(nachher.abgesagtAt), iso(jetzt)) assert.equal(iso(nachher.updatedAt), iso(jetzt)) assert.equal(nachher.widerrufenAt, undefined, 'eine Absage ist kein Widerruf') assert.equal(nachher.icsSequenz, b.icsSequenz) assert.equal(claims(id).length, 0, 'Zellen frei') const neu = sink.sent.slice(mailStart + 2) // G02 und K20 der Bestaetigung ausgenommen assert.equal(neu.length, 2) const g06 = an(neu, 'max@example.com')[0] assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt') assert.equal(g06.icalEvent.method, 'CANCEL') const ics = entfalte(g06.icalEvent.content) assert.match(ics, /STATUS:CANCELLED/) assert.match(ics, /SEQUENCE:1\r\n/) assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`)) assert.match(g06.text, /Sie selbst/) assert.equal('bcc' in g06, false) const k21 = an(neu, 'info@salon.de')[0] assert.equal(k21.subject, 'Termin abgesagt: Damenschnitt am 26.10.2026 um 10:00') assert.equal(k21.from.name, 'WEBklar') assert.equal('icalEvent' in k21, false) assert.equal('bcc' in k21, false) assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21']) // die freie Zeit ist wieder buchbar const neuerGast = await halte(ctx, eingabe({ email: 'zwei@example.com' }), jetzt) assert.equal(neuerGast.status, 'gehalten') }) test('sageAbGast: genau an der Frist noch ja, eine Millisekunde spaeter 409 "Absage nur telefonisch", nichts aendert sich', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx) const start = new Date(b.startsAt) const standVorher = claimsStand() const err = await fehlerVon(() => sageAbGast(verwaltung, plus(start, -24 * 60 * MIN + 1))) assert.equal(err.status, 409) assert.deepEqual(err.fehler, { _: 'Absage nur telefonisch: 06301 1234' }) assert.equal(err.code, 'frist_vorbei') assert.equal(buchung(id).status, 'bestaetigt') assert.equal(claimsStand(), standVorher) assert.equal(sink.sent.length, mailStart + 2, 'keine neuen Mails') assert.deepEqual(await sageAbGast(verwaltung, plus(start, -24 * 60 * MIN)), { status: 'abgesagt' }) }) test('sageAbGast: ohne Telefonnummer des Betriebs steht ein allgemeiner Hinweis, nach Beginn und fuer erledigte Termine "nicht mehr moeglich"', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung } = await gebucht(ctx) const start = new Date(b.startsAt) const spaet = plus(start, -60 * MIN) aw.seed('termineConfig', { ...aw.docs('termineConfig').get(FEATURE), einstellung: JSON.stringify({ ...ctx.einstellung, betrieb: { ...ctx.einstellung.betrieb, telefon: '' } }) }) assert.deepEqual((await fehlerVon(() => sageAbGast(verwaltung, spaet))).fehler, { _: 'Absage nur direkt beim Betrieb möglich.' }) const begonnen = await fehlerVon(() => sageAbGast(verwaltung, plus(start, 5 * MIN))) assert.equal(begonnen.status, 409) assert.deepEqual(begonnen.fehler, { _: 'Dieser Termin kann nicht mehr abgesagt werden.' }) assert.equal(begonnen.code, 'nicht_moeglich') aw.seed('termineBookings', { ...buchung(id), status: 'erledigt' }) assert.equal((await fehlerVon(() => sageAbGast(verwaltung, JETZT))).code, 'nicht_moeglich') }) test('sageAbGast: zweiter Klick und paralleler Klick aendern nichts und senden nichts doppelt', async () => { const ctx = await speichere() const { verwaltung } = await gebucht(ctx) const erg = await Promise.all([sageAbGast(verwaltung, JETZT), sageAbGast(verwaltung, JETZT)]) await warteAufVersand() assert.deepEqual(erg.map((x) => x.status), ['abgesagt', 'abgesagt']) assert.deepEqual(erg.filter((x) => x.schon).length, 1) assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21']) assert.deepEqual(await sageAbGast(verwaltung, plus(JETZT, MIN)), { status: 'abgesagt', schon: true }) await warteAufVersand() assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21']) }) test('sageAbGast: Anfrage (nie eine Einladung erhalten) => G06 ohne Kalenderdatei (R21), K21 geht trotzdem; Zellen frei', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE }) assert.equal(b.status, 'angefragt') assert.equal(claims(id).length, 12) const vorher = sink.sent.length await sageAbGast(verwaltung, JETZT) await warteAufVersand() const neu = sink.sent.slice(vorher) const g06 = an(neu, 'max@example.com')[0] assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt') assert.equal('icalEvent' in g06, false) assert.equal(an(neu, 'info@salon.de')[0].subject, 'Termin abgesagt: Besichtigung am 26.10.2026 um 10:00') assert.equal(buchung(id).status, 'abgesagt') assert.equal(claims(id).length, 0) assert.deepEqual(arten(), ['G01', 'G03', 'G06', 'K20', 'K21']) }) test('sageAbGast: nur mit Verwaltungstoken; Bestaetigungstoken, unbekannte Token, Terminende + 1 Tag, Modus off', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung, token } = await gebucht(ctx) for (const falsch of ['', 'x', 'A'.repeat(43), null, undefined, 42, token, verwaltungsToken('andere-id')]) { const err = await fehlerVon(() => sageAbGast(falsch, JETZT)) assert.equal(err.status, 404, String(falsch).slice(0, 20)) } const ende = new Date(b.endsAt) const abgelaufen = await fehlerVon(() => sageAbGast(verwaltung, plus(ende, TAG_MS))) assert.equal(abgelaufen.status, 404) assert.equal(abgelaufen.code, 'abgelaufen') assert.equal(abgelaufen.betrieb.name, 'Salon Müller') config.termine.mode = 'off' assert.equal((await fehlerVon(() => sageAbGast(verwaltung, JETZT))).status, 404) config.termine.mode = 'pilot' assert.equal(buchung(id).status, 'bestaetigt') await assert.rejects(() => sageAbGast(verwaltung, undefined), TypeError) }) test('sageAbGast: eine abgeschaltete Funktion (disabled) erlaubt weiter das Absagen', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx) aw.seed('portalFeatures', feature('disabled')) assert.deepEqual(await sageAbGast(verwaltung, JETZT), { status: 'abgesagt' }) assert.equal(buchung(id).status, 'abgesagt') }) test('sageAbGast: scheitern die Mails, bleibt die Absage bestehen (Log ohne Gastdaten)', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx) const erg = await mitDefektemPostausgang(() => sageAbGast(verwaltung, JETZT)) assert.deepEqual(erg, { status: 'abgesagt' }) assert.equal(buchung(id).status, 'abgesagt') assert.equal(claims(id).length, 0) const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments)) assert.match(protokoll, /G06/) assert.match(protokoll, /K21/) assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/) }) test('sageAbGast: schlaegt das Freigeben der Zellen fehl, bleibt die Absage bestehen und die Mails gehen raus', async () => { const ctx = await speichere() const { id, verwaltung, mailStart } = await gebucht(ctx) aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 500 }) assert.deepEqual(await sageAbGast(verwaltung, JETZT), { status: 'abgesagt' }) await warteAufVersand() assert.equal(buchung(id).status, 'abgesagt') assert.ok(claims(id).length > 0, 'die Zellen bleiben liegen (gelten als frei, der Tagesjob raeumt sie)') assert.equal(sink.sent.length, mailStart + 4, 'G02 und K20, dann G06 und K21') assert.match(JSON.stringify(log.error.mock.calls.map((c) => c.arguments)), /Zellen/) }) // ---------------------------------------------------------------- Verschieben test('verschiebeGast: 10:00 -> 10:30 (ueberlappend) gelingt; Zeiten, Zellen, Sequenz 1, G07 mit REQUEST, K21 verschoben', async () => { const ctx = await speichere() const { id, buchung: alt, verwaltung, mailStart } = await gebucht(ctx) const jetzt = plus(JETZT, 3 * 60 * MIN) const erg = await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, jetzt) await warteAufVersand() assert.deepEqual(erg, { status: 'bestaetigt', termin: { datum: 'Montag, 26.10.2026', zeit: '10:30' } }) const b = buchung(id) assert.equal(iso(b.startsAt), '2026-10-26T09:30:00.000Z') // MEZ assert.equal(iso(b.endsAt), '2026-10-26T10:15:00.000Z') assert.equal(iso(b.blockEndsAt), '2026-10-26T10:20:00.000Z') assert.equal(b.status, 'bestaetigt') assert.equal(b.ressourceId, R1) assert.equal(b.icsSequenz, 1) assert.equal(iso(b.updatedAt), iso(jetzt)) assert.equal(b.manageHash, alt.manageHash, 'der Verwaltungslink bleibt gleich') const erwartet = zellenFuer(b.startsAt, b.blockEndsAt).map(iso) assert.equal(erwartet.length, 10) assert.deepEqual(zeitenIso(claims(id)), erwartet, 'genau die Zellen des neuen Blocks') assert.ok(claims(id).every((c) => c.ressourceId === R1)) // belegeZellen(ausser) legt nur Fehlendes an: die Zellen, die schon zum alten Block gehoerten, behalten ihr bis const bis = new Set(claims(id).map((c) => iso(c.bis))) assert.deepEqual([...bis].sort(), [iso(alt.blockEndsAt), iso(b.blockEndsAt)].sort()) const neu = sink.sent.slice(mailStart + 2) assert.equal(neu.length, 2) const g07 = an(neu, 'max@example.com')[0] assert.equal(g07.subject, 'Ihr Termin wurde verschoben') assert.equal(g07.icalEvent.method, 'REQUEST') const ics = entfalte(g07.icalEvent.content) assert.match(ics, /SEQUENCE:1\r\n/) assert.match(ics, /DTSTART:20261026T093000Z/) assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`)) assert.match(g07.text, /Montag, 26\.10\.2026/) assert.match(g07.text, /10:00/) assert.match(g07.text, /10:30/) const k21 = an(neu, 'info@salon.de')[0] assert.equal(k21.subject, 'Termin verschoben: Damenschnitt, jetzt am 26.10.2026 um 10:30') assert.match(k21.text, /10:00/) assert.deepEqual(arten(), ['G01', 'G02', 'G07', 'K20', 'K21']) // zweites Verschieben: Sequenz 2, nur noch die Zellen der neuen Zeit await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '14:00' }, jetzt) await warteAufVersand() assert.equal(buchung(id).icsSequenz, 2) assert.deepEqual(zeitenIso(claims(id)), zellenFuer(buchung(id).startsAt, buchung(id).blockEndsAt).map(iso)) assert.match(entfalte(an(sink.sent, 'max@example.com').at(-1).icalEvent.content), /SEQUENCE:2\r\n/) }) test('verschiebeGast: auf eine belegte Zeit => 409 SLOT_VERGEBEN, alte Zellen, Buchung und Mails unveraendert', async () => { const ctx = await speichere() const a = await gebucht(ctx) await gebucht(ctx, { zeit: '11:00', email: 'zwei@example.com' }) // Block 11:00-11:50 auf derselben Ressource const standVorher = claimsStand() const mails = sink.sent.length const docVorher = JSON.stringify(buchung(a.id)) for (const zeit of ['10:30', '11:00', '11:15']) { const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit }, JETZT)) assert.equal(err.status, 409, zeit) assert.equal(err.code, 'SLOT_VERGEBEN') assert.ok(err.fehler.zeit) } assert.equal(claimsStand(), standVorher) assert.equal(JSON.stringify(buchung(a.id)), docVorher) assert.equal(sink.sent.length, mails) assert.equal(eintraege().filter((d) => d.art === 'G07' || d.art === 'K21').length, 0) }) test('verschiebeGast: verliert das Belegen den Wettlauf (Zellen gehoeren einer anderen Buchung), bleibt alles beim Alten', async () => { const ctx = await speichere() const a = await gebucht(ctx) // eine fremde belegende Buchung vom Folgetag (nicht im Tageslesen) haelt Zellen zur Zielzeit: nur die Datenbank sieht den Konflikt aw.seed('termineBookings', { $id: 'x-fremd', featureId: FEATURE, customerId: KUNDE, projectId: PROJEKT, ressourceId: R1, status: 'bestaetigt', quelle: 'online', startsAt: '2026-10-27T09:00:00.000Z', endsAt: '2026-10-27T09:45:00.000Z', blockEndsAt: '2026-10-27T09:50:00.000Z', }) for (const zelle of zellenFuer('2026-10-26T13:00:00.000Z', '2026-10-26T13:50:00.000Z')) { aw.seed('termineClaims', { $id: zellenId(FEATURE, R1, zelle), featureId: FEATURE, ressourceId: R1, bookingId: 'x-fremd', zelle: iso(zelle), bis: '2026-10-26T13:50:00.000Z' }) } const standVorher = claimsStand() const docVorher = JSON.stringify(buchung(a.id)) const mails = sink.sent.length const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, JETZT)) // 14:00 MEZ = 13:00Z assert.equal(err.status, 409) assert.equal(err.code, 'SLOT_VERGEBEN') assert.equal(claimsStand(), standVorher, 'alte Zellen unveraendert, keine neuen') assert.equal(JSON.stringify(buchung(a.id)), docVorher) assert.equal(sink.sent.length, mails) }) test('verschiebeGast: auf einen anderen Tag; die alten Zellen sind weg, die neuen da (Wanduhr Berlin, MESZ -> MEZ)', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx, { tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true }, zeit: '10:00' }) const erg = await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '09:00' }, JETZT) assert.deepEqual(erg.termin, { datum: 'Montag, 26.10.2026', zeit: '09:00' }) const b = buchung(id) assert.equal(iso(b.startsAt), '2026-10-26T08:00:00.000Z') assert.deepEqual(zeitenIso(claims(id)), zellenFuer(b.startsAt, b.blockEndsAt).map(iso)) assert.equal(claims(id).length, 10) }) test('verschiebeGast: Frist wie beim Absagen (409 "Absage nur telefonisch", unveraendert); eine Anfrage laesst sich verschieben, G07 ohne Kalenderdatei', async () => { const ctx = await speichere() const a = await gebucht(ctx) const start = new Date(a.buchung.startsAt) const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, plus(start, -24 * 60 * MIN + 1))) assert.deepEqual([err.status, err.code, err.fehler._], [409, 'frist_vorbei', 'Absage nur telefonisch: 06301 1234']) assert.equal(buchung(a.id).startsAt, a.buchung.startsAt) assert.equal((await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, plus(start, -24 * 60 * MIN))).status, 'bestaetigt') const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'zwei@example.com' }) const vorher = sink.sent.length const erg = await verschiebeGast(anfrage.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT) await warteAufVersand() assert.equal(erg.status, 'angefragt') assert.equal(buchung(anfrage.id).status, 'angefragt') const g07 = an(sink.sent.slice(vorher), 'zwei@example.com')[0] assert.equal(g07.subject, 'Ihr Termin wurde verschoben') assert.equal('icalEvent' in g07, false, 'unbestaetigte Anfrage: keine Einladung') assert.equal(an(sink.sent.slice(vorher), 'info@salon.de')[0].subject, 'Termin verschoben: Besichtigung, jetzt am 26.10.2026 um 15:00') }) test('verschiebeGast: Eingaben pruefen (400 mit Feldern), nicht angebotene Zeit und dieselbe Zeit', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx) const faelle = [ [{}, ['tag', 'zeit']], [{ tag: 'morgen', zeit: '10:30' }, ['tag']], [{ tag: '2026-02-30', zeit: '10:30' }, ['tag']], [{ tag: TAG_FERN, zeit: '25:00' }, ['zeit']], [{ tag: TAG_FERN, zeit: 1030 }, ['zeit']], [{ tag: 5, zeit: null }, ['tag', 'zeit']], ] for (const [roh, felder] of faelle) { const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT)) assert.equal(err.status, 400, JSON.stringify(roh)) assert.deepEqual(Object.keys(err.fehler).sort(), felder, JSON.stringify(roh)) } assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, null, JETZT))).status, 400) assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, undefined, JETZT))).status, 400) // nicht im Raster, ausserhalb der Oeffnungszeit, an einem Tag ohne Plan: 409 Zeit vergeben for (const roh of [{ tag: TAG_FERN, zeit: '10:07' }, { tag: TAG_FERN, zeit: '08:00' }, { tag: '2026-10-27', zeit: '10:00' }]) { const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT)) assert.equal(err.status, 409, JSON.stringify(roh)) assert.equal(err.code, 'SLOT_VERGEBEN') } // dieselbe Zeit: nichts zu tun const gleich = await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:00' }, JETZT)) assert.equal(gleich.status, 400) assert.ok(gleich.fehler.zeit) assert.equal(buchung(id).icsSequenz, 0) assert.deepEqual(arten(), ['G01', 'G02', 'K20']) }) test('verschiebeGast: Person nicht waehlbar => auf einen anderen Platz, wenn der gleiche belegt ist; sonst bleibt die Ressource', async () => { const frei = await speichere(einstellung({ regeln: { ...eingabeEinstellung().regeln, personWaehlbar: false } })) const a = await gebucht(frei, { zeit: '10:00' }) const b = await gebucht(frei, { zeit: '14:00', email: 'zwei@example.com' }) // R1 14:00-14:50 assert.equal(a.buchung.ressourceId, R1) assert.equal(b.buchung.ressourceId, R1) // 11:00: R1 und R2 frei => die eigene Ressource bleibt await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '11:00' }, JETZT) assert.equal(buchung(a.id).ressourceId, R1) // 14:00: R1 ist belegt, R2 frei => Wechsel zu Ben; Zellen nur noch auf R2, auch zur selben Zeit nicht doppelt const erg = await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, JETZT) await warteAufVersand() assert.equal(erg.status, 'bestaetigt') const neu = buchung(a.id) assert.equal(neu.ressourceId, R2) assert.equal(neu.ressourceName, 'Ben') assert.equal(claims(a.id).length, 10) assert.ok(claims(a.id).every((c) => c.ressourceId === R2)) assert.deepEqual(zeitenIso(claims(a.id)), zellenFuer(neu.startsAt, neu.blockEndsAt).map(iso)) assert.equal(claims(b.id).length, 10, 'die Zellen der anderen Buchung bleiben') // zurueck auf die Zeit von vorhin: R1 ist wieder die bevorzugte? Nein - die eigene Ressource ist jetzt R2 und frei await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '10:00' }, JETZT) assert.equal(buchung(a.id).ressourceId, R2) // Person waehlbar (Vorgabe): nur die eigene Ressource wird angeboten const gewaehlt = await speichere() const c = await gebucht(gewaehlt, { zeit: '12:00', email: 'drei@example.com' }) await gebucht(gewaehlt, { zeit: '16:00', email: 'vier@example.com' }) // R1 16:00-16:50 const err = await fehlerVon(() => verschiebeGast(c.verwaltung, { tag: TAG_FERN, zeit: '16:00' }, JETZT)) assert.equal(err.code, 'SLOT_VERGEBEN') assert.equal(buchung(c.id).ressourceId, R1) }) test('verschiebeGast: zwei Gaeste verschieben gleichzeitig auf dieselbe Zeit => genau einer gewinnt, der andere behaelt alles', async () => { const ctx = await speichere() const a = await gebucht(ctx, { zeit: '10:00' }) const b = await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) const standB = JSON.stringify(zeitenIso(claims(b.id))) const ergebnisse = await Promise.allSettled([ verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT), verschiebeGast(b.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT), ]) await warteAufVersand() assert.deepEqual(ergebnisse.map((x) => x.status).sort(), ['fulfilled', 'rejected']) const verlierer = ergebnisse[0].status === 'rejected' ? ergebnisse[0] : ergebnisse[1] assert.equal(verlierer.reason.code, 'SLOT_VERGEBEN') const [gewinnerId, verliererId] = ergebnisse[0].status === 'fulfilled' ? [a.id, b.id] : [b.id, a.id] assert.equal(iso(buchung(gewinnerId).startsAt), '2026-10-26T14:00:00.000Z') assert.equal(claims(gewinnerId).length, 10) assert.equal(claims(verliererId).length, 10) if (verliererId === b.id) assert.equal(JSON.stringify(zeitenIso(claims(b.id))), standB) assert.equal(buchung(verliererId).icsSequenz, 0) assert.equal(eintraege().filter((d) => d.art === 'G07').length, 1) }) test('verschiebeGast: Doppelklick auf dieselbe neue Zeit verschiebt einmal (Sequenz 1, eine G07)', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx) const ergebnisse = await Promise.allSettled([ verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT), verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT), ]) await warteAufVersand() assert.deepEqual(ergebnisse.map((x) => x.status).sort(), ['fulfilled', 'rejected']) assert.equal(ergebnisse.find((x) => x.status === 'rejected').reason.status, 400) assert.equal(buchung(id).icsSequenz, 1) assert.equal(eintraege().filter((d) => d.art === 'G07').length, 1) assert.equal(claims(id).length, 10) }) test('verschiebeGast: scheitert das Speichern der Buchung, werden die neuen Zellen wieder entfernt', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx) const standVorher = claimsStand() const docVorher = JSON.stringify(buchung(id)) const mails = sink.sent.length aw.failNext({ collection: 'termineBookings', method: 'PATCH', status: 500 }) const err = await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT)) assert.equal(err.status, 500) assert.equal(JSON.stringify(buchung(id)), docVorher) assert.equal(claimsStand(), standVorher, 'nur die alten Zellen') assert.equal(sink.sent.length, mails) }) test('verschiebeGast: scheitern die Mails, bleibt die neue Zeit bestehen; Dauer und Pause bleiben die der Buchung', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx) // 45 + 5 Minuten // der Betrieb aendert die Leistung nachtraeglich auf 60 + 10 Minuten await speichere(einstellung({ leistungen: [{ ...eingabeEinstellung().leistungen[0], dauer: 60, pufferNach: 10 }, eingabeEinstellung().leistungen[1]] })) const erg = await mitDefektemPostausgang(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT)) assert.equal(erg.status, 'bestaetigt') const b = buchung(id) assert.equal(iso(b.startsAt), '2026-10-26T09:30:00.000Z') assert.equal(iso(b.endsAt), '2026-10-26T10:15:00.000Z', 'weiter 45 Minuten') assert.equal(iso(b.blockEndsAt), '2026-10-26T10:20:00.000Z', 'weiter 5 Minuten Pause') assert.equal(claims(id).length, 10) const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments)) assert.match(protokoll, /G07/) assert.match(protokoll, /K21/) assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/) }) test('verschiebeGast: eine fuer die alte Zeit gesendete Erinnerung wird zurueckgesetzt, damit die neue Zeit wieder eine bekommt', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung } = await gebucht(ctx, { erinnerung: true }) const keine = await gebucht(ctx, { zeit: '14:00', email: 'zwei@example.com' }) aw.seed('termineBookings', { ...b, erinnerungGesendetAt: iso(plus(JETZT, 60 * MIN)) }) await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT) assert.equal(buchung(id).erinnerungGesendetAt ?? null, null) assert.equal(buchung(id).erinnerung, true, 'der Wunsch nach einer Erinnerung bleibt') await verschiebeGast(keine.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT) assert.equal(buchung(keine.id).erinnerungGesendetAt ?? null, null) }) test('verschiebeGast: ohne Zustimmung zum vorzeitigen Beginn nicht in die Widerrufsfrist einer kostenpflichtigen Verbraucherbuchung (R28)', async () => { const plan = { 1: [['09:00', '18:00']], 2: [['09:00', '18:00']] } const ctx = await speichere(einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }] })) const a = await gebucht(ctx) // Mo 26.10. 10:00, Vertrag Mo 05.10. => Fristende Mo 19.10.; 21 Tage Vorlauf: keine Zustimmung noetig gewesen assert.equal(JSON.parse(a.buchung.nachweis).vorzeitig, undefined) const standVorher = claimsStand() const docVorher = JSON.stringify(buchung(a.id)) const mails = sink.sent.length // innerhalb der Frist (auch am letzten Tag, 17:00 Uhr): 409, nichts aendert sich for (const [tag, zeit] of [['2026-10-12', '11:00'], ['2026-10-19', '17:00']]) { const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag, zeit }, JETZT)) assert.equal(err.status, 409, `${tag} ${zeit}`) assert.equal(err.code, 'zustimmung_noetig') assert.deepEqual(err.fehler, { _: 'Dieser Termin liegt innerhalb der Widerrufsfrist. Bitte neu buchen oder anrufen: 06301 1234.' }) } assert.equal(claimsStand(), standVorher) assert.equal(JSON.stringify(buchung(a.id)), docVorher) assert.equal(sink.sent.length, mails) assert.equal(eintraege().filter((d) => d.art === 'G07').length, 0) // am Tag nach dem Fristende geht es const erg = await verschiebeGast(a.verwaltung, { tag: '2026-10-20', zeit: '09:00' }, JETZT) assert.deepEqual(erg.termin, { datum: 'Dienstag, 20.10.2026', zeit: '09:00' }) assert.equal(iso(buchung(a.id).startsAt), '2026-10-20T07:00:00.000Z') // mit der Zustimmung zum vorzeitigen Beginn (beim Buchen gegeben) ist es erlaubt const b = await gebucht(ctx, { ...NAH, zeit: '12:00', email: 'zwei@example.com' }) assert.ok(JSON.parse(b.buchung.nachweis).vorzeitig) await verschiebeGast(b.verwaltung, { tag: '2026-10-19', zeit: '17:00' }, JETZT) assert.equal(iso(buchung(b.id).startsAt), '2026-10-19T15:00:00.000Z') // ohne Preis, als Unternehmer oder bei einer Anfrage gilt die Regel nicht const frei = await speichere( einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }], leistungen: [...eingabeEinstellung().leistungen, { id: 's_bbbbbbb3', name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', ressourcen: [R1, R2] }], }), ) const gratis = await gebucht(frei, { leistung: 's_bbbbbbb3', zeit: '14:00', email: 'drei@example.com' }) await verschiebeGast(gratis.verwaltung, { tag: '2026-10-12', zeit: '11:00' }, JETZT) assert.equal(iso(buchung(gratis.id).startsAt), '2026-10-12T09:00:00.000Z') const anfrage = await gebucht(frei, { leistung: S_ANFRAGE, zeit: '16:00', email: 'vier@example.com' }) await verschiebeGast(anfrage.verwaltung, { tag: '2026-10-12', zeit: '15:00' }, JETZT) assert.equal(iso(buchung(anfrage.id).startsAt), '2026-10-12T13:00:00.000Z') const unternehmer = await speichere(einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }], recht: { gaesteSind: 'unternehmer', freizeit: false } })) const u = await gebucht(unternehmer, { zeit: '16:00', email: 'fuenf@example.com' }) assert.equal(u.buchung.verbraucher, false) await verschiebeGast(u.verwaltung, { tag: '2026-10-12', zeit: '09:00' }, JETZT) assert.equal(iso(buchung(u.id).startsAt), '2026-10-12T07:00:00.000Z') }) test('verschiebeGast: Token, Modus und Uhr werden geprueft; abgesagte Termine lassen sich nicht verschieben', async () => { const ctx = await speichere() const { id, verwaltung, token } = await gebucht(ctx) const roh = { tag: TAG_FERN, zeit: '10:30' } for (const falsch of ['', 'A'.repeat(43), null, token]) assert.equal((await fehlerVon(() => verschiebeGast(falsch, roh, JETZT))).status, 404) await assert.rejects(() => verschiebeGast(verwaltung, roh, undefined), TypeError) await sageAbGast(verwaltung, JETZT) const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT)) assert.deepEqual([err.status, err.code], [409, 'nicht_moeglich']) assert.equal(buchung(id).status, 'abgesagt') assert.equal(claims(id).length, 0) }) // ---------------------------------------------------------------- Zeiten fuer das Verschieben test('zeitenFuerToken { tag }: freie Zeiten ohne die eigene Buchung (die eigene Zeit ist wieder frei), nur HH:MM', async () => { const ctx = await speichere() const a = await gebucht(ctx) // R1 10:00-10:50 await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) // R1 12:00-12:50 const erg = await zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, JETZT) assert.deepEqual(Object.keys(erg), ['zeiten']) for (const z of ['09:00', '10:00', '10:15', '10:30', '11:00']) assert.ok(erg.zeiten.includes(z), `${z} ist frei`) for (const z of ['11:15', '11:30', '11:45', '12:00', '12:30']) assert.ok(!erg.zeiten.includes(z), `${z} ueberlappt die andere Buchung (12:00, Block 50 Minuten)`) assert.ok(erg.zeiten.includes('13:00')) assert.deepEqual([...erg.zeiten].sort(), erg.zeiten, 'aufsteigend') assert.ok(erg.zeiten.every((z) => /^\d\d:\d\d$/.test(z))) assert.equal(JSON.stringify(erg).includes(FEATURE), false) }) test('zeitenFuerToken { monat }: Tage mit freien Zeiten und Monatsdaten; die eigene Zeit zaehlt als frei', async () => { // Montag nur 10:00-10:50 offen: genau eine Startzeit, die der Termin selbst belegt const eng = einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['10:00', '10:50']] } }], leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, ressourcen: [R1] }], }) const ctx = await speichere(eng) const a = await gebucht(ctx, { zustimmungen: { storno: true, vorzeitig: false } }) const erg = await zeitenFuerToken(a.verwaltung, { monat: '2026-10' }, JETZT) assert.deepEqual(Object.keys(erg).sort(), ['monat', 'tage']) assert.deepEqual(erg.monat, monatInfo('2026-10')) assert.ok(erg.tage.includes(TAG_FERN), 'der Tag des eigenen Termins bleibt waehlbar') assert.ok(erg.tage.includes('2026-10-19')) assert.ok(!erg.tage.includes('2026-10-27'), 'Dienstag hat keinen Plan') assert.deepEqual([...erg.tage].sort(), erg.tage) assert.ok(erg.tage.every((t) => t.startsWith('2026-10-'))) const tag = await zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, JETZT) assert.deepEqual(tag.zeiten, ['10:00']) }) test('zeitenFuerToken: Eingaben, Token, Frist und Uhr', async () => { const ctx = await speichere() const a = await gebucht(ctx) for (const roh of [{}, { tag: 'x' }, { monat: '2026-13' }, { monat: 5 }, null, undefined]) { const err = await fehlerVon(() => zeitenFuerToken(a.verwaltung, roh, JETZT)) assert.equal(err.status, 400, JSON.stringify(roh)) } assert.ok((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { tag: 'x' }, JETZT))).fehler.tag) assert.ok((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { monat: '2026-13' }, JETZT))).fehler.monat) assert.equal((await fehlerVon(() => zeitenFuerToken(a.token, { tag: TAG_FERN }, JETZT))).status, 404, 'Bestaetigungstoken genuegt nicht') assert.equal((await fehlerVon(() => zeitenFuerToken('A'.repeat(43), { tag: TAG_FERN }, JETZT))).status, 404) // nach der Absagefrist wird nichts angeboten const spaet = plus(new Date(a.buchung.startsAt), -60 * MIN) assert.equal((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, spaet))).code, 'frist_vorbei') await assert.rejects(() => zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, undefined), TypeError) assert.equal(buchung(a.id).status, 'bestaetigt', 'die Abfrage aendert nichts') }) // ---------------------------------------------------------------- Widerruf per Link test('kannWiderrufen: sofort ab bestaetigtAt, angenommene Anfrage ab angenommenAt; Fristende, Beginn, Verbraucher, Handeintrag', () => { const basis = { buchungsart: 'sofort', verbraucher: true, status: 'bestaetigt', quelle: 'online', bestaetigtAt: iso(JETZT), startsAt: iso(plus(JETZT, 30 * TAG_MS)) } // Vertrag Montag 05.10.: Fristende Montag 19.10., 23:59:59,999 Uhr Berliner Zeit (MESZ) = 21:59:59,999 UTC assert.equal(kannWiderrufen(basis, new Date('2026-10-19T21:59:59.999Z')), true) assert.equal(kannWiderrufen(basis, new Date('2026-10-19T22:00:00.000Z')), false) assert.equal(kannWiderrufen(basis, plus(new Date(basis.startsAt), 0)), false, 'Termin hat begonnen') assert.equal(kannWiderrufen({ ...basis, startsAt: iso(plus(JETZT, 3 * TAG_MS)) }, plus(JETZT, 3 * TAG_MS - 1)), true, 'bis zum Beginn') assert.equal(kannWiderrufen({ ...basis, verbraucher: false }, JETZT), false) assert.equal(kannWiderrufen({ ...basis, status: 'angefragt' }, JETZT), false) assert.equal(kannWiderrufen({ ...basis, status: 'abgesagt' }, JETZT), false) assert.equal(kannWiderrufen({ ...basis, quelle: 'manuell' }, JETZT), false) assert.equal(kannWiderrufen({ ...basis, bestaetigtAt: undefined }, JETZT), false) const anfrage = { ...basis, buchungsart: 'anfrage', bestaetigtAt: undefined, angenommenAt: iso(plus(JETZT, 2 * TAG_MS)) } assert.equal(kannWiderrufen(anfrage, plus(JETZT, 3 * TAG_MS)), true, 'angenommene Anfrage') // Annahme Mittwoch 07.10.: Fristende Mittwoch 21.10. (21:59:59,999 UTC) assert.equal(kannWiderrufen(anfrage, new Date('2026-10-21T21:59:59.999Z')), true, 'die Frist zaehlt ab der Annahme') assert.equal(kannWiderrufen(anfrage, new Date('2026-10-21T22:00:00.000Z')), false) assert.equal(kannWiderrufen({ ...anfrage, angenommenAt: undefined }, JETZT), false, 'ohne Annahme kein Vertrag') assert.equal(kannWiderrufen({ ...anfrage, bestaetigtAt: iso(JETZT), angenommenAt: undefined }, JETZT), false, 'bestaetigtAt allein macht eine Anfrage nicht verbindlich') }) test('kannWiderrufen: Fristende nach §§ 187, 188, 193 BGB im Berliner Kalender - Zeitumstellung, Wochenende, Feiertag, Tageswechsel', () => { const buchungAm = (vertrag) => ({ buchungsart: 'sofort', verbraucher: true, status: 'bestaetigt', quelle: 'online', bestaetigtAt: vertrag, startsAt: '2027-02-01T09:00:00.000Z' }) const grenze = (vertrag, letzterMoment) => { const b = buchungAm(vertrag) const ende = new Date(letzterMoment) assert.equal(kannWiderrufen(b, ende), true, `${vertrag}: noch ${letzterMoment}`) assert.equal(kannWiderrufen(b, new Date(ende.getTime() + 1)), false, `${vertrag}: nach ${letzterMoment} nicht mehr`) } // Sommerzeit endet am 25.10.: Vertrag Montag 12.10. 10:00 MESZ => letzter Tag Montag 26.10. (MEZ), 23:59:59,999 = 22:59:59,999 UTC. // (14 x 24 Stunden wuerden schon am 26.10. um 08:00 UTC enden) grenze('2026-10-12T08:00:00.000Z', '2026-10-26T22:59:59.999Z') // Sommerzeit beginnt am 29.03.: Vertrag Montag 16.03. 10:00 MEZ => letzter Tag Montag 30.03. (MESZ), 21:59:59,999 UTC grenze('2026-03-16T09:00:00.000Z', '2026-03-30T21:59:59.999Z') // der 14. Tag ist ein Samstag (24.10.) => Montag 26.10.; der Sonntag dazwischen zaehlt noch mit grenze('2026-10-10T10:00:00.000Z', '2026-10-26T22:59:59.999Z') assert.equal(kannWiderrufen(buchungAm('2026-10-10T10:00:00.000Z'), new Date('2026-10-25T10:00:00.000Z')), true) // der 14. Tag ist Freitag, 25.12. (Feiertag), danach Samstag 26.12. (Feiertag) und Sonntag => Montag 28.12. grenze('2026-12-11T10:00:00.000Z', '2026-12-28T22:59:59.999Z') // Vertrag um 23:30 MESZ am 05.10. (21:30 UTC) zaehlt noch fuer den 05.10., um 00:30 MESZ am 06.10. (22:30 UTC am 05.10.) fuer den 06.10. grenze('2026-10-05T21:30:00.000Z', '2026-10-19T21:59:59.999Z') grenze('2026-10-05T22:30:00.000Z', '2026-10-20T21:59:59.999Z') }) test('widerrufeGast: 2 Stunden vor dem Termin trotz abgelaufener Stornofrist; abgesagt von gast, Zellen frei, G08 zugeordnet, K21 widerrufen', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx, NAH) const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN) // 12.10.2026 06:00Z = 08:00 MESZ assert.equal((await fehlerVon(() => sageAbGast(verwaltung, jetzt))).code, 'frist_vorbei', 'Absagen waere zu spaet') assert.equal((await vorschau(verwaltung, jetzt)).aktionen.widerrufen, true) const erg = await widerrufeGast(verwaltung, { name: ' Max Mustermann ' }, jetzt) await warteAufVersand() assert.deepEqual(erg, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } }) const nachher = buchung(id) assert.equal(nachher.status, 'abgesagt') assert.equal(nachher.abgesagtVon, 'gast') assert.equal(iso(nachher.widerrufenAt), iso(jetzt)) assert.equal(iso(nachher.abgesagtAt), iso(jetzt)) assert.match(nachher.absageGrund, /Widerruf/) assert.match(nachher.absageGrund, /Max Mustermann/) assert.equal(claims(id).length, 0) const neu = sink.sent.slice(mailStart + 2) assert.equal(neu.length, 2) const g08 = an(neu, 'max@example.com')[0] assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') assert.match(g08.text, /12\.10\.2026, 08:00 Uhr/) assert.ok(g08.text.includes(buchungsNummer(id))) assert.match(g08.text, /Der Termin ist abgesagt/) assert.doesNotMatch(g08.text, /Mustermann/, 'kein Text des Gastes in G08') assert.equal('icalEvent' in g08, false) const k21 = an(neu, 'info@salon.de')[0] assert.equal(k21.subject, 'Termin widerrufen: Damenschnitt am 12.10.2026 um 10:00') assert.match(k21.text, /12\.10\.2026, 08:00 Uhr/) assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) }) test('widerrufeGast: Eingang in Berliner Zeit (22:30 UTC = 00:30 am Folgetag)', async () => { const ctx = await speichere() const { verwaltung } = await gebucht(ctx) const erg = await widerrufeGast(verwaltung, { name: 'Max' }, new Date('2026-10-10T22:30:00.000Z')) assert.deepEqual(erg, { eingang: { datum: '11.10.2026', uhrzeit: '00:30' } }) }) test('widerrufeGast: die Frist gilt ab der Bestaetigung bis Fristende; am letzten Moment wirksam, danach nur gespeichert und gemeldet (nach_frist)', async () => { const ctx = await speichere() const a = await gebucht(ctx) // Termin 21 Tage nach der Buchung const b = await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) const vorher = sink.sent.length // Vertrag Montag 05.10. => Fristende Montag 19.10., 23:59:59,999 MESZ const fristEnde = new Date('2026-10-19T21:59:59.999Z') assert.deepEqual(await widerrufeGast(a.verwaltung, { name: 'Max' }, fristEnde), { eingang: { datum: '19.10.2026', uhrzeit: '23:59' } }) assert.equal(buchung(a.id).status, 'abgesagt') const erg = await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, plus(fristEnde, 1)) await warteAufVersand() assert.deepEqual(erg, { eingang: { datum: '20.10.2026', uhrzeit: '00:00' } }) const nachher = buchung(b.id) assert.equal(nachher.status, 'bestaetigt', 'der Termin bleibt') assert.equal(nachher.widerrufenAt, undefined) assert.equal(claims(b.id).length, 10) assert.match(nachher.absageGrund, /nach Ablauf der Frist/) assert.match(nachher.absageGrund, /Erika Beispiel/) const neu = sink.sent.slice(vorher).filter((m) => /Widerruf/i.test(m.subject)) assert.equal(neu.length, 4) const g08 = an(neu, 'zwei@example.com')[0] assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') assert.match(g08.text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/) assert.doesNotMatch(g08.text, /Der Termin ist abgesagt/) assert.equal(an(neu, 'info@salon.de').find((m) => /nach Ablauf/i.test(m.subject)).subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 26.10.2026 um 12:00') }) test('Widerruf nach Terminbeginn bei noch laufender Frist (R27): vermerkt, G08 ohne "abgelaufen", K21 begonnen, Termin bleibt; nach Fristende nach_frist', async () => { const ctx = await speichere() const a = await gebucht(ctx, NAH) // 12.10.2026 10:00 MESZ, Vertrag 05.10.: die Frist laeuft bis 19.10. const b = await gebucht(ctx, { ...NAH, zeit: '12:00', email: 'zwei@example.com' }) const antwortA = { eingang: { datum: '12.10.2026', uhrzeit: '10:20' } } const vorher = sink.sent.length // ohne Link, 20 Minuten nach Beginn const jetztA = plus(new Date(a.buchung.startsAt), 20 * MIN) assert.deepEqual(await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(a.id) }, jetztA), antwortA) // mit Link, 10 Minuten nach Beginn const jetztB = plus(new Date(b.buchung.startsAt), 10 * MIN) assert.deepEqual(await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, jetztB), { eingang: { datum: '12.10.2026', uhrzeit: '12:10' } }) await warteAufVersand() for (const [x, grund] of [[a, 'Max Mustermann'], [b, 'Erika Beispiel']]) { const nachher = buchung(x.id) assert.equal(nachher.status, 'bestaetigt', 'der Termin bleibt') assert.equal(nachher.widerrufenAt, undefined) assert.equal(claims(x.id).length, 10) assert.match(nachher.absageGrund, /nach Terminbeginn/) assert.ok(nachher.absageGrund.includes(grund)) } const neu = sink.sent.slice(vorher) assert.equal(neu.length, 4) const g08 = an(neu, 'max@example.com')[0] assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') assert.match(g08.text, /12\.10\.2026, 10:20 Uhr/) assert.match(g08.text, /prüft Ihre Erklärung und meldet sich bei Ihnen/) assert.doesNotMatch(g08.text, /abgelaufen|Der Termin ist abgesagt/) assert.ok(!g08.text.includes(buchungsNummer(a.id)), 'neutraler Satz, keine Zusage zur Buchung') const k21 = an(neu, 'info@salon.de').find((m) => m.text.includes('max@example.com')) assert.equal(k21.subject, 'Widerruf nach Terminbeginn: Damenschnitt am 12.10.2026 um 10:00') assert.match(k21.text, /Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte\./) assert.match(k21.text, /wertersatz|Wertersatz/) assert.doesNotMatch(k21.text, /Widerrufsfrist bei Eingang bereits abgelaufen/) assert.equal(eintraege().filter((d) => d.art === 'G08').length, 2) // nach Fristende (Montag 19.10., 23:59:59,999) dieselbe Erklaerung: jetzt wirklich nach der Frist const vorher2 = sink.sent.length await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(a.id) }, new Date('2026-10-19T22:00:00.000Z')) await warteAufVersand() const spaet = sink.sent.slice(vorher2) assert.match(an(spaet, 'max@example.com')[0].text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/) assert.equal(an(spaet, 'info@salon.de')[0].subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 12.10.2026 um 10:00') assert.match(buchung(a.id).absageGrund, /nach Ablauf der Frist/) }) test('widerrufeGast: eine nicht angenommene Anfrage kann nicht widerrufen werden (409), nur absagen; ebenso Unternehmer', async () => { const ctx = await speichere() const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE }) assert.equal(anfrage.buchung.status, 'angefragt') const mails = sink.sent.length const standVorher = claimsStand() const err = await fehlerVon(() => widerrufeGast(anfrage.verwaltung, { name: 'Max' }, JETZT)) assert.deepEqual([err.status, err.code], [409, 'kein_widerruf']) assert.ok(err.fehler._) assert.equal(buchung(anfrage.id).status, 'angefragt') assert.equal(buchung(anfrage.id).widerrufenAt, undefined) assert.equal(buchung(anfrage.id).absageGrund, undefined) assert.equal(claimsStand(), standVorher) assert.equal(sink.sent.length, mails) assert.deepEqual(await sageAbGast(anfrage.verwaltung, JETZT), { status: 'abgesagt' }) const unternehmer = await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } })) const u = await gebucht(unternehmer, { zeit: '14:00', email: 'zwei@example.com' }) assert.equal(u.buchung.verbraucher, false) assert.equal((await fehlerVon(() => widerrufeGast(u.verwaltung, { name: 'Max' }, JETZT))).code, 'kein_widerruf') assert.equal(buchung(u.id).status, 'bestaetigt') }) test('widerrufeGast: eine vom Betrieb angenommene Anfrage ist widerrufbar, die Frist laeuft ab angenommenAt', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE }) // Annahme durch den Betrieb (Aufgabe 14) von Hand nachgestellt const angenommen = plus(JETZT, 2 * TAG_MS) aw.seed('termineBookings', { ...b, status: 'bestaetigt', angenommenAt: iso(angenommen) }) const fristEnde = new Date('2026-10-21T21:59:59.999Z') // Annahme Mittwoch 07.10. => Fristende Mittwoch 21.10. assert.equal((await vorschau(verwaltung, fristEnde)).aktionen.widerrufen, true) assert.equal((await vorschau(verwaltung, plus(fristEnde, 1))).aktionen.widerrufen, false) const jetzt = plus(angenommen, 13 * TAG_MS) // 20.10.2026, der Termin am 26.10. liegt noch in der Zukunft const erg = await widerrufeGast(verwaltung, { name: 'Max' }, jetzt) assert.ok(erg.eingang) assert.equal(buchung(id).status, 'abgesagt') assert.equal(iso(buchung(id).widerrufenAt), iso(jetzt)) assert.equal(claims(id).length, 0) }) test('widerrufeGast: zweiter und paralleler Klick melden denselben Eingang und senden nichts doppelt', async () => { const ctx = await speichere() const { id, verwaltung } = await gebucht(ctx) const erste = plus(JETZT, 60 * MIN) const erg = await Promise.all([widerrufeGast(verwaltung, { name: 'Max' }, erste), widerrufeGast(verwaltung, { name: 'Max' }, erste)]) await warteAufVersand() assert.deepEqual(erg[0], erg[1]) assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) const spaeter = await widerrufeGast(verwaltung, { name: 'Max' }, plus(erste, 3 * 60 * MIN)) await warteAufVersand() assert.deepEqual(spaeter, erg[0], 'der Eingang der ersten Erklaerung') assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) assert.equal(iso(buchung(id).widerrufenAt), iso(erste)) }) test('widerrufeGast: Name Pflicht (400), Token und Uhr; scheitern die Mails, bleibt der Widerruf bestehen', async () => { const ctx = await speichere() const { id, verwaltung, token } = await gebucht(ctx) for (const name of ['', ' ', undefined, null, 5, 'x'.repeat(101)]) { const err = await fehlerVon(() => widerrufeGast(verwaltung, { name }, JETZT)) assert.equal(err.status, 400, String(name).slice(0, 5)) assert.deepEqual(Object.keys(err.fehler), ['name']) } assert.equal((await fehlerVon(() => widerrufeGast(verwaltung, null, JETZT))).status, 400) assert.equal((await fehlerVon(() => widerrufeGast(token, { name: 'Max' }, JETZT))).status, 404) assert.equal((await fehlerVon(() => widerrufeGast('A'.repeat(43), { name: 'Max' }, JETZT))).status, 404) await assert.rejects(() => widerrufeGast(verwaltung, { name: 'Max' }, undefined), TypeError) assert.equal(buchung(id).status, 'bestaetigt') const erg = await mitDefektemPostausgang(() => widerrufeGast(verwaltung, { name: 'Max' }, JETZT)) assert.ok(erg.eingang) assert.equal(buchung(id).status, 'abgesagt') const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments)) assert.match(protokoll, /G08/) assert.match(protokoll, /K21/) assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/) }) // ---------------------------------------------------------------- Widerruf ohne Link test('widerrufeOhneLink: Treffer mit Widerrufsrecht; Adresse klein und getrimmt, Nummer egal in welcher Schreibweise; G08 an die angegebene Adresse', async () => { const ctx = await speichere() const { id, buchung: b, mailStart } = await gebucht(ctx, NAH) const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN) const erg = await widerrufeOhneLink(ctx, { name: ' Max Mustermann ', email: ' Max@Example.COM ', nummer: ` ${buchungsNummer(id).toLowerCase()} ` }, jetzt) await warteAufVersand() assert.deepEqual(erg, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } }) const nachher = buchung(id) assert.equal(nachher.status, 'abgesagt') assert.equal(nachher.abgesagtVon, 'gast') assert.equal(iso(nachher.widerrufenAt), iso(jetzt)) assert.equal(claims(id).length, 0) const neu = sink.sent.slice(mailStart + 2) const g08 = an(neu, 'max@example.com')[0] assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') assert.ok(g08.text.includes(buchungsNummer(id))) assert.match(g08.text, /Der Termin ist abgesagt/) assert.doesNotMatch(g08.text, /Mustermann/) assert.equal(an(neu, 'info@salon.de')[0].subject, 'Termin widerrufen: Damenschnitt am 12.10.2026 um 10:00') assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) }) test('widerrufeOhneLink: ohne Treffer dieselbe Antwort; G08 ohne Bezug an die angegebene Adresse, K21 nicht_zugeordnet an den Betrieb', async () => { const ctx = await speichere() const { id, buchung: b, mailStart } = await gebucht(ctx, NAH) const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN) const doc = JSON.stringify(buchung(id)) const stand = claimsStand() const richtig = await widerrufeOhneLink(ctx, { name: 'Erika', email: 'erika@example.com', nummer: 'ZZZZZZZZ' }, jetzt) // Nummer passt zu keiner Buchung const fremdeAdresse = await widerrufeOhneLink(ctx, { name: 'Max', email: 'anderer@example.com', nummer: buchungsNummer(id) }, jetzt) // Nummer passt, Adresse nicht const kaputt = await widerrufeOhneLink(ctx, { name: 'Hans', email: 'hans@example.com', nummer: 'abc' }, jetzt) const leer = await widerrufeOhneLink(ctx, { name: 'Lena', email: 'lena@example.com', nummer: '' }, jetzt) const keineZeichenkette = await widerrufeOhneLink(ctx, { name: 'Tom', email: 'tom@example.com', nummer: 12345678 }, jetzt) await warteAufVersand() const antworten = [richtig, fremdeAdresse, kaputt, leer, keineZeichenkette] for (const a of antworten) assert.deepEqual(a, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } }) assert.equal(JSON.stringify(buchung(id)), doc, 'nichts an der Buchung geaendert') assert.equal(claimsStand(), stand) const neu = sink.sent.slice(mailStart + 2) assert.equal(neu.length, 10, '5 x G08 + 5 x K21') for (const adresse of ['erika@example.com', 'anderer@example.com', 'hans@example.com', 'lena@example.com', 'tom@example.com']) { const g08 = an(neu, adresse) assert.equal(g08.length, 1, adresse) assert.equal(g08[0].subject, 'Eingangsbestätigung Ihres Widerrufs') assert.doesNotMatch(g08[0].text, /Der Termin ist abgesagt|Widerrufsfrist|Buchung [A-Z0-9]{8}/) assert.doesNotMatch(g08[0].text, /Erika|Hans|Lena|Tom\b|ZZZZZZZZ/, 'kein vom Gast eingegebener Text') assert.ok(g08[0].text.includes('12.10.2026, 08:00 Uhr')) } const k21 = an(neu, 'info@salon.de') assert.equal(k21.length, 5) for (const m of k21) assert.equal(m.subject, 'Widerruf ohne passende Buchung') const erika = k21.find((m) => m.text.includes('erika@example.com')) assert.match(erika.text, /Erika/) assert.match(erika.text, /ZZZZZZZZ/, 'die angegebene Nummer steht beim Betrieb zur Pruefung') assert.equal('bcc' in erika, false) assert.equal(eintraege().filter((d) => d.art === 'K21' && d.bookingId !== '-').length, 0) }) test('widerrufeOhneLink: Treffer nach Ablauf der Frist => nur gespeichert, G08 nach_frist und K21 nach_frist, Termin bleibt', async () => { const ctx = await speichere() const { id, mailStart } = await gebucht(ctx) const jetzt = plus(JETZT, 15 * TAG_MS) const erg = await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(id) }, jetzt) await warteAufVersand() assert.deepEqual(erg, { eingang: { datum: '20.10.2026', uhrzeit: '10:00' } }) const b = buchung(id) assert.equal(b.status, 'bestaetigt') assert.equal(b.widerrufenAt, undefined) assert.equal(claims(id).length, 10) assert.match(b.absageGrund, /nach Ablauf der Frist/) assert.match(b.absageGrund, /Max Mustermann/) const neu = sink.sent.slice(mailStart + 2) assert.match(an(neu, 'max@example.com')[0].text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/) assert.equal(an(neu, 'info@salon.de')[0].subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 26.10.2026 um 10:00') }) test('widerrufeOhneLink: Buchung ohne Widerrufsrecht (nicht angenommene Anfrage, Unternehmer) => wie ohne Treffer, nichts geaendert', async () => { const ctx = await speichere() const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE }) const unternehmer = await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } })) const u = await gebucht(unternehmer, { zeit: '14:00', email: 'zwei@example.com' }) const vorher = sink.sent.length const docA = JSON.stringify(buchung(anfrage.id)) const docU = JSON.stringify(buchung(u.id)) const a1 = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(anfrage.id) }, JETZT) const a2 = await widerrufeOhneLink(unternehmer, { name: 'Zwei', email: 'zwei@example.com', nummer: buchungsNummer(u.id) }, JETZT) await warteAufVersand() assert.deepEqual(a1, a2) assert.equal(JSON.stringify(buchung(anfrage.id)), docA) assert.equal(JSON.stringify(buchung(u.id)), docU) assert.equal(claims(anfrage.id).length, 12) const neu = sink.sent.slice(vorher) assert.equal(an(neu, 'info@salon.de').filter((m) => m.subject === 'Widerruf ohne passende Buchung').length, 2) assert.equal(an(neu, 'max@example.com').length, 1) }) test('widerrufeOhneLink: vierter G08 an dieselbe Adresse am Tag wird nicht gesendet, die Antwort bleibt gleich; am naechsten Tag wieder', async () => { const ctx = await speichere() const roh = { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' } const antworten = [] for (let i = 0; i < 4; i += 1) antworten.push(await widerrufeOhneLink(ctx, roh, plus(JETZT, i * MIN))) await warteAufVersand() for (const a of antworten) assert.deepEqual(a, { eingang: { datum: '05.10.2026', uhrzeit: a.eingang.uhrzeit } }) assert.deepEqual(antworten.map((a) => a.eingang.uhrzeit), ['10:00', '10:01', '10:02', '10:03'], 'die Zeit der jeweiligen Erklaerung') assert.equal(eintraege().filter((d) => d.art === 'G08').length, 3) assert.equal(eintraege().filter((d) => d.art === 'K21').length, 4, 'der Betrieb bekommt jede Erklaerung') const protokoll = JSON.stringify([...log.error.mock.calls, ...log.warn.mock.calls].map((c) => c.arguments)) assert.match(protokoll, /G08/) assert.doesNotMatch(protokoll, /max@example\.com|Max\b/) await widerrufeOhneLink(ctx, roh, plus(JETZT, TAG_MS)) await warteAufVersand() assert.equal(eintraege().filter((d) => d.art === 'G08').length, 4) }) test('widerrufeOhneLink: 20 G08 je Funktion und Stunde, 30 K21 nicht_zugeordnet je Funktion und Stunde; weitere werden still uebersprungen', async () => { const ctx = await speichere() const antworten = [] for (let i = 0; i < 32; i += 1) antworten.push(await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT)) await warteAufVersand() for (const a of antworten) assert.deepEqual(a, antworten[0]) assert.equal(eintraege().filter((d) => d.art === 'G08').length, 20) assert.equal(eintraege().filter((d) => d.art === 'K21').length, 30) // eine Stunde spaeter beginnen beide Fenster neu await widerrufeOhneLink(ctx, { name: 'Spaet', email: 'spaet@example.com', nummer: 'ZZZZZZZZ' }, plus(JETZT, 60 * MIN)) await warteAufVersand() assert.equal(eintraege().filter((d) => d.art === 'G08').length, 21) assert.equal(eintraege().filter((d) => d.art === 'K21').length, 31) }) test('widerrufeOhneLink: ein Treffer bekommt seine Mails immer und zaehlt nicht mit; die Grenzen gelten nur ohne Treffer (R30)', async () => { const ctx = await speichere() const a = await gebucht(ctx) // max@example.com const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'vier@example.com' }) // nicht angenommen: kein Widerrufsrecht const g08 = () => eintraege().filter((d) => d.art === 'G08') const k21 = () => eintraege().filter((d) => d.art === 'K21') // Adresse max@ (3 Erklaerungen ins Leere), dann Funktion und K21-Grenze ausschoepfen for (let i = 0; i < 3; i += 1) await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }, JETZT) for (let i = 0; i < 28; i += 1) await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT) await warteAufVersand() assert.equal(g08().length, 20, 'G08: Funktionsgrenze erreicht') const kNichtZugeordnet = k21().length assert.equal(kNichtZugeordnet, 30, 'K21 nicht_zugeordnet: Grenze erreicht') const nochEine = await widerrufeOhneLink(ctx, { name: 'Noch', email: 'neu@example.com', nummer: 'ZZZZZZZZ' }, JETZT) await warteAufVersand() assert.equal(g08().length, 20, 'ohne Treffer bleibt die Grenze') assert.equal(k21().length, 30) // Treffer mit Widerrufsrecht: G08 (an die Adresse mit erschoepftem Tageskontingent) und K21 gehen trotzdem raus const erg = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(a.id) }, plus(JETZT, MIN)) await warteAufVersand() assert.deepEqual(erg, { eingang: { datum: '05.10.2026', uhrzeit: '10:01' } }) assert.deepEqual(nochEine, { eingang: { datum: '05.10.2026', uhrzeit: '10:00' } }) assert.equal(buchung(a.id).status, 'abgesagt', 'der Widerruf ist wirksam') assert.equal(g08().length, 21) assert.equal(g08().filter((d) => d.bookingId === a.id).length, 1) assert.equal(k21().filter((d) => d.bookingId === a.id).length, 1, 'der Betrieb erfaehrt davon') // Treffer ohne Widerrufsrecht (nicht angenommene Anfrage): G08 und K21 nicht_zugeordnet ebenfalls ohne Grenze await widerrufeOhneLink(ctx, { name: 'Vier', email: 'vier@example.com', nummer: buchungsNummer(anfrage.id) }, plus(JETZT, 2 * MIN)) await warteAufVersand() assert.equal(g08().length, 22) assert.equal(k21().length, 32) assert.equal(buchung(anfrage.id).status, 'angefragt', 'die Anfrage bleibt unveraendert') // der Treffer hat nichts verbraucht: ohne Treffer bleibt es bei der Grenze, nach einer Stunde geht es wieder await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }, plus(JETZT, 3 * MIN)) await warteAufVersand() assert.equal(g08().length, 22) }) test('widerrufeOhneLink: Eingaben - Name und E-Mail Pflicht (400), sonst nichts gespeichert und keine Mail', async () => { const ctx = await speichere() const faelle = [ [{ email: 'max@example.com', nummer: 'ZZZZZZZZ' }, ['name']], [{ name: ' ', email: 'max@example.com' }, ['name']], [{ name: 'x'.repeat(101), email: 'max@example.com' }, ['name']], [{ name: 'Max', email: 'a@example.com, b@example.com' }, ['email']], [{ name: 'Max', email: 'keine-adresse' }, ['email']], [{ name: 'Max', email: 5 }, ['email']], [{ name: '', email: '' }, ['email', 'name']], ] for (const [roh, felder] of faelle) { const err = await fehlerVon(() => widerrufeOhneLink(ctx, roh, JETZT)) assert.equal(err.status, 400, JSON.stringify(roh)) assert.deepEqual(Object.keys(err.fehler).sort(), felder, JSON.stringify(roh)) } assert.equal((await fehlerVon(() => widerrufeOhneLink(ctx, null, JETZT))).status, 400) assert.equal(eintraege().length, 0) await assert.rejects(() => widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com' }, undefined), TypeError) await assert.rejects(() => widerrufeOhneLink({ feature: feature(), einstellung: null }, { name: 'Max', email: 'max@example.com' }, JETZT), TypeError) await assert.rejects(() => widerrufeOhneLink(null, { name: 'Max', email: 'max@example.com' }, JETZT), TypeError) }) test('widerrufeOhneLink: Erklaerung zu einer schon widerrufenen Buchung aendert nichts und sendet nichts; Antwort wie immer', async () => { const ctx = await speichere() const { id } = await gebucht(ctx) const roh = { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(id) } await widerrufeOhneLink(ctx, roh, JETZT) await warteAufVersand() const mails = eintraege().length const zweite = await widerrufeOhneLink(ctx, roh, plus(JETZT, 5 * MIN)) await warteAufVersand() assert.deepEqual(zweite, { eingang: { datum: '05.10.2026', uhrzeit: '10:05' } }) assert.equal(iso(buchung(id).widerrufenAt), iso(JETZT)) assert.equal(eintraege().length, mails) }) test('Mail G06 ohne Kalenderdatei: anGast mit ohneIcs laesst die Datei weg, ohne die Option bleibt sie', async () => { const { anGast } = await import('../server/services/termine/mails.js') const ctx = await speichere() const { buchung: b } = await gebucht(ctx) await anGast('G06', ctx, b, { von: 'gast' }, JETZT) await anGast('G06', ctx, b, { von: 'gast', ohneIcs: true }, JETZT) await warteAufVersand() const g06 = sink.sent.filter((m) => /abgesagt/.test(m.subject)) assert.equal(g06.length, 2) assert.deepEqual(g06.map((m) => 'icalEvent' in m).sort(), [false, true]) })