Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d5a565e9f
95 files changed
+12879
-546
No files matched your search
+130
-10
@@ -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('&', '&')}"`), 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('<img src=x onerror=alert(1)>'))
|
||||
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)) {
|
||||
|
||||
Reference in new issue
Block a user