import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' 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-Ablauf des Terminplaners: halten, Vorschau, bestaetigen. Nachbau von Appwrite mit Schema fuer die Termine- // Collections (Standard-Limit 25 wie in Produktion), Mails landen 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 { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_ERINNERUNG, formularHinweis } = await import( '../server/services/termine/rechtstexte.js' ) const { halte, vorschau, bestaetige, pruefeEingabe } = await import('../server/services/termine/ablauf.js') after(() => setMailTransportForTests(null)) const MIN = 60000 const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin const TAG_NAH = '2026-10-12' // 7 Tage spaeter: vorzeitiger Beginn zustimmungspflichtig const TAG_FERN = '2026-10-26' // 21 Tage spaeter, Montag 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 Minuten, 45 Euro, sofort const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage const S_GRATIS = 's_bbbbbbb3' // Kennenlernen, 30 Minuten, ohne Preis, sofort 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 sha256 = (text) => createHash('sha256').update(text).digest('hex') const plus = (date, ms) => new Date(date.getTime() + ms) const feature = () => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', 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] }, { id: S_GRATIS, name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', 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: 'egal', 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, }) 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() 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 buchungMitStatus = (status) => buchungen().filter((b) => b.status === status) const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId) const eintraege = () => [...aw.docs('termineOutbox').values()] const entfalte = (ics) => ics.replace(/\r\n /g, '') /** 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 einen Termin und gibt { buchung, token } zurueck; das Token steht nur in G01 (Mail-Link #v=). */ async function haltenMitToken(ctx, over = {}, now = JETZT) { const vorher = sink.sent.length const erg = await halte(ctx, eingabe(over), now) await warteAufVersand() assert.equal(erg.status, 'gehalten') const mail = sink.sent[vorher] const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(mail.text)[1] const buchung = buchungen().find((b) => b.verifyHash === hashToken(token)) assert.ok(buchung, 'Buchung zum Token') return { buchung, token, mail } } /** Gehalten und bestaetigt; => { buchung (frisch gelesen), verwaltungsToken, mailStart (Index der ersten Bestaetigungsmail) }. */ async function gebucht(ctx, over = {}, now = JETZT) { const { buchung, token } = await haltenMitToken(ctx, over, now) const mailStart = sink.sent.length const erg = await bestaetige(token, now) await warteAufVersand() return { erg, id: buchung.$id, buchung: aw.docs('termineBookings').get(buchung.$id), verwaltung: verwaltungsToken(buchung.$id), token, mailStart } } // ---------------------------------------------------------------- Halten test('halte: Buchung gehalten mit Haltezeit, Preis, Nachweis und Zellen; G01 ohne Gastname, Token nur in der Mail', async () => { const ctx = await speichere() const erg = await halte(ctx, eingabe({ name: ' Max Mustermann ', email: ' Max@Example.COM ', telefon: '0171 123', erinnerung: true }), JETZT) await warteAufVersand() assert.deepEqual(erg, { status: 'gehalten', email: 'max@example.com' }) const [b] = buchungen() assert.equal(buchungen().length, 1) assert.match(b.$id, /^[0-9a-f-]{36}$/) assert.deepEqual( { featureId: b.featureId, customerId: b.customerId, projectId: b.projectId, leistungId: b.leistungId, leistungName: b.leistungName, status: b.status, quelle: b.quelle, gastName: b.gastName, gastEmail: b.gastEmail, gastTelefon: b.gastTelefon, gastNachricht: b.gastNachricht, gastZusatz: b.gastZusatz, erinnerung: b.erinnerung, preisCent: b.preisCent, preisArt: b.preisArt, buchungsart: b.buchungsart, verbraucher: b.verbraucher, zahlart: b.zahlart, zahlStatus: b.zahlStatus, icsSequenz: b.icsSequenz, }, { featureId: FEATURE, customerId: KUNDE, projectId: PROJEKT, leistungId: S_SOFORT, leistungName: 'Damenschnitt', status: 'gehalten', quelle: 'online', gastName: 'Max Mustermann', gastEmail: 'max@example.com', gastTelefon: '0171 123', gastNachricht: '', gastZusatz: '', erinnerung: true, preisCent: 4500, preisArt: 'fest', buchungsart: 'sofort', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0, }, ) // 10:00 Berlin (MESZ, bis 25.10.) und 26.10. = MEZ: 10:00 = 09:00Z assert.equal(iso(b.startsAt), '2026-10-26T09:00:00.000Z') assert.equal(iso(b.endsAt), '2026-10-26T09:45:00.000Z') assert.equal(iso(b.blockEndsAt), '2026-10-26T09:50:00.000Z', 'Pause danach gehoert zum Block') assert.equal(iso(b.heldUntil), iso(plus(JETZT, 30 * MIN))) assert.equal(iso(b.createdAt), iso(JETZT)) assert.equal(b.manageHash, undefined, 'Verwaltungstoken erst bei der Bestaetigung') assert.equal(b.bestaetigtAt, undefined) assert.ok([R1, R2].includes(b.ressourceId)) // Zellen: 50 Minuten = 10 Zellen, Ende = blockEndsAt const zellen = claims(b.$id) assert.equal(zellen.length, 10) assert.ok(zellen.every((z) => z.ressourceId === b.ressourceId && z.featureId === FEATURE && iso(z.bis) === iso(b.blockEndsAt))) assert.ok(aw.docs('termineClaims').has(zellenId(FEATURE, b.ressourceId, zellenFuer(b.startsAt, b.blockEndsAt)[0]))) // G01: genau eine Mail an die normalisierte Adresse, ohne Gastname, ohne Kalenderdatei, ohne BCC assert.equal(sink.sent.length, 1) const [mail] = sink.sent assert.equal(mail.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Müller') assert.deepEqual(mail.to, [{ address: 'max@example.com', name: '' }]) assert.equal('bcc' in mail, false) assert.equal('icalEvent' in mail, false) for (const inhalt of [mail.text, mail.html]) { assert.doesNotMatch(inhalt, /Mustermann/) assert.doesNotMatch(inhalt, /0171/) } const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(mail.text)?.[1] assert.ok(token, 'Bestaetigungslink mit Token im Fragment') assert.equal(b.verifyHash, hashToken(token)) assert.doesNotMatch(JSON.stringify(buchungen()), new RegExp(token), 'das Token selbst wird nie gespeichert') assert.equal(eintraege().filter((d) => d.art === 'G01').length, 1) }) test('halte: nachweis enthaelt Fassung, Zeitpunkt, Knopftext, Anrede und die Hashes der gezeigten Texte (ohne IP und Browser)', async () => { const ctx = await speichere() await halte(ctx, eingabe({ tag: TAG_NAH, erinnerung: true, zustimmungen: { storno: true, vorzeitig: true } }), JETZT) const nachweis = JSON.parse(buchungen()[0].nachweis) const e = ctx.einstellung assert.equal(nachweis.fassung, RECHTSTEXTE_VERSION) assert.equal(nachweis.am, iso(JETZT)) assert.equal(nachweis.knopf, 'Zahlungspflichtig buchen') assert.equal(nachweis.anrede, 'sie') assert.equal(nachweis.datenschutz, sha256(formularHinweis(e))) assert.deepEqual(nachweis.storno, { version: e.storno.version, hash: sha256(e.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am: iso(JETZT) }) assert.deepEqual(nachweis.vorzeitig, { hash: sha256(ZUSTIMMUNG_VORZEITIG('Salon Müller')), am: iso(JETZT) }) assert.deepEqual(nachweis.erinnerung, { stunden: 24, hash: sha256(ZUSTIMMUNG_ERINNERUNG(24)), am: iso(JETZT) }) assert.match(nachweis.widerruf.hash, /^[0-9a-f]{64}$/, 'Widerrufsbelehrung bei verbindlicher Verbraucherbuchung') assert.deepEqual(Object.keys(nachweis).sort(), ['am', 'anrede', 'datenschutz', 'erinnerung', 'fassung', 'knopf', 'storno', 'vorzeitig', 'widerruf']) assert.ok(buchungen()[0].nachweis.length < 4000) }) test('halte: Zustimmungen, die nicht verlangt oder nicht gegeben wurden, stehen nicht im Nachweis', async () => { const ctx = await speichere(einstellung({ storno: { text: '' }, recht: { gaesteSind: 'unternehmer' } })) await halte(ctx, eingabe({ tag: TAG_NAH, erinnerung: false, zustimmungen: { storno: true, vorzeitig: true } }), JETZT) const nachweis = JSON.parse(buchungen()[0].nachweis) assert.deepEqual(Object.keys(nachweis).sort(), ['am', 'anrede', 'datenschutz', 'fassung', 'knopf'], 'kein Storno-Text, Unternehmer, keine Erinnerung') assert.equal(buchungen()[0].verbraucher, false) }) test('halte: Anfrage und Leistung ohne Preis bekommen ihre Knoepfe und brauchen keine Zustimmung zum vorzeitigen Beginn', async () => { const ctx = await speichere() await halte(ctx, eingabe({ leistung: S_ANFRAGE, tag: TAG_NAH, zeit: '10:00' }), JETZT) await halte(ctx, eingabe({ leistung: S_GRATIS, tag: TAG_NAH, zeit: '12:00', email: 'zwei@example.com' }), JETZT) const nachweise = buchungen().map((b) => [b.buchungsart, JSON.parse(b.nachweis).knopf, JSON.parse(b.nachweis).vorzeitig]).sort() assert.deepEqual(nachweise, [['anfrage', 'Termin anfragen', undefined], ['sofort', 'Termin buchen', undefined]]) 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('storno_haken_nur_bei_preis: der Storno-Haken ist nur bei einer Leistung mit Preis Pflicht und steht nur dort im Nachweis', async () => { const ctx = await speichere() // mit Preis: ohne Haken wie bisher 400 (Feld storno), nichts angelegt const err = await fehlerVon(() => halte(ctx, eingabe({ zustimmungen: { storno: false, vorzeitig: false } }), JETZT)) assert.equal(err.status, 400) assert.deepEqual(Object.keys(err.fehler), ['storno']) assert.equal(buchungen().length, 0) // ohne Preis (sofort): ohne Haken gehalten, kein Eintrag storno im Nachweis await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '10:00', email: 'a@example.com', zustimmungen: { storno: false, vorzeitig: false } }), JETZT) // ohne Preis, auch ganz ohne Zustimmungen await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '12:00', email: 'b@example.com', zustimmungen: undefined }), JETZT) // ohne Preis, Anfrage await halte(ctx, eingabe({ leistung: S_ANFRAGE, zeit: '14:00', email: 'c@example.com', zustimmungen: {} }), JETZT) // ohne Preis mit storno: true (altes Widget, anderer Client): ebenfalls kein Nachweis des Hakens await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '16:00', email: 'd@example.com', zustimmungen: { storno: true, vorzeitig: false } }), JETZT) assert.equal(buchungen().length, 4) for (const b of buchungen()) { assert.equal(b.status, 'gehalten') const nachweis = JSON.parse(b.nachweis) assert.equal(nachweis.storno, undefined, `${b.leistungId}: kein Storno-Nachweis ohne Preis`) assert.equal(Object.hasOwn(nachweis, 'storno'), false) assert.ok(!b.nachweis.includes(sha256(ZUSTIMMUNG_STORNO)), 'auch der Hash des Haken-Textes fehlt') } // mit Preis und Haken: Nachweis wie bisher await halte(ctx, eingabe({ zeit: '09:00', email: 'e@example.com' }), JETZT) const mitPreis = buchungen().find((b) => b.leistungId === S_SOFORT) assert.deepEqual(JSON.parse(mitPreis.nachweis).storno, { version: ctx.einstellung.storno.version, hash: sha256(ctx.einstellung.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am: iso(JETZT) }) // Anfrage mit Preis: Haken bleibt Pflicht const anfrageMitPreis = { id: 's_bbbbbbb4', name: 'Beratung', dauer: 30, preisArt: 'fest', preisCent: 3000, buchungsart: 'anfrage', ressourcen: [R1, R2] } const mitAnfrage = await speichere(einstellung({ leistungen: [anfrageMitPreis] })) const ohneHaken = await fehlerVon(() => halte(mitAnfrage, eingabe({ leistung: 's_bbbbbbb4', zeit: '11:00', email: 'f@example.com', zustimmungen: { storno: false, vorzeitig: true } }), JETZT)) assert.deepEqual(Object.keys(ohneHaken.fehler), ['storno']) // Handeintrag (manuell): nie Zustimmungen, mit und ohne Preis const e = ctx.einstellung for (const leistung of [S_SOFORT, S_GRATIS]) { const geprueft = pruefeEingabe(e, eingabe({ leistung, zustimmungen: undefined }), JETZT, { manuell: true }) assert.equal(geprueft.stornoNoetig, false, `Handeintrag ${leistung}`) } assert.equal(pruefeEingabe(e, eingabe({ leistung: S_SOFORT }), JETZT).stornoNoetig, true) assert.equal(pruefeEingabe(e, eingabe({ leistung: S_GRATIS }), JETZT).stornoNoetig, false) }) test('halte: egal waehlt die Ressource mit den wenigsten Buchungen des Tages, eine feste Ressource bleibt sie', async () => { const ctx = await speichere() await halte(ctx, eingabe({ ressource: R1, zeit: '09:00' }), JETZT) await halte(ctx, eingabe({ ressource: 'egal', zeit: '10:00', email: 'zwei@example.com' }), JETZT) const nachZeit = Object.fromEntries(buchungen().map((b) => [new Date(b.startsAt).toISOString().slice(11, 16), b.ressourceId])) assert.deepEqual(nachZeit, { '08:00': R1, '09:00': R2 }, 'Anna hat schon einen Termin, also Ben') assert.equal(buchungen().find((b) => b.ressourceId === R2).ressourceName, 'Ben') }) // ---------------------------------------------------------------- Pruefung der Eingabe test('halte: ungueltige Eingaben ergeben 400 mit Feldfehlern und speichern nichts', async () => { const ctx = await speichere(einstellung({ gastFelder: { telefon: 'pflicht', nachricht: 'optional', zusatz: [ { key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true }, { key: 'art', label: 'Art', typ: 'auswahl', optionen: ['Kurz', 'Lang'], pflicht: false }, ], }, })) const gut = eingabe({ zusatz: { kennzeichen: 'KL-AB 123' } }) const faelle = [ ['leistung', { leistung: 's_unbekannt' }], ['leistung', { leistung: undefined }], ['leistung', { leistung: { $ne: null } }], ['ressource', { ressource: 'r_unbekannt' }], ['ressource', { ressource: undefined }], ['ressource', { ressource: ['egal'] }], ['tag', { tag: '2026-02-30' }], ['tag', { tag: '26.10.2026' }], ['tag', { tag: undefined }], ['zeit', { zeit: '10:7' }], ['zeit', { zeit: '24:00' }], ['zeit', { zeit: '25:00' }], ['zeit', { tag: '2027-03-28', zeit: '02:30' }], // Luecke der Maerz-Umstellung ['zeit', { zeit: undefined }], ['name', { name: '' }], ['name', { name: ' ' }], ['name', { name: '​\u0000' }], ['name', { name: 'M'.repeat(101) }], ['name', { name: 42 }], ['email', { email: 'max' }], ['email', { email: 'max@example' }], ['email', { email: 'a@example.com, b@example.com' }], ['email', { email: 'a@example.com;b@example.com' }], ['email', { email: '"Max" ' }], ['email', { email: `${'a'.repeat(250)}@example.com` }], ['email', { email: ['max@example.com'] }], ['telefon', { telefon: '' }], ['telefon', { telefon: 'abc' }], ['telefon', { telefon: '0171