Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich

PATCH /api/termine/einstellungen schreibt den Entwurf nach eigenen Regeln
(withTermine({ schreiben, entwurf }) in routes/termine.js): off und active
ja, ordered und setup nur in der Kundenansicht (sonst 409 gesperrt), disabled
409 abgeschaltet wie bisher. In off zusaetzlich laeuft(customerId): ohne
Wartungs-Abo 409 kein_abo (Text aus aboMeldung), bei Lesefehler 503
abo_pruefung; eine Kuendigung oder offene Zahlung sperrt nur die Bestellung,
nicht das Speichern des Entwurfs. Das Funktionsdokument legt erst der Handler
an (ensureFeatureDoc), nach der Eingabepruefung. PATCH normalisiert mit
entwurf: state !== 'active' und antwortet mit fehlt. Tests: Zustandsmatrix,
Mandanten, Pflichtangaben, Abo, und die Gegenprobe an der oeffentlichen
Schnittstelle (Entwurf und Einbauwuensche nie oeffentlich).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 19:48:57 +00:00
1 parent 42a3befc5f
commit 1cd74226cb
3 files changed
+495 -29

No files matched your search

+409 -3
View File
@@ -9,8 +9,10 @@ import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Entwurfszugriff der Kunden-Schnittstelle /api/termine (TP2, Task C4a): GET /einstellungen liest in JEDEM Zustand des
// Funktionsdokuments (off, ordered, setup, active, disabled) und auch ohne Dokument (virtueller Kontext off, nichts wird
// angelegt); alle anderen Routen behalten die Schranke active|disabled. Dazu die neuen GET-Felder (branchen, fragebogen,
// kundenansicht, abo, offen, bestellung, betriebVorschlag, einbau/feeds nur in active/disabled). Teil 1: nur Lesen; die
// Schreibregeln des Entwurfs (PATCH, Vorlage, PUT fragebogen) folgen in Teil 1b (C4b).
// kundenansicht, abo, offen, bestellung, betriebVorschlag, einbau/feeds nur in active/disabled). Teil 1: nur Lesen.
// Teil 1b (Task C4b): Schreibregeln des Entwurfs fuer PATCH /einstellungen (off und active ja, ordered und setup nur in der
// Kundenansicht, disabled nie; in off nur mit laufendem Wartungs-Abo), PATCH mit `fehlt`, und die Gegenprobe an der
// oeffentlichen Schnittstelle (ein Entwurf ist nie oeffentlich, Einbauwuensche nie). Vorlage und PUT fragebogen folgen mit C5/C6.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
@@ -33,15 +35,18 @@ const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc, customers } = await import('./support/fixtures.js')
const { bestellbarerStand, entwurfDoc } = await import('./support/fragebogenStand.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: termineRoutes, setzeUhrFuerTests, resetTermineRoutesForTests } = await import('../server/routes/termine.js')
const { default: publicTermineRoutes, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js')
const { resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } = await import('../server/services/termine/fragebogen.js')
const { aboMeldung } = await import('../server/services/featureOrder.js')
const app = await startTestApp({
'/': kundenansichtGuard,
'/api/public/termine/v1': publicTermineRoutes,
'/api/termine': termineRoutes,
})
after(() => Promise.all([app.close(), aw.close(), st.close()]))
@@ -50,6 +55,7 @@ const T = config.features.termine
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const MODUS_VORHER = config.termine.mode
const MAILS_VORHER = [...config.termine.pilotEmails]
const MIN_ANTWORT_VORHER = config.termine.minAntwortMs
const VIEW = 'a'.repeat(32)
const SITE_KEY = 'abcdefghijklmnop'
const FEATURE = 'f_p1_termine'
@@ -99,9 +105,12 @@ beforeEach(async () => {
}
resetKontextForTests()
resetTermineRoutesForTests()
resetPublicTermineForTests()
resetTermineLimitsForTests()
setzeUhrFuerTests(() => JETZT)
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['*']
config.termine.minAntwortMs = 0
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
@@ -115,6 +124,7 @@ afterEach(() => {
mock.restoreAll()
config.termine.mode = MODUS_VORHER
config.termine.pilotEmails = [...MAILS_VORHER]
config.termine.minAntwortMs = MIN_ANTWORT_VORHER
})
// ---------------------------------------------------------------- Zustandsmatrix: GET liest in jedem Zustand
@@ -514,3 +524,399 @@ test('betriebVorschlag auch in active und disabled ohne Einstellung (das Konto l
assert.equal(body.betriebVorschlag.name, 'Salon Müller', zustand)
}
})
// ================================================================ Teil 1b (C4b): Schreibregeln des Entwurfs
const PATCH_URL = '/api/termine/einstellungen'
const GESPERRT = 'Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'
const FEHLT_BEIDES = ['betrieb.telefon', 'betrieb.adresse']
const PUBLIC = '/api/public/termine/v1'
const patch = (body, auth = { cookie }) => send('PATCH', PATCH_URL, { ...auth, body: { projectId: 'p1', ...body } })
/** Alle Dokumente, die ein Schreibversuch ändern könnte, als Text: gleicher Text = nichts angelegt, nichts geändert. */
const dokumente = () =>
JSON.stringify(['portalFeatures', 'termineConfig', 'customerViewLog', 'featureConsents'].map((c) => [c, [...aw.docs(c).entries()]]))
const logEintraege = () => [...aw.docs('customerViewLog').values()]
const termineConfigDoc = () => aw.docs('termineConfig').get(FEATURE)
/** Ausgangsstand für einen PATCH: Zustand setzen (oder 'keins') und die Einstellung dazu zurückgeben. */
const ausgangsstand = (zustand) => setzeZustand(zustand) ?? bestellbarerStand({ now: JETZT })
const neueEinstellung = (basis, aendere = (e) => e) => {
const e = structuredClone(basis.einstellung)
e.betrieb.name = 'Salon Neu'
return aendere(e)
}
const ohneTelefonUndAnschrift = (e) => {
e.betrieb.telefon = ''
e.betrieb.adresse = ''
return e
}
// ---------------------------------------------------------------- Zustandsmatrix PATCH
const ERLAUBT = { keins: 1, off: 2, active: 2, ordered: 2, setup: 2 } // erwartete Version nach dem Speichern
for (const zustand of ZUSTAENDE) {
for (const [zeile, auth] of ZEILEN) {
const kundenansicht = zeile === 'Kundenansicht'
const gesperrt = (zustand === 'ordered' || zustand === 'setup') && !kundenansicht
const abgeschaltet = zustand === 'disabled'
const erwartet = abgeschaltet ? '409 abgeschaltet' : gesperrt ? '409 gesperrt' : '200'
test(`PATCH einstellungen: Zustand ${zustand}, ${zeile} => ${erwartet}`, async () => {
const basis = ausgangsstand(zustand)
const vorher = dokumente()
const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis) }, auth())
if (abgeschaltet) {
assert.equal(res.status, 409, JSON.stringify(res.body))
assert.equal(res.body.code, 'abgeschaltet')
assert.match(res.body.error, /Wartungs-Abo beendet/, 'der Text von E11')
assert.match(res.body.error, /Ab dem 03\.11\.2026 löschen wir die Daten\./)
assert.equal(dokumente(), vorher, 'nichts geändert, nichts protokolliert')
return
}
if (gesperrt) {
assert.equal(res.status, 409, JSON.stringify(res.body))
assert.equal(res.body.code, 'gesperrt')
assert.equal(res.body.error, GESPERRT)
assert.equal(dokumente(), vorher, 'das Dokument bleibt unverändert')
return
}
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.einstellung.betrieb.name, 'Salon Neu')
assert.equal(res.body.version, ERLAUBT[zustand])
assert.deepEqual(res.body.fehlt, [])
assert.equal(termineConfigDoc().version, ERLAUBT[zustand])
assert.equal(JSON.parse(termineConfigDoc().einstellung).betrieb.name, 'Salon Neu')
assert.equal(termineConfigDoc().updatedBy, kundenansicht ? 'team:u_staff2' : 'kunde:c1')
// der Zustand des Funktionsdokuments ändert sich durch das Speichern nie
const feature = aw.docs('portalFeatures').get(FEATURE)
assert.equal(feature.state, zustand === 'keins' ? 'off' : zustand)
assert.equal(feature.enabled, zustand === 'active')
// Protokoll nur in der Kundenansicht, ohne Inhalt der Einstellung
assert.deepEqual(logEintraege().map((e) => [e.action, e.detail]), kundenansicht ? [['termin_einstellungen', 'Einstellungen gespeichert']] : [])
})
}
}
test('PATCH einstellungen ohne Funktionsdokument: der erste Schreibzugriff legt Dokument (off, siteKey, ohne Kauf) und Einstellung an', async () => {
assert.equal(aw.docs('portalFeatures').size, 0)
const res = await patch({ version: 0, einstellung: neueEinstellung(bestellbarerStand({ now: JETZT })) })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(aw.docs('portalFeatures').size, 1)
const feature = aw.docs('portalFeatures').get(FEATURE)
assert.equal(feature.customerId, 'c1')
assert.equal(feature.projectId, 'p1')
assert.equal(feature.featureKey, 'termine')
assert.equal(feature.state, 'off')
assert.equal(feature.enabled, false)
assert.equal(feature.unlockedByPurchase, false)
assert.equal(feature.purchaseStatus, '')
assert.match(feature.siteKey, /^[a-z2-7]{16}$/)
assert.equal(termineConfigDoc().customerId, 'c1')
assert.equal(termineConfigDoc().projectId, 'p1')
// danach liest GET den Entwurf in off
const gelesen = (await send('GET', einstellungenUrl(), { cookie })).body
assert.equal(gelesen.state, 'off')
assert.equal(gelesen.version, 1)
assert.equal(gelesen.einstellung.betrieb.name, 'Salon Neu')
assert.equal(gelesen.siteKey, feature.siteKey)
})
test('PATCH einstellungen: die Antwort trägt Einstellung, Version und fehlt - ohne Feed-Token', async () => {
const basis = ausgangsstand('off')
const res = await patch({ version: 1, einstellung: neueEinstellung(basis) })
assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'fehlt', 'version'])
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))
})
// ---------------------------------------------------------------- Mandanten (Review-Focus 3)
test('Mandanten: Kunde c2 (und seine Kundenansicht) mit dem Projekt von c1 => 403 auf PATCH, in jedem Zustand; nichts angelegt, termineConfig unverändert', async () => {
for (const zustand of ZUSTAENDE) {
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
const basis = ausgangsstand(zustand)
const vorher = dokumente()
for (const auth of [{ cookie: customerCookie('c2') }, kv({ customerId: 'c2' })]) {
const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis) }, auth)
assert.equal(res.status, 403, zustand)
assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.')
}
assert.equal(dokumente(), vorher, `${zustand}: nichts angelegt, nichts geändert`)
if (zustand !== 'keins') assert.equal(JSON.parse(termineConfigDoc().einstellung).betrieb.name, 'Salon Muster', zustand)
}
})
test('Mandanten: ohne Sitzung 401, ohne projectId 400, unbrauchbare projectId 403, Pilot-Schalter 409 - nichts wird angelegt', async () => {
const basis = ausgangsstand('keins')
const eingabe = { version: 0, einstellung: neueEinstellung(basis) }
assert.equal((await send('PATCH', PATCH_URL, { body: { projectId: 'p1', ...eingabe } })).status, 401)
assert.equal((await send('PATCH', PATCH_URL, { cookie, body: eingabe })).status, 400)
assert.equal((await patch({ ...eingabe, projectId: 'gibt es nicht' })).status, 403)
config.termine.pilotEmails = ['andere@example.invalid']
const gesperrt = await patch(eingabe)
assert.equal(gesperrt.status, 409)
assert.equal(gesperrt.body.code, 'nicht_verfuegbar')
assert.equal(aw.docs('portalFeatures').size, 0)
assert.equal(aw.docs('termineConfig').size, 0)
})
// ---------------------------------------------------------------- fehlt: der Entwurf überspringt nur die Pflichtangaben
for (const zustand of ['keins', 'off']) {
test(`PATCH ${zustand} ohne Telefon und Anschrift => 200 mit fehlt [betrieb.telefon, betrieb.adresse]; GET meldet dasselbe`, async () => {
const basis = ausgangsstand(zustand)
const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis, ohneTelefonUndAnschrift) })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.fehlt, FEHLT_BEIDES)
assert.equal(res.body.einstellung.betrieb.telefon, '')
assert.equal(JSON.parse(termineConfigDoc().einstellung).betrieb.adresse, '')
assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.fehlt, FEHLT_BEIDES)
})
}
test('PATCH ohne Telefon und Anschrift: in active 400 (streng), im Entwurf (off, Kundenansicht in ordered) 200 - dieselbe Eingabe', async () => {
const basis = ausgangsstand('active')
const eingabe = neueEinstellung(basis, ohneTelefonUndAnschrift)
const vorher = dokumente()
const streng = await patch({ version: 1, einstellung: eingabe })
assert.equal(streng.status, 400, JSON.stringify(streng.body))
assert.deepEqual(Object.keys(streng.body.fehler).sort(), [...FEHLT_BEIDES].sort())
assert.match(streng.body.error, /Telefonnummer/)
assert.equal(dokumente(), vorher, 'nichts gespeichert')
await deleteDocument('portalFeatures', FEATURE)
await deleteDocument('termineConfig', FEATURE)
setzeZustand('ordered', { stand: basis })
const entwurf = await patch({ version: 1, einstellung: eingabe }, kv())
assert.equal(entwurf.status, 200, JSON.stringify(entwurf.body))
assert.deepEqual(entwurf.body.fehlt, FEHLT_BEIDES)
})
test('PATCH ungültige Angaben bleiben im Entwurf 400 (nur die Pflichtangaben entfallen), ohne Dokument entsteht nichts', async () => {
for (const zustand of ['keins', 'off']) {
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
const basis = ausgangsstand(zustand)
const vorher = dokumente()
const version = zustand === 'keins' ? 0 : 1
const url = await patch({ version, einstellung: neueEinstellung(basis, (e) => ({ ...e, betrieb: { ...e.betrieb, websiteUrl: 'kein link' } })) })
assert.equal(url.status, 400, `${zustand}: ${JSON.stringify(url.body)}`)
assert.ok(url.body.fehler['betrieb.websiteUrl'], zustand)
const farbe = await patch({ version, einstellung: neueEinstellung(basis, (e) => ({ ...e, farbe: 'rot' })) })
assert.equal(farbe.status, 400, zustand)
assert.ok(farbe.body.fehler.farbe, zustand)
const keinObjekt = await patch({ version, einstellung: 'text' })
assert.equal(keinObjekt.status, 400, zustand)
for (const falsch of [undefined, -1, 1.5, '1', null]) {
const res = await patch({ version: falsch, einstellung: neueEinstellung(basis) })
assert.equal(res.status, 400, `${zustand} version ${String(falsch)}`)
assert.ok(res.body.fehler.version)
}
assert.equal(dokumente(), vorher, `${zustand}: ein abgelehnter Entwurf legt nichts an und ändert nichts`)
}
})
test('PATCH mit veralteter Version im Entwurf => 409 EINSTELLUNG_VERALTET, der gespeicherte Stand bleibt', async () => {
const basis = ausgangsstand('off')
const vorher = dokumente()
const res = await patch({ version: 0, einstellung: neueEinstellung(basis) })
assert.equal(res.status, 409)
assert.equal(res.body.code, 'EINSTELLUNG_VERALTET')
assert.equal(dokumente(), vorher)
})
// ---------------------------------------------------------------- Abo: ein Entwurf entsteht nur mit laufendem Wartungs-Abo
const KEIN_ABO = aboMeldung(T, 'kein_abo')
for (const zustand of ['keins', 'off']) {
for (const [zeile, auth] of ZEILEN) {
test(`Abo: ${zustand}, ${zeile}: nur Hosting oder gar kein Abo => PATCH 409 kein_abo mit dem Text aus aboMeldung; kein Dokument entsteht, nichts ändert sich`, async () => {
const basis = ausgangsstand(zustand)
const vorher = dokumente()
assert.equal(KEIN_ABO, 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.')
for (const vorbereitung of [
() => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })), // nur Hosting
() => deleteDocument('subscriptions', 'sub_h'), // gar keins
]) {
await vorbereitung()
const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis) }, auth())
assert.equal(res.status, 409, JSON.stringify(res.body))
assert.equal(res.body.code, 'kein_abo')
assert.equal(res.body.error, KEIN_ABO)
assert.equal(dokumente(), vorher)
}
if (zustand === 'keins') {
assert.equal(aw.docs('portalFeatures').size, 0)
assert.equal(aw.docs('termineConfig').size, 0)
}
})
}
}
test('Abo: vorgemerkte Kündigung oder offene Zahlung (past_due) sperren den Entwurf nicht - PATCH bleibt 200 (die Bestellung sperrt Gate (h))', async () => {
const basis = ausgangsstand('off')
let version = 1
for (const aenderung of [{ cancelAtPeriodEnd: true }, { cancelAt: '2026-11-30T00:00:00.000Z' }, { status: 'past_due' }, { status: 'trialing' }]) {
wartung(aenderung)
const res = await patch({ version, einstellung: neueEinstellung(basis) })
assert.equal(res.status, 200, `${JSON.stringify(aenderung)}: ${JSON.stringify(res.body)}`)
version = res.body.version
}
// dasselbe ohne Dokument: der Entwurf entsteht auch bei vorgemerkter Kündigung
await deleteDocument('portalFeatures', FEATURE)
await deleteDocument('termineConfig', FEATURE)
wartung({ cancelAtPeriodEnd: true })
assert.equal((await patch({ version: 0, einstellung: neueEinstellung(basis) })).status, 200)
assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off')
})
test('Abo: ein beendetes Wartungs-Abo (canceled) ist kein laufendes => PATCH 409 kein_abo', async () => {
const basis = ausgangsstand('off')
wartung({ status: 'canceled', endedAt: '2026-10-01T00:00:00.000Z' })
const res = await patch({ version: 1, einstellung: neueEinstellung(basis) })
assert.equal(res.status, 409)
assert.equal(res.body.code, 'kein_abo')
})
for (const zustand of ['keins', 'off']) {
test(`Abo: Lesefehler (${zustand}) => PATCH 503 abo_pruefung ohne Appwrite-Text; nichts angelegt oder geändert; der nächste Versuch geht`, async () => {
const basis = ausgangsstand(zustand)
const vorher = dokumente()
const version = zustand === 'keins' ? 0 : 1
aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
const res = await patch({ version, einstellung: neueEinstellung(basis) })
assert.equal(res.status, 503, JSON.stringify(res.body))
assert.equal(res.body.code, 'abo_pruefung')
assert.equal(res.body.error, aboMeldung(T, 'unbekannt'))
assert.ok(!JSON.stringify(res.body).includes('geheimer'))
assert.ok(console.error.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log')
assert.equal(dokumente(), vorher)
assert.equal((await patch({ version, einstellung: neueEinstellung(basis) })).status, 200, 'nichts wird gemerkt')
})
}
test('Abo: gelesen wird nur in off (und ohne Dokument); in active und in der Kundenansicht von ordered und setup gibt es keine Abfrage, und es geht auch ohne Abo', async () => {
await deleteDocument('subscriptions', 'sub_h')
for (const [zustand, auth] of [['active', { cookie }], ['ordered', kv()], ['setup', kv()]]) {
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
const basis = ausgangsstand(zustand)
aw.resetListCalls()
const res = await patch({ version: 1, einstellung: neueEinstellung(basis) }, auth)
assert.equal(res.status, 200, `${zustand}: ${JSON.stringify(res.body)}`)
assert.deepEqual(aw.listCalls('subscriptions'), [], zustand)
}
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
wartung()
setzeZustand('off')
aw.resetListCalls()
assert.equal((await patch({ version: 1, einstellung: neueEinstellung(bestellbarerStand({ now: JETZT })) })).status, 200)
assert.equal(aw.listCalls('subscriptions').length, 1, 'in off genau eine Abfrage')
})
test('Schreibregeln: die anderen Schreib-Routen behalten die Schranke active (Vorlage und Feed-Link im Entwurf bleiben 409 "nicht aktiv")', async () => {
for (const zustand of ['keins', 'off', 'ordered', 'setup']) {
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
setzeZustand(zustand)
const vorher = dokumente()
for (const [method, pfad, body] of [
['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p1', vorlage: 'salon' }],
['POST', '/api/termine/ressourcen/r_aaaaaaa1/feed', { projectId: 'p1' }],
]) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${zustand} ${pfad}`)
assert.equal(res.body.code, 'nicht_aktiv')
}
assert.equal(dokumente(), vorher)
}
})
// ---------------------------------------------------------------- öffentlich: ein Entwurf ist nie sichtbar
const feedToken = (stand) => stand.einstellung.ressourcen[0].feedToken
/** Frisch lesen: der Speicher der öffentlichen Schnittstelle (30 s je siteKey) darf zwischen zwei Zuständen nichts merken. */
const oeffentlichConfig = (siteKey = SITE_KEY) => {
resetKontextForTests()
return send('GET', `${PUBLIC}/${siteKey}/config`)
}
const oeffentlichBuchung = (siteKey = SITE_KEY) => {
resetKontextForTests()
return send('POST', `${PUBLIC}/${siteKey}/buchungen`, { body: { name: 'Gast', email: 'gast@example.invalid' }, headers: { origin: app.url } })
}
const oeffentlichKalender = (token, siteKey = SITE_KEY) => {
resetKontextForTests()
return send('GET', `${PUBLIC}/kalender/${siteKey}/${token}.ics`)
}
for (const zustand of ['off', 'ordered', 'setup', 'disabled']) {
test(`öffentlich: ${zustand} mit vollständiger Einstellung => config und Buchung { aktiv: false }, Kalender-Link mit dem richtigen Token 404`, async () => {
const stand = setzeZustand(zustand)
assert.deepEqual(stand.einstellung.betrieb.telefon !== '' && stand.einstellung.betrieb.adresse !== '', true, 'die Einstellung ist vollständig')
const config_ = await oeffentlichConfig()
assert.equal(config_.status, 200)
assert.deepEqual(config_.body, { aktiv: false })
const buchung = await oeffentlichBuchung()
assert.equal(buchung.status, 200, JSON.stringify(buchung.body))
assert.deepEqual(buchung.body, { aktiv: false })
const kalender = await oeffentlichKalender(feedToken(stand))
assert.equal(kalender.status, 404)
assert.ok(!String(kalender.body).includes('BEGIN:VCALENDAR'))
})
}
test('öffentlich: ein Entwurf, der per PATCH gespeichert wurde, bleibt unsichtbar - erst die Aktivierung macht ihn sichtbar', async () => {
const basis = ausgangsstand('keins')
const res = await patch({ version: 0, einstellung: neueEinstellung(basis) })
assert.equal(res.status, 200, JSON.stringify(res.body))
const siteKey = aw.docs('portalFeatures').get(FEATURE).siteKey // beim ersten Schreiben vergeben
const token = JSON.parse(termineConfigDoc().einstellung).ressourcen[0].feedToken
assert.deepEqual((await oeffentlichConfig(siteKey)).body, { aktiv: false })
assert.deepEqual((await oeffentlichBuchung(siteKey)).body, { aktiv: false })
assert.equal((await oeffentlichKalender(token, siteKey)).status, 404)
// Aktivierung (Team): ab jetzt gilt dieselbe Einstellung öffentlich
await updateDocument('portalFeatures', FEATURE, { state: 'active', enabled: true })
const aktiv = await oeffentlichConfig(siteKey)
assert.equal(aktiv.body.aktiv, true, JSON.stringify(aktiv.body))
const buchung = await oeffentlichBuchung(siteKey)
assert.notDeepEqual(buchung.body, { aktiv: false }, 'die Buchung wird jetzt geprüft (Formular-Token fehlt), nicht als inaktiv abgewiesen')
assert.equal(buchung.status, 400)
const kalender = await oeffentlichKalender(token, siteKey)
assert.equal(kalender.status, 200)
assert.match(kalender.body, /BEGIN:VCALENDAR/)
})
test('öffentlich: active mit gespeicherter Einstellung geht (Gegenprobe zu off, ordered, setup), auch der Kalender-Link', async () => {
const stand = setzeZustand('active')
const aktiv = await oeffentlichConfig()
assert.equal(aktiv.body.aktiv, true, JSON.stringify(aktiv.body))
assert.equal((await oeffentlichKalender(feedToken(stand))).status, 200)
assert.equal((await oeffentlichKalender('x'.repeat(43))).status, 404, 'ein falsches Token bleibt 404')
})
test('Einbauwünsche nie öffentlich: fragebogen.einbau.stelle und knopfText stehen in keiner öffentlichen Antwort', async () => {
const stand = bestellbarerStand({ now: JETZT })
stand.fragebogen.einbau = { modus: 'knopf', knopfText: 'GEHEIMKNOPF', stelle: 'GEHEIMWUNSCH', gestaltung: 'GEHEIMDESIGN', bemerkung: 'GEHEIMNOTIZ' }
setzeZustand('active', { stand })
const res = await oeffentlichConfig()
assert.equal(res.body.aktiv, true, 'die Funktion ist aktiv, die Antwort enthält also Daten')
const text = JSON.stringify(res.body)
for (const verboten of ['GEHEIMWUNSCH', 'GEHEIMKNOPF', 'GEHEIMDESIGN', 'GEHEIMNOTIZ', 'fragebogen', 'einbau', 'knopfText']) {
assert.ok(!text.includes(verboten), `"${verboten}" darf in der öffentlichen Config nicht stehen`)
}
// der Kunde selbst sieht seinen Entwurf natürlich weiter
assert.equal((await send('GET', einstellungenUrl(), { cookie })).body.fragebogen.einbau.stelle, 'GEHEIMWUNSCH')
// ... und derselbe Stand in den Entwurfszuständen ist öffentlich gar nicht erst erreichbar
for (const zustand of ['off', 'ordered', 'setup']) {
await deleteDocument('portalFeatures', FEATURE)
await deleteDocument('termineConfig', FEATURE)
setzeZustand(zustand, { stand })
assert.deepEqual((await oeffentlichConfig()).body, { aktiv: false }, zustand)
}
})
+5 -5
View File
@@ -246,7 +246,7 @@ test('ohne Pilot (Kunde nicht in der Liste, Modus off) 409 "noch nicht verfuegba
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409)
})
test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen geht (Entwurf), alles andere bleibt 409 "nicht aktiv"', async () => {
test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen geht (Entwurf), Vorlage, Feed-Link, Buchungen und Zeiten bleiben 409 "nicht aktiv"', async () => {
// Lesen der Einstellungen: in jedem Zustand und auch ohne Dokument (Zustandsmatrix: test/termineFragebogenRoutes.test.js)
for (const projectId of ['p2', 'p5']) {
const lesen = await send('GET', einstellungenUrl(projectId), { cookie })
@@ -254,13 +254,12 @@ test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen
assert.equal(lesen.body.state, 'off', projectId)
assert.equal(lesen.body.einstellung, null, projectId)
assert.equal(lesen.body.version, 0, projectId)
// alles andere, auch das Schreiben der Einstellungen (Schreibregeln des Entwurfs folgen in C4b)
// alles andere (PATCH der Einstellungen im Entwurf: Schreibregeln in test/termineFragebogenRoutes.test.js, Teil 1b)
for (const [method, pfad, body] of [
['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`],
['GET', `/api/termine/zeiten?projectId=${projectId}`],
['GET', `/api/termine/export.csv?projectId=${projectId}`],
['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }],
['PATCH', '/api/termine/einstellungen', { projectId, version: 0, einstellung: {} }],
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }],
['POST', '/api/termine/buchungen', { projectId, art: 'sperre' }],
]) {
@@ -415,7 +414,7 @@ test('R55: Betrieb nur mit Anfrage-Leistungen (Privatpersonen) braucht die Ansch
const komplett = { ...nurTelefon, betrieb: { ...nurTelefon.betrieb, adresse: 'Weg 1, 12345 Ort' } }
const ok = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: komplett } })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual(ok.body.fehlt ?? [], [])
assert.deepEqual(ok.body.fehlt, [])
const config = await oeffentlich(SCHLUESSEL)
assert.equal(config.aktiv, true)
assert.equal(config.widerruf, true)
@@ -460,7 +459,8 @@ test('PATCH einstellungen: speichert, Version zaehlt hoch, Feed-Token bleiben (a
neu.ressourcen.push({ name: 'Clara', typ: 'person', plan: { 2: [['09:00', '12:00']] } })
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: vorher.version, einstellung: neu } })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'version'])
assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'fehlt', 'version'])
assert.deepEqual(res.body.fehlt, [])
assert.equal(res.body.version, 2)
assert.equal(res.body.einstellung.betrieb.name, 'Salon Neu')
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))