Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90692b646b
commit
2db3eca357
9 files changed
+1181
-49
No files matched your search
@@ -0,0 +1,288 @@
|
||||
import { test, after, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
|
||||
// Kurzfassung der Einbauanleitung im Einrichtungs-Ticket (TP2 Task D4): termineTicketDetails ist der Inhalt von
|
||||
// config.features.termine.ticket.details. Es wirft nie ("bezahlt ist bezahlt, das Ticket entsteht immer"): bei Lesefehlern
|
||||
// stehen Kopfzeilen, der Hinweis und der Verweis im Ticket. websiteProjects, workorders und customers legt das Setup-Skript nicht
|
||||
// an: ohne Schema im Nachbau.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') },
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
})
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
|
||||
const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js')
|
||||
const reg = await import('../server/services/featureRegistry.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js')
|
||||
const { ticketStand, driftWarnung, gesundheitsMeldungen } = await import('../server/services/termine/einbauanleitung.js')
|
||||
|
||||
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' })
|
||||
|
||||
const NOW = new Date('2026-10-05T10:00:00Z')
|
||||
const GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'
|
||||
const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
|
||||
const MAX = reg.TICKET_DETAILS_MAX
|
||||
const BETRIEB = {
|
||||
name: 'Salon Haarlinie',
|
||||
email: 'inhaber-intern@haarlinie.example',
|
||||
telefon: '06301 1234',
|
||||
adresse: 'Hauptstraße 1, 67722 Winnweiler',
|
||||
kleinunternehmer: false,
|
||||
impressumUrl: 'https://haarlinie.example/impressum',
|
||||
datenschutzUrl: 'https://haarlinie.example/datenschutz',
|
||||
bundesland: 'RP',
|
||||
}
|
||||
const PROJEKT = { projectName: 'Salon Haarlinie', liveDomain: 'haarlinie.example', subdomain: 'haarlinie' }
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
const UPDATED_AT = '2026-10-03T08:15:00.000Z'
|
||||
|
||||
const einstellung = () => {
|
||||
const e = ausVorlage('salon', BETRIEB, NOW)
|
||||
e.farbe = '#9d4b6f'
|
||||
return e
|
||||
}
|
||||
|
||||
/** Maximalbefuellung wie im Plan: 50 Leistungen mit 100 Zeichen, 20 Personen, 5 Zusatzfelder, 10 Gesundheitsmeldungen, 6 Warnungen. */
|
||||
function maximalEinstellung() {
|
||||
const e = einstellung()
|
||||
e.betrieb.telefon = ''
|
||||
e.betrieb.adresse = ''
|
||||
e.betrieb.impressumUrl = ''
|
||||
e.betrieb.datenschutzUrl = ''
|
||||
e.farbe = '#ffff00'
|
||||
const vorlage = e.leistungen[0]
|
||||
e.leistungen = Array.from({ length: 50 }, (_, i) => ({
|
||||
...vorlage,
|
||||
id: `s_max${String(i).padStart(5, '0')}`,
|
||||
name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`,
|
||||
aktiv: true,
|
||||
ressourcen: [],
|
||||
beschreibung: i < 10 ? 'Physiotherapie' : '',
|
||||
}))
|
||||
e.leistungen[0].name = 'Physiotherapie'
|
||||
e.ressourcen = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
|
||||
}))
|
||||
e.gastFelder = {
|
||||
telefon: 'pflicht',
|
||||
nachricht: 'optional',
|
||||
zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })),
|
||||
}
|
||||
return e
|
||||
}
|
||||
const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } }
|
||||
|
||||
let zaehler = 0
|
||||
/** Projekt (Kunde c1) anlegen und den Terminplaner bestellen; mit e (und fb) liegt ein termineConfig-Dokument unter der Funktions-ID. */
|
||||
async function bestelle({ e = null, fb = null, version = 1, projekt = PROJEKT } = {}) {
|
||||
zaehler += 1
|
||||
const projectId = `tt${zaehler}`
|
||||
const sessionId = `cs_tt${zaehler}`
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', ...projekt })
|
||||
const doc = await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
|
||||
if (e) {
|
||||
aw.seed('termineConfig', {
|
||||
$id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(e), version, updatedAt: UPDATED_AT, updatedBy: 'kunde:c1',
|
||||
...(fb ? { fragebogen: JSON.stringify(fb) } : {}),
|
||||
})
|
||||
}
|
||||
return { projectId, sessionId, doc }
|
||||
}
|
||||
|
||||
/** ctx genau in der Form, die markPaid uebergibt: { feature: Register-Eintrag, doc, project, customer }. */
|
||||
const ctxFuer = (doc, project = PROJEKT, customer = { name: 'Kunde Eins' }) => ({ feature: reg.featureConfig('termine'), doc, project, customer })
|
||||
const absaetze = (text) => text.split('\n\n')
|
||||
const stumm = () => mock.method(console, 'error', () => {})
|
||||
|
||||
// ------------------------------------------------------------------------------ termineTicketDetails ---
|
||||
test('termineTicketDetails mit Einstellungen: Kopf mit Konfigurationsstand: Version 1, Snippet mit siteKey, Verweis, Stundenkontingent', async () => {
|
||||
const { projectId, doc } = await bestelle({ e: einstellung() })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
const [kopf] = absaetze(text)
|
||||
assert.deepEqual(kopf.split('\n'), [
|
||||
'Funktion: Terminplaner',
|
||||
'Projekt: Salon Haarlinie',
|
||||
'Domain: haarlinie.example',
|
||||
'Kunde: Kunde Eins',
|
||||
`siteKey: ${doc.siteKey}`,
|
||||
'Konfigurationsstand: Version 1, 03.10.2026',
|
||||
])
|
||||
assert.ok(text.includes('Konfigurationsstand: Version 1'))
|
||||
assert.equal(ticketStand(text), 1)
|
||||
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
|
||||
assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
|
||||
assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
|
||||
assert.ok(text.includes('Umfang:\n- 4 Leistungen'), text)
|
||||
assert.ok(!text.includes('Online-Terminbuchung') && !text.includes(BETRIEB.email), 'weder Datenschutz-Absatz noch Betriebs-E-Mail')
|
||||
assert.ok(text.length <= MAX)
|
||||
assert.ok(!text.includes('bezahlt'), 'hier wurde nur bestellt: keine Behauptung, die Einrichtung sei bezahlt')
|
||||
})
|
||||
|
||||
test('termineTicketDetails: der Wunsch des Kunden aus dem Fragebogen steht im Ticket, der Modus im Snippet', async () => {
|
||||
const fb = { einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht', bemerkung: 'Bitte vor dem 20.10.' } }
|
||||
const { doc } = await bestelle({ e: einstellung(), fb })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
assert.ok(text.includes(' Modus: knopf\n Knopf-Text: Termin sichern\n Stelle: Startseite unter dem Hero\n Gestaltung: Schlicht\n Bemerkung: Bitte vor dem 20.10.'), text)
|
||||
assert.ok(text.includes('data-modus="knopf" data-knopf="Termin sichern"'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails ohne termineConfig: noch keine Einstellungen, Snippet, Verweis', async () => {
|
||||
const { projectId, doc } = await bestelle()
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
assert.ok(text.includes('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), text)
|
||||
assert.ok(text.includes('noch keine Einstellungen'))
|
||||
assert.equal(ticketStand(text), 0)
|
||||
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
|
||||
assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
|
||||
assert.ok(text.includes('Keine Einstellungen gespeichert'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails bei Lesefehler: loest auf, ohne Wurf, mit Kopfzeilen, Hinweis und Link', async () => {
|
||||
const { projectId, doc } = await bestelle({ e: einstellung() })
|
||||
const fehler = stumm()
|
||||
try {
|
||||
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
const link = reg.anleitungsLink('c1', projectId)
|
||||
for (const teil of ['Projekt: Salon Haarlinie', 'Domain: haarlinie.example', 'Kunde: Kunde Eins', `siteKey: ${doc.siteKey}`, `Verweis: ${link}`, 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen', 'Nicht auf das Stundenkontingent anrechnen']) {
|
||||
assert.ok(text.includes(teil), `${teil}\n---\n${text}`)
|
||||
}
|
||||
assert.equal(ticketStand(text), null, 'ohne Konfigurationsstand-Zeile kein Vergleich')
|
||||
assert.equal(fehler.mock.callCount(), 1)
|
||||
assert.ok(!String(fehler.mock.calls[0].arguments.join(' ')).includes(doc.siteKey), 'das Log nennt keine Daten des Betriebs')
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
// danach funktioniert es wieder (der Fehler war einmalig)
|
||||
assert.ok((await termineTicketDetails(ctxFuer(doc))).includes('Konfigurationsstand: Version 1'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails wirft nie: auch nicht ohne Dokument, ohne ctx oder bei fehlender Collection', async () => {
|
||||
const fehler = stumm()
|
||||
try {
|
||||
for (const ctx of [undefined, null, {}, { doc: null }, { doc: {} }, { doc: { $id: '' } }, { doc: { $id: 7 } }, ctxFuer({ $id: 'f_gibt_es_nicht', customerId: 'c1', projectId: 'p0', siteKey: SITE_KEY })]) {
|
||||
const text = await termineTicketDetails(ctx)
|
||||
assert.equal(typeof text, 'string', JSON.stringify(ctx))
|
||||
assert.ok(text.includes('Terminplaner'), JSON.stringify(ctx))
|
||||
assert.ok(text.length <= MAX && text.isWellFormed())
|
||||
}
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('termineTicketDetails: Namen mit Markup bleiben Text, halbe Zeichenpaare werden ersetzt', async () => {
|
||||
const e = einstellung()
|
||||
e.leistungen[0].name = `Schnitt <b>x</b> \uD83D${'x'.repeat(8)}😀`
|
||||
const projekt = { projectName: `Salon \uD83D <i>x</i>`, liveDomain: 'haarlinie.example' }
|
||||
const { doc } = await bestelle({ e, projekt })
|
||||
const text = await termineTicketDetails(ctxFuer(doc, projekt, { name: `Kunde \uDE00 & Co` }))
|
||||
assert.ok(text.isWellFormed() && !HALBES_PAAR.test(text))
|
||||
assert.ok(text.includes('<b>x</b>') && text.includes('<i>x</i>'), 'Klartext, kein HTML')
|
||||
assert.ok(text.includes('😀'))
|
||||
// ohne Kunde: die Kunden-ID wie in den Notfall-Details
|
||||
assert.ok((await termineTicketDetails(ctxFuer(doc, projekt, null))).includes('Kunde: c1'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails: Gesundheit (Physiotherapie) und Maximalbefuellung: enthaelt Physio und bleibt hoechstens 7000 Zeichen', async () => {
|
||||
const e = maximalEinstellung()
|
||||
const meldungen = gesundheitsMeldungen(e)
|
||||
assert.equal(meldungen.length, 10)
|
||||
const { doc } = await bestelle({ e, fb: MAX_FRAGEBOGEN, version: 12 })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
assert.ok(text.length <= MAX, `kurz ${text.length}`)
|
||||
assert.ok(text.includes('Physio'))
|
||||
assert.ok(text.includes(`Gesundheitsbereich prüfen:\n${meldungen.map((m) => `- ${m}`).join('\n')}`), 'alle zehn Meldungen')
|
||||
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
|
||||
assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
|
||||
assert.equal(ticketStand(text), 12)
|
||||
assert.ok(!text.includes('Online-Terminbuchung'))
|
||||
for (const r of e.ressourcen) assert.ok(!text.includes(r.feedToken))
|
||||
assert.ok(!text.includes(GEKUERZT), 'die Budgetliste laesst Teile weg und schneidet nicht ab')
|
||||
// ticketTexte kuerzt nichts mehr
|
||||
const texte = await reg.ticketTexte(ctxFuer(doc))
|
||||
assert.equal(texte.details, text)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------- Registry ---
|
||||
test('Registry: featureConfig("termine").ticket.details(ctx) liefert den Text von termineTicketDetails, siteKey und $id aus ctx.doc', async () => {
|
||||
const { doc } = await bestelle({ e: einstellung() })
|
||||
const feature = reg.featureConfig('termine')
|
||||
assert.equal(feature.ticket.topic(PROJEKT), 'Terminplaner einbauen – Salon Haarlinie')
|
||||
// das Register-Objekt traegt keinen siteKey: der Text darf nur aus ctx.doc kommen
|
||||
const ctx = { feature: { ...feature, siteKey: 'FALSCH_AUS_REGISTER', $id: 'f_falsch' }, doc, project: PROJEKT, customer: { name: 'Kunde Eins' } }
|
||||
const ueberRegister = await feature.ticket.details(ctx)
|
||||
assert.equal(ueberRegister, await termineTicketDetails(ctx))
|
||||
assert.equal(ueberRegister, await termineTicketDetails(ctxFuer(doc)))
|
||||
assert.ok(ueberRegister.includes(doc.siteKey) && !ueberRegister.includes('FALSCH_AUS_REGISTER'))
|
||||
assert.ok(ueberRegister.includes('Konfigurationsstand: Version 1'), 'gelesen wurde das Dokument der Funktion (doc.$id)')
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------- Integration (Review Focus 1) ---
|
||||
const workorder = (doc) => aw.docs('workorders').get(doc.ticketId)
|
||||
|
||||
test('markOrdered + markPaid: das Ticket traegt die Kurzfassung (hoechstens 7000, mit Snippet), die Stand-Zeile und der Vergleich meldet Aenderungen', async () => {
|
||||
const { sessionId, doc: bestellt } = await bestelle({ e: maximalEinstellung(), fb: MAX_FRAGEBOGEN })
|
||||
const { doc, created, fall } = await sf.markPaid({ sessionId })
|
||||
assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
|
||||
const ticket = workorder(doc)
|
||||
assert.ok(ticket.details.length <= MAX, `details ${ticket.details.length}`)
|
||||
assert.ok(ticket.details.includes(`data-webklar-termine="${bestellt.siteKey}"`))
|
||||
assert.ok(ticket.details.includes('Einrichtung bezahlt, 149 €'), 'markPaid uebergibt das bezahlte Dokument (unlockedByPurchase)')
|
||||
assert.ok(ticket.details.includes('Physio'))
|
||||
assert.ok(!ticket.details.includes(GEKUERZT))
|
||||
assert.equal(ticket.topic, 'Terminplaner einbauen – Salon Haarlinie')
|
||||
// Ticket Version 1; die Einstellungen aendern sich spaeter: der Vergleich (Team-Route, D8) meldet es
|
||||
const ticketVersion = ticketStand(ticket.details)
|
||||
assert.equal(ticketVersion, 1)
|
||||
assert.equal(driftWarnung(ticketVersion, 1), null)
|
||||
assert.equal(driftWarnung(ticketVersion, 4), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 4).')
|
||||
})
|
||||
|
||||
test('markPaid mit kaputtem termineConfig-Lesen legt das Ticket trotzdem an: created true, Notfall-Details mit Kopf, Hinweis und Link', async () => {
|
||||
const { projectId, sessionId } = await bestelle({ e: einstellung() })
|
||||
const fehler = stumm()
|
||||
try {
|
||||
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
|
||||
const { doc, created, fall } = await sf.markPaid({ sessionId })
|
||||
assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
|
||||
assert.ok(doc.ticketId, 'Zahlung nie ohne Ticket')
|
||||
const ticket = workorder(doc)
|
||||
assert.ok(ticket.details.includes(`siteKey: ${doc.siteKey}`))
|
||||
assert.ok(ticket.details.includes('Anleitung konnte nicht erzeugt werden, im WOMS öffnen'))
|
||||
assert.ok(ticket.details.includes(reg.anleitungsLink('c1', projectId)))
|
||||
assert.ok(ticket.details.length <= MAX)
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('Webhook-Wiederholung nach einem Lesefehler: ohne Ticket bleibt setup fortsetzbar, die Kurzfassung entsteht beim zweiten Versuch', async () => {
|
||||
const { sessionId } = await bestelle({ e: einstellung() })
|
||||
const fehler = stumm()
|
||||
try {
|
||||
// das Anlegen des Tickets scheitert (nicht das Lesen): setup ohne ticketId
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500 })
|
||||
await assert.rejects(() => sf.markPaid({ sessionId }))
|
||||
const { doc, created } = await sf.markPaid({ sessionId })
|
||||
assert.equal(created, true)
|
||||
assert.ok(workorder(doc).details.includes('Konfigurationsstand: Version 1'))
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('config.features.termine.ticket.details ruft die Datei lazy auf (config.js bleibt ohne statischen Dienst-Import)', async () => {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const src = readFileSync(new URL('../server/config.js', import.meta.url), 'utf8')
|
||||
assert.match(src, /import\('\.\/services\/termine\/ticketDetails\.js'\)/)
|
||||
assert.ok(!/^import .*ticketDetails/m.test(src))
|
||||
assert.equal(typeof config.features.termine.ticket.details, 'function')
|
||||
})
|
||||
Reference in new issue
Block a user