Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+130 -10
View File
@@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js'
// Mails K15 (Bestellung eingegangen) und K16 (Funktion bereit) je Funktion (Task B9a). Der News-Wortlaut ist als Literal
// eingefroren (vor dem Umbau der Vorlagen aus den damaligen Mails uebernommen): "News-Mails bleiben woertlich".
// B9b ergaenzt T16 (Team-Hinweis je Funktion) und T19 (Erstattung faellig) in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
@@ -145,7 +145,7 @@ test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => {
'',
'Funktion: News-Pinnwand – Einrichtung',
'Projekt: Bäckerei',
'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einrichtung: meist innerhalb von 1–2 Wochen',
'',
'Wie geht es weiter?',
@@ -180,7 +180,7 @@ test('News-Mails bleiben wörtlich: K15 ohne Rechnung', async () => {
'',
'Funktion: News-Pinnwand – Einrichtung',
'Projekt: Café',
'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einrichtung: meist innerhalb von 1–2 Wochen',
'',
'Wie geht es weiter?',
@@ -365,7 +365,7 @@ test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () =>
assert.equal(link, reg.anleitungsLink('c1', projectId))
assert.ok(mail.text.includes(`Im Ticketsystem öffnen: ${link}`), mail.text)
assert.ok(mail.html.includes(`href="${link.replaceAll('&', '&amp;')}"`), mail.html)
// die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit füllt D4)
// die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit nur bei Treffern der Stichwortprüfung: T16 Gesundheit unten)
for (const warnung of ['Zahlung fehlgeschlagen', 'Bezahlt, aber nicht zuzuordnen', 'Kein laufendes Wartungs-Abo', 'Gesundheitsbereich prüfen', 'Bezahlt, aber schon vorhanden']) {
assert.ok(!mail.text.includes(warnung), `kein Block "${warnung}"`)
}
@@ -428,6 +428,116 @@ test('T16 gesundheit: Block aus ohne Warntext, Block an mit dem Text', () => {
assert.ok(an.text.includes('Gesundheitsbereich prüfen: Branche Physiotherapie: Gesundheitsdaten möglich'), an.text)
})
// ------------------------------------------------------- T16 Block gesundheit (Termine, Task D4) ---
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
const GESUNDHEIT_ZEILE = 'Gesundheitsbereich prüfen: '
/** Einstellung eines Salons mit den Namen und Beschreibungen der Leistungen, die der Test braucht. */
function salon(leistungen = []) {
const e = ausVorlage('salon', { name: 'Salon Haarlinie', email: 'info@haarlinie.example', telefon: '06301 1234', adresse: 'Hauptstraße 1, 67722 Winnweiler', bundesland: 'RP' }, new Date('2026-10-05T10:00:00Z'))
leistungen.forEach(([name, beschreibung], i) => Object.assign(e.leistungen[i], { name, beschreibung: beschreibung ?? '' }))
return e
}
/** Termine bestellen (mit einer Einstellung im termineConfig-Dokument), bezahlen und die T16-Mail liefern. */
async function t16MitEinstellung(einstellung, { fehlerBeimLesen = false } = {}) {
if (!aw.docs('subscriptions').has('sub_w_mails')) {
aw.seed('subscriptions', { $id: 'sub_w_mails', stripeSubscriptionId: 'sub_w_mails', customerId: 'c1', productKey: 'wartung', status: 'active' })
}
const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
if (einstellung) {
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(einstellung), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
}
// der erste Lesezugriff gehoert dem Ticket (Kurzfassung), der zweite dem Hinweis fuer T16
if (fehlerBeimLesen) aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, after: 1 })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
const gesundheit = t16[0].text.split('\n').find((z) => z.startsWith(GESUNDHEIT_ZEILE))
return { mail: t16[0], doc: await sf.getFeatureDoc('c1', projectId, 'termine'), gesundheit: gesundheit ? gesundheit.slice(GESUNDHEIT_ZEILE.length) : null }
}
test('T16 Termine mit Leistung Physiotherapie: Warnblock "Gesundheitsbereich prüfen" mit dem Fundort, Ticket und Mail gehen raus', async () => {
const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]))
assert.equal(gesundheit, 'Leistungsname enthält „Physiotherapie“')
assert.ok(mail.html.includes('Gesundheitsbereich prüfen: Leistungsname enthält „Physiotherapie“'), mail.html)
assert.ok(doc.ticketId && aw.docs('workorders').get(doc.ticketId).details.includes('Gesundheitsbereich prüfen:'), 'das Ticket nennt es ebenfalls')
// die uebrigen Bloecke sind unveraendert: Ticket, Knopf zur Anleitung, kein kein_abo
assert.ok(mail.text.includes('Im Ticketsystem öffnen:'))
assert.ok(!mail.text.includes('Kein laufendes Wartungs-Abo'))
})
test('T16 Termine ohne Treffer oder ohne Einstellungen: kein Gesundheitsblock', async () => {
const ohneTreffer = await t16MitEinstellung(salon([['Herrenschnitt', 'Schneiden und Föhnen']]))
assert.equal(ohneTreffer.gesundheit, null)
assert.ok(!ohneTreffer.mail.text.includes('Gesundheitsbereich'))
const ohneConfig = await t16MitEinstellung(null)
assert.equal(ohneConfig.gesundheit, null)
assert.ok(!ohneConfig.mail.text.includes('Gesundheitsbereich') && !ohneConfig.mail.html.includes('Gesundheitsbereich'))
})
test('T16 Termine: scheitert das Lesen der Einstellung, geht T16 trotzdem ohne Block raus (nie Geld ohne Spur)', async () => {
const protokoll = []
const alt = console.error
console.error = (...args) => protokoll.push(args.join(' '))
try {
const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]), { fehlerBeimLesen: true })
assert.equal(gesundheit, null)
assert.ok(mail.text.includes('Neue Funktion bestellt: Terminplaner') && mail.text.includes('Im Ticketsystem öffnen:'))
assert.ok(doc.ticketId, 'das Ticket entstand')
assert.equal(protokoll.filter((z) => /Gesundheitshinweis/.test(z)).length, 1, protokoll.join('\n'))
assert.ok(!protokoll.join('\n').includes('Physio'), 'das Log nennt keine Daten des Betriebs')
} finally {
console.error = alt
}
})
test('T16 Termine: Namen des Kunden werden als Text eingesetzt (HTML maskiert, nichts Halbes), bei vielen Treffern hoechstens 500 Zeichen', async () => {
const boese = salon([
[`<img src=x onerror=alert(1)> \uD83D${'x'.repeat(5)}`, 'Physiotherapie'],
['Zeile1\n## Zeile2', 'Therapie'],
])
const eins = await t16MitEinstellung(boese)
assert.ok(eins.gesundheit.includes('<img src=x onerror=alert(1)>'), eins.gesundheit)
assert.ok(!eins.mail.html.includes('<img src=x'), 'im HTML maskiert')
assert.ok(eins.mail.html.includes('&lt;img src=x onerror=alert(1)&gt;'))
assert.ok(eins.mail.text.isWellFormed() && eins.mail.html.isWellFormed())
assert.ok(!eins.gesundheit.includes('\n'))
// 10 Treffer: gekuerzt auf ganze Meldungen und "und N weitere", hoechstens 500 Zeichen
const viele = salon(Array.from({ length: 4 }, (_, i) => [`${String(i)} ${'N'.repeat(36)}`, 'Physiotherapie']))
viele.leistungen.push(...Array.from({ length: 7 }, (_, i) => ({ ...viele.leistungen[0], id: `s_v${i}`, name: `${String(i + 4)} ${'M'.repeat(36)}`, beschreibung: 'Physiotherapie' })))
const zehn = await t16MitEinstellung(viele)
assert.ok(zehn.gesundheit.length <= 500, String(zehn.gesundheit.length))
assert.match(zehn.gesundheit, / … und \d+ weitere$/)
assert.ok(zehn.gesundheit.startsWith('Beschreibung der Leistung „0 NNN'), zehn.gesundheit)
})
test('T16 Termine, Zahlung fehlgeschlagen: kein Gesundheitsblock (es wurde nichts bestellt), auch mit Treffer in den Einstellungen', async () => {
const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(failedEvent(sessionId, projectId, 'termine'))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1)
assert.match(t16[0].text, /Zahlung fehlgeschlagen/)
assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
})
test('T16 News: nie ein Gesundheitsblock, auch wenn unter der ID der News-Funktion ein Einstellungsdokument mit Treffer laege', async () => {
const { projectId, sessionId } = await bestelle('news', 'Bäckerei Physiotherapie')
const doc = await sf.getFeatureDoc('c1', projectId, 'news')
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'news'))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1)
assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
})
test('T16 anleitung: Knopf nur mit Block, Link trägt &anleitung=<Projekt-ID>', () => {
const link = reg.anleitungsLink('c1', 'p42')
assert.ok(link.endsWith('&anleitung=p42'))
@@ -501,8 +611,13 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
'danach',
'portal_url',
'rechnung_url',
'fassung',
'lb_url',
'avv_url',
'widerruf_url',
'zustimmungen_text',
])
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu'])
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu', 'anlagen', 'anhang', 'zustimmungen'])
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
const k16 = loadTemplate('K16')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
@@ -514,7 +629,7 @@ test('Platzhalter-/Blocklisten exakt: T16 und T19', () => {
const t16 = loadTemplate('T16')
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team', 'anhang_fehlt'])
const t19 = loadTemplate('T19')
assert.equal(t19.file, 'T19 Erstattung faellig.html')
assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung')
@@ -533,8 +648,9 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
assert.equal(k16.template, 'K16')
assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`)
assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`)
assert.deepEqual(k15.blocks, { rechnung: true, behalten: false, neu: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, behalten: false, neu: true })
// ohne Rechtsunterlagen im Aufruf (F8: onFeaturePaid übergibt sie für den Terminplaner) bleiben anlagen, anhang und zustimmungen aus
assert.deepEqual(k15.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
for (const nachricht of [k15, k16]) {
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`)
@@ -547,8 +663,12 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
})
// ------------------------------------------------------------------------------------ Anhänge ---
test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', async () => {
// Die Vertragsunterlagen des Terminplaners hängen seit F8 an K15 (legalDocs.featureLegalAttachments, Tests in funktionenRecht.test.js).
// Diese Probe hält fest, dass das nicht über einen Registerhaken läuft: kein mails.anhaenge, und die Haken des Eintrags werden bei der
// Zahlung nicht gerufen.
test('K15 mit Anhängen aus legalDocs: die Termine-Bestellung ruft keinen Registerhaken, mails.anhaenge gibt es nicht', async () => {
const termine = config.features.termine
assert.equal(Object.hasOwn(termine.mails, 'anhaenge'), false)
const aufrufe = []
const alt = {}
const spion = (name) => (...args) => {
@@ -562,7 +682,7 @@ test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', asyn
termine.mails.anhaenge = spion('mails.anhaenge')
try {
const { mail } = await bezahle('termine', 'Anhang-Test')
assert.ok(!mail.attachments || mail.attachments.length === 0, 'K15 trägt keinen Anhang')
assert.equal(mail.attachments.length, 3, 'K15 trägt die drei PDFs der Vertragsunterlagen')
assert.deepEqual(aufrufe, [])
} finally {
for (const name of Object.keys(alt)) {