POST /api/termine/einstellungen/vorlage nimmt jetzt eine Branche (BRANCHEN), optionale
Betriebsfelder (nichtleere Werte ueberschreiben das Konto) und schreibt den Fragebogen mit
der Einstellung in einem Vorgang. Der Gesundheitsbereich ist ausgeschlossen (400
gesundheitsbereich, nichts wird angelegt). Eine bestehende Einstellung ersetzt nur
ersetzen:true mit passender version im Zustand off (neue IDs und Kalender-Token, Fragebogen
neu, Einbauwuensche bleiben). Die Route arbeitet im Entwurf (off, active, ordered/setup nur
in der Kundenansicht, disabled nie).
PUT /api/termine/fragebogen speichert nur den Fragebogen (Version der Einstellung bleibt),
ist in der Kundenansicht gesperrt (Waechter und Router), zaehlt gegen das Schreibbudget und
antwortet mit { fragebogen, offen }.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1612 lines
86 KiB
JavaScript
1612 lines
86 KiB
JavaScript
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
import { startFakeStripe } from './support/fakeStripe.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
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.
|
|
// 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).
|
|
// Teil 2 (Task C5): POST /einstellungen/vorlage mit Branche (Fragebogen mit, Gesundheitsbereich ausgeschlossen, Ersetzen im Entwurf)
|
|
// und PUT /fragebogen (speichert nur den Fragebogen, nie in der Kundenansicht), dazu das Schreibbudget.
|
|
const aw = await startAppwriteMock({
|
|
schema: {
|
|
portalFeatures: schemaKeys('portalFeatures'),
|
|
termineConfig: schemaKeys('termineConfig'),
|
|
termineBookings: schemaKeys('termineBookings'),
|
|
termineClaims: schemaKeys('termineClaims'),
|
|
termineOutbox: schemaKeys('termineOutbox'),
|
|
subscriptions: schemaKeys('subscriptions'),
|
|
contractEvents: schemaKeys('contractEvents'),
|
|
featureConsents: schemaKeys('featureConsents'),
|
|
customerViewLog: schemaKeys('customerViewLog'),
|
|
},
|
|
datetimes: TERMINE_DATETIMES,
|
|
defaultLimit: 25,
|
|
})
|
|
const st = await startFakeStripe()
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
|
|
const { config } = await import('../server/config.js')
|
|
const { startTestApp, send } = await import('./support/testApp.js')
|
|
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, subscriptionDoc, wartungDoc, customers } = await import('./support/fixtures.js')
|
|
const { bestellbarerStand, entwurfDoc } = await import('./support/fragebogenStand.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, SCHRITTE, bestellpruefung, bestellungAnsicht, leerFragebogen } = await import('../server/services/termine/fragebogen.js')
|
|
const { aboMeldung } = await import('../server/services/featureOrder.js')
|
|
|
|
// Der Router allein, ohne Waechter der Kundenansicht (Teil 2: der Router prueft die Kundenansicht bei PUT /fragebogen selbst).
|
|
// Zuerst gestartet: send() spricht die zuletzt gestartete App an, das ist die mit Waechter.
|
|
const direkt = await startTestApp({ '/api/termine': termineRoutes })
|
|
const app = await startTestApp({
|
|
'/': kundenansichtGuard,
|
|
'/api/public/termine/v1': publicTermineRoutes,
|
|
'/api/termine': termineRoutes,
|
|
})
|
|
after(() => Promise.all([app.close(), direkt.close(), aw.close(), st.close()]))
|
|
|
|
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'
|
|
const NICHT_AKTIV = 'Der Terminplaner ist für diese Website nicht aktiv.'
|
|
|
|
const cookie = customerCookie('c1')
|
|
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
|
|
/** Zwei Zeilen der Zustandsmatrix: der Kunde selbst und ein Mitarbeiter in der Kundenansicht. */
|
|
const ZEILEN = [
|
|
['Kunde', () => ({ cookie })],
|
|
['Kundenansicht', () => kv()],
|
|
]
|
|
const ZUSTAENDE = ['keins', 'off', 'ordered', 'setup', 'active', 'disabled']
|
|
|
|
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
|
|
const einstellungenUrl = (projectId = 'p1') => `/api/termine/einstellungen?projectId=${projectId}`
|
|
const featureDoc = (state, over = {}) => ({
|
|
$id: FEATURE,
|
|
customerId: 'c1',
|
|
projectId: 'p1',
|
|
featureKey: 'termine',
|
|
state,
|
|
enabled: state === 'active',
|
|
siteKey: SITE_KEY,
|
|
...(state === 'active' ? { activatedAt: '2026-10-01T09:00:00.000Z' } : {}),
|
|
...(state === 'disabled' ? { disabledAt: '2026-10-04T10:00:00.000Z', disabledReason: 'abo_ende' } : {}),
|
|
...over,
|
|
})
|
|
const groessen = () => ({
|
|
portalFeatures: aw.docs('portalFeatures').size,
|
|
termineConfig: aw.docs('termineConfig').size,
|
|
featureConsents: aw.docs('featureConsents').size,
|
|
customerViewLog: aw.docs('customerViewLog').size,
|
|
})
|
|
|
|
/** Zustand des Funktionsdokuments von Projekt p1: 'keins' = kein Dokument. Mit Entwurf (Einstellung und Fragebogen) in termineConfig. */
|
|
function setzeZustand(zustand, { stand = bestellbarerStand({ now: JETZT }), over = {} } = {}) {
|
|
if (zustand === 'keins') return null
|
|
aw.seed('portalFeatures', featureDoc(zustand, over))
|
|
if (stand) aw.seed('termineConfig', entwurfDoc(FEATURE, { customerId: 'c1', projectId: 'p1', stand }))
|
|
return stand
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'featureConsents', 'customerViewLog', 'subscriptions', 'contractEvents', 'customers', 'websiteProjects']) {
|
|
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
|
}
|
|
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', () => {})
|
|
|
|
seedFixtures(aw)
|
|
aw.seed('customers', { ...customers.c1, companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', phone: '06301 1234' })
|
|
wartung()
|
|
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon', subdomain: 'salon' })
|
|
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Fremd', subdomain: 'fremd' })
|
|
})
|
|
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
|
|
|
|
for (const zustand of ZUSTAENDE) {
|
|
for (const [zeile, auth] of ZEILEN) {
|
|
test(`GET einstellungen: Zustand ${zustand}, ${zeile} => 200 mit Zustand, Entwurf und Fragebogen; nichts wird angelegt`, async () => {
|
|
const stand = setzeZustand(zustand)
|
|
const vorher = groessen()
|
|
const res = await send('GET', einstellungenUrl(), auth())
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
const b = res.body
|
|
assert.equal(b.state, zustand === 'keins' ? 'off' : zustand)
|
|
assert.equal(b.kundenansicht, zeile === 'Kundenansicht')
|
|
if (stand) {
|
|
assert.equal(b.version, 1)
|
|
assert.equal(b.einstellung.betrieb.name, stand.einstellung.betrieb.name)
|
|
assert.equal(b.fragebogen.branche, 'salon', 'der gespeicherte Fragebogen kommt mit')
|
|
assert.deepEqual(b.fragebogen.bestaetigt, stand.fragebogen.bestaetigt)
|
|
} else {
|
|
assert.equal(b.einstellung, null)
|
|
assert.equal(b.version, 0)
|
|
assert.deepEqual(b.fragebogen, leerFragebogen())
|
|
}
|
|
assert.ok(Array.isArray(b.vorlagen) && b.vorlagen.length === 9, 'die Vorlagen kommen in jedem Zustand')
|
|
assert.equal(res.headers.get('cache-control'), 'no-store')
|
|
assert.deepEqual(groessen(), vorher, 'Lesen legt nie ein Dokument an, ändert keins und protokolliert nichts')
|
|
})
|
|
}
|
|
}
|
|
|
|
test('GET einstellungen ohne Funktionsdokument: 200 { state: off, einstellung: null, version: 0 }, portalFeatures und termineConfig bleiben leer', async () => {
|
|
const vorher = groessen()
|
|
const res = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(res.status, 200)
|
|
assert.equal(res.body.state, 'off')
|
|
assert.equal(res.body.einstellung, null)
|
|
assert.equal(res.body.version, 0)
|
|
assert.equal(res.body.siteKey, null, 'noch kein Dokument, also noch kein siteKey')
|
|
assert.equal(res.body.aktivSeit, null)
|
|
assert.equal(res.body.abgeschaltet, null)
|
|
assert.equal(res.body.anfragenOffen, 0)
|
|
assert.deepEqual(res.body.fehlt, [])
|
|
assert.deepEqual(res.body.feeds, {})
|
|
assert.equal(aw.docs('portalFeatures').size, vorher.portalFeatures)
|
|
assert.equal(aw.docs('portalFeatures').size, 0)
|
|
assert.equal(aw.docs('termineConfig').size, 0)
|
|
})
|
|
|
|
test('GET einstellungen: ein termineConfig-Dokument ohne Funktionsdokument wird nie gelesen (kein Dokument heißt leerer Entwurf)', async () => {
|
|
aw.seed('termineConfig', entwurfDoc(FEATURE, { customerId: 'c1', projectId: 'p1' }))
|
|
const res = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(res.status, 200)
|
|
assert.equal(res.body.einstellung, null)
|
|
assert.equal(res.body.version, 0)
|
|
assert.deepEqual(res.body.fragebogen, leerFragebogen())
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Alle anderen Routen behalten die Schranke active|disabled
|
|
|
|
const ANDERE_ROUTEN = [
|
|
['GET', '/api/termine/buchungen?projectId=p1&von=2026-10-01&bis=2026-10-31'],
|
|
['GET', '/api/termine/zeiten?projectId=p1&leistung=x&tag=2026-10-12'],
|
|
['GET', '/api/termine/export.csv?projectId=p1'],
|
|
['POST', '/api/termine/ressourcen/r_aaaaaaa1/feed', { projectId: 'p1' }],
|
|
['POST', '/api/termine/buchungen', { projectId: 'p1', art: 'sperre' }],
|
|
['POST', '/api/termine/buchungen/b_x/aktion', { projectId: 'p1', aktion: 'annehmen' }],
|
|
['DELETE', '/api/termine/buchungen/b_x?projectId=p1'],
|
|
]
|
|
|
|
for (const zustand of ['keins', 'off', 'ordered', 'setup']) {
|
|
for (const [zeile, auth] of ZEILEN) {
|
|
test(`Zustand ${zustand}, ${zeile}: Buchungen, Zeiten, Export, Feed-Link und Termine eintragen bleiben 409 "nicht aktiv"`, async () => {
|
|
setzeZustand(zustand)
|
|
const vorher = groessen()
|
|
for (const [method, pfad, body] of ANDERE_ROUTEN) {
|
|
const res = await send(method, pfad, { ...auth(), body })
|
|
assert.equal(res.status, 409, `${method} ${pfad}`)
|
|
assert.equal(res.body.error, NICHT_AKTIV, `${method} ${pfad}`)
|
|
assert.equal(res.body.code, 'nicht_aktiv', `${method} ${pfad}`)
|
|
}
|
|
assert.deepEqual(groessen(), vorher)
|
|
})
|
|
}
|
|
}
|
|
|
|
test('Zustand active und disabled: die Lese-Routen gehen weiter (Schranke active|disabled unverändert)', async () => {
|
|
for (const zustand of ['active', 'disabled']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
const liste = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-01&bis=2026-10-31', { cookie })
|
|
assert.equal(liste.status, 200, zustand)
|
|
assert.deepEqual(liste.body.buchungen, [])
|
|
assert.equal((await send('GET', '/api/termine/export.csv?projectId=p1', { cookie })).status, 200, zustand)
|
|
}
|
|
})
|
|
|
|
test('Pilot-Schalter gilt auch für das Lesen des Entwurfs: Modus off oder Kunde nicht in der Liste => 409 "noch nicht verfügbar", ohne und mit Dokument', async () => {
|
|
for (const zustand of ['keins', 'off']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
config.termine.pilotEmails = ['andere@example.invalid']
|
|
for (const auth of [{ cookie }, kv()]) {
|
|
const res = await send('GET', einstellungenUrl(), auth)
|
|
assert.equal(res.status, 409, zustand)
|
|
assert.equal(res.body.error, 'Der Terminplaner ist noch nicht verfügbar.')
|
|
assert.equal(res.body.code, 'nicht_verfuegbar')
|
|
}
|
|
config.termine.pilotEmails = ['*']
|
|
config.termine.mode = 'off'
|
|
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409, `${zustand} bei Modus off`)
|
|
config.termine.mode = 'pilot'
|
|
}
|
|
})
|
|
|
|
test('ohne Sitzung 401, ohne projectId 400, unbrauchbare projectId 403', async () => {
|
|
assert.equal((await send('GET', einstellungenUrl())).status, 401)
|
|
assert.equal((await send('GET', '/api/termine/einstellungen', { cookie })).status, 400)
|
|
const unbrauchbar = await send('GET', einstellungenUrl('gibt es nicht'), { cookie })
|
|
assert.equal(unbrauchbar.status, 403)
|
|
assert.equal(unbrauchbar.body.error, 'Kein Zugriff auf dieses Projekt.')
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Mandanten (Review-Focus 3)
|
|
|
|
test('Mandanten: Kunde c2 mit dem Projekt von c1 => 403 auf GET, in jedem Zustand und in der Kundenansicht; nichts angelegt, nichts gelesen', async () => {
|
|
for (const zustand of ['keins', 'off', 'ordered', 'setup', 'active', 'disabled']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
const vorher = groessen()
|
|
for (const auth of [{ cookie: customerCookie('c2') }, kv({ customerId: 'c2' })]) {
|
|
const res = await send('GET', einstellungenUrl('p1'), auth)
|
|
assert.equal(res.status, 403, zustand)
|
|
assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.')
|
|
assert.ok(!JSON.stringify(res.body).includes('Salon'), 'keine Daten des fremden Entwurfs')
|
|
}
|
|
assert.deepEqual(groessen(), vorher, zustand)
|
|
}
|
|
})
|
|
|
|
test('Mandanten: gehört das Funktionsdokument eines eigenen Projekts einem anderen Kunden, sieht der Kunde einen leeren Entwurf statt dessen Daten', async () => {
|
|
aw.seed('portalFeatures', featureDoc('off', { $id: 'f_p3_termine', customerId: 'c1', projectId: 'p3', siteKey: 'bcdefghijklmnopq' }))
|
|
aw.seed('termineConfig', entwurfDoc('f_p3_termine', { customerId: 'c1', projectId: 'p3' }))
|
|
const res = await send('GET', einstellungenUrl('p3'), { cookie: customerCookie('c2') })
|
|
assert.equal(res.status, 200)
|
|
assert.equal(res.body.state, 'off')
|
|
assert.equal(res.body.einstellung, null)
|
|
assert.equal(res.body.siteKey, null)
|
|
assert.ok(!JSON.stringify(res.body).includes('Salon Muster'))
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Felder je Zustand
|
|
|
|
test('branchen: Schlüssel, Name und gesundheit je Branche aus BRANCHEN (ohne Vorlage), der Gesundheitsbereich zuletzt', async () => {
|
|
const res = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.deepEqual(res.body.branchen, BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })))
|
|
assert.equal(res.body.branchen.length, 10)
|
|
assert.deepEqual(res.body.branchen.at(-1), { key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true })
|
|
assert.ok(res.body.branchen.every((b) => Object.keys(b).join() === 'key,name,gesundheit'), 'keine internen Felder wie vorlage')
|
|
assert.equal(res.body.branchen.filter((b) => b.gesundheit).length, 1)
|
|
})
|
|
|
|
test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippet); in off, ordered, setup und ohne Dokument leer', async () => {
|
|
for (const zustand of ['keins', 'off', 'ordered', 'setup', 'active', 'disabled']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
const stand = setzeZustand(zustand)
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
if (zustand === 'active' || zustand === 'disabled') {
|
|
assert.match(body.einbau.snippet, new RegExp(`data-webklar-termine="${SITE_KEY}"`), zustand)
|
|
} else {
|
|
assert.equal(body.einbau.snippet, undefined, zustand)
|
|
}
|
|
if (zustand === 'active') {
|
|
assert.equal(Object.keys(body.feeds).length, body.einstellung.ressourcen.length, 'Kalender-Link je Person')
|
|
assert.ok(Object.keys(body.feeds).length > 0)
|
|
} else {
|
|
assert.deepEqual(body.feeds, {}, zustand)
|
|
}
|
|
// die Token stehen höchstens in den Links, nie in der Einstellung
|
|
if (stand) {
|
|
for (const r of stand.einstellung.ressourcen) {
|
|
assert.equal(typeof r.feedToken, 'string')
|
|
assert.ok(!JSON.stringify(body.einstellung).includes(r.feedToken), `${zustand}: kein Feed-Token in der Einstellung`)
|
|
if (zustand !== 'active') assert.ok(!JSON.stringify(body).includes(r.feedToken), `${zustand}: auch sonst nirgends`)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
test('abo, offen und bestellung gibt es nur in off; sonst null', async () => {
|
|
for (const zustand of ['ordered', 'setup', 'active', 'disabled']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(body.abo, null, zustand)
|
|
assert.equal(body.offen, null, zustand)
|
|
assert.equal(body.bestellung, null, zustand)
|
|
}
|
|
setzeZustand('off')
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.notEqual(body.abo, null)
|
|
assert.notEqual(body.offen, null)
|
|
assert.notEqual(body.bestellung, null)
|
|
})
|
|
|
|
test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => {
|
|
setzeZustand('disabled')
|
|
const lesen = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' })
|
|
const schreiben = await send('POST', '/api/termine/buchungen', { cookie, body: { projectId: 'p1', art: 'sperre' } })
|
|
assert.equal(schreiben.status, 409)
|
|
assert.equal(schreiben.body.code, 'abgeschaltet')
|
|
assert.match(schreiben.body.error, /Wartungs-Abo beendet/)
|
|
for (const zustand of ['keins', 'off', 'ordered', 'setup', 'active']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
assert.equal((await send('GET', einstellungenUrl(), { cookie })).body.abgeschaltet, null, zustand)
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Bestellung in off: abo, offen, bestellung
|
|
|
|
test('off mit bestellbarem Entwurf und laufendem Wartungs-Abo: abo { ok: true }, offen leer, bestellung = Sicht des Registers', async () => {
|
|
setzeZustand('off')
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.deepEqual(body.abo, { ok: true })
|
|
assert.deepEqual(body.offen, [])
|
|
assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T))))
|
|
assert.equal(body.bestellung.consent.knopf, T.consent.knopf, 'der Knopf zeigt genau den Registertext')
|
|
assert.equal(body.bestellung.consent.version, T.consent.version)
|
|
assert.deepEqual(body.bestellung.consent.items, T.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })))
|
|
assert.equal(body.bestellung.preis, T.priceLabel)
|
|
assert.equal(body.bestellung.preisHinweis, config.ustHinweis)
|
|
assert.equal(typeof body.bestellung.belehrung, 'string')
|
|
assert.ok(!('ticket' in body.bestellung) && !('mails' in body.bestellung), 'keine internen Registerfelder')
|
|
})
|
|
|
|
test('off mit offenem Fragebogen: offen nennt die Punkte aus bestellpruefung mit Schritt und Text (ohne Abo-Punkt, wenn das Abo läuft)', async () => {
|
|
const stand = bestellbarerStand({ now: JETZT })
|
|
stand.fragebogen.bestaetigt = stand.fragebogen.bestaetigt.filter((schritt) => schritt !== 'wer')
|
|
stand.fragebogen.bundeslandGewaehlt = false
|
|
setzeZustand('off', { stand })
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.deepEqual(body.offen, [
|
|
{ schritt: 'wer', text: 'Bitte bestätige den Schritt „Wer wird gebucht“.' },
|
|
{ schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.' },
|
|
])
|
|
assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, abo: { ok: true } }))
|
|
})
|
|
|
|
test('off ohne Entwurf (Dokument ohne Einstellung, oder gar kein Dokument): genau ein offener Punkt, die Wahl der Branche', async () => {
|
|
const erwartet = [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]
|
|
assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.offen, erwartet, 'kein Dokument')
|
|
aw.seed('portalFeatures', featureDoc('off'))
|
|
const body = (await send('GET', einstellungenUrl(), { cookie })).body
|
|
assert.deepEqual(body.offen, erwartet, 'Dokument ohne termineConfig')
|
|
assert.deepEqual(body.abo, { ok: true })
|
|
assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T))))
|
|
})
|
|
|
|
test('Abo ohne laufendes Wartungs-Abo (nur Hosting, oder gar keins): abo { ok: false, grund: kein_abo }, offen mit dem Abo-Punkt (h) unter "bestellen"', async () => {
|
|
setzeZustand('off')
|
|
const text = 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'
|
|
assert.equal(aboMeldung(T, 'kein_abo'), text)
|
|
const faelle = [
|
|
['nur Hosting', async () => {}],
|
|
['gar kein Abo', () => deleteDocument('subscriptions', 'sub_h')],
|
|
]
|
|
for (const [name, vorbereitung] of faelle) {
|
|
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) // wie seedFixtures
|
|
await vorbereitung()
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(body.state, 'off', name)
|
|
assert.deepEqual(body.abo, { ok: false, grund: 'kein_abo' }, name)
|
|
assert.deepEqual(body.offen, [{ schritt: 'bestellen', text }], name)
|
|
}
|
|
})
|
|
|
|
test('Abo mit vorgemerkter Kündigung: grund gekuendigt; past_due: grund nicht_aktiv; jeweils mit dem Text aus aboMeldung', async () => {
|
|
setzeZustand('off')
|
|
wartung({ cancelAtPeriodEnd: true })
|
|
let body = (await send('GET', einstellungenUrl(), { cookie })).body
|
|
assert.deepEqual(body.abo, { ok: false, grund: 'gekuendigt' })
|
|
assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'gekuendigt') }])
|
|
assert.equal(body.offen[0].text, 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.')
|
|
|
|
wartung({ status: 'past_due' })
|
|
body = (await send('GET', einstellungenUrl(), { cookie })).body
|
|
assert.deepEqual(body.abo, { ok: false, grund: 'nicht_aktiv' })
|
|
assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'nicht_aktiv') }])
|
|
})
|
|
|
|
test('Lesefehler beim Abo: GET bleibt 200, abo null und der offene Punkt "nicht geprüft" (Text aus aboMeldung unbekannt); nichts Technisches nach außen', async () => {
|
|
setzeZustand('off')
|
|
aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
|
|
const res = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(res.status, 200)
|
|
assert.equal(res.body.abo, null)
|
|
assert.deepEqual(res.body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'unbekannt') }])
|
|
assert.equal(res.body.offen[0].text, 'Das Abo konnte gerade nicht geprüft werden.')
|
|
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')
|
|
// der nächste Aufruf liest wieder (nichts wird gemerkt)
|
|
assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.abo, { ok: true })
|
|
})
|
|
|
|
test('Lesefehler beim Abo und offener Fragebogen: die übrigen Punkte bleiben, der Abo-Punkt kommt dazu (sortiert nach Schrittfolge, "bestellen" zuletzt)', async () => {
|
|
const stand = bestellbarerStand({ now: JETZT })
|
|
stand.fragebogen.bestaetigt = []
|
|
setzeZustand('off', { stand })
|
|
aw.failNext({ collection: 'subscriptions', status: 503 })
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(body.abo, null)
|
|
assert.equal(body.offen.at(-1).schritt, 'bestellen')
|
|
assert.equal(body.offen.at(-1).text, aboMeldung(T, 'unbekannt'))
|
|
assert.equal(body.offen.length, 8 + 1, 'acht unbestätigte Schritte und der Abo-Punkt')
|
|
})
|
|
|
|
test('Das Abo wird nur in off gelesen: in ordered, setup, active und disabled gibt es keine Abfrage auf subscriptions', async () => {
|
|
for (const zustand of ['ordered', 'setup', 'active', 'disabled']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
aw.resetListCalls()
|
|
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, zustand)
|
|
assert.deepEqual(aw.listCalls('subscriptions'), [], zustand)
|
|
}
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
setzeZustand('off')
|
|
aw.resetListCalls()
|
|
await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(aw.listCalls('subscriptions').length, 1, 'in off genau eine Abfrage')
|
|
})
|
|
|
|
// ---------------------------------------------------------------- betriebVorschlag
|
|
|
|
test('betriebVorschlag ohne Einstellung: Name, Anschrift, E-Mail und Telefon aus dem Konto; Website leer, wenn es nur die Vorschau-Adresse gibt', async () => {
|
|
for (const zustand of ['keins', 'off']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
if (zustand === 'off') aw.seed('portalFeatures', featureDoc('off'))
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.deepEqual(body.betriebVorschlag, {
|
|
name: 'Salon Müller',
|
|
adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
|
email: 'k@example.invalid',
|
|
telefon: '06301 1234',
|
|
websiteUrl: '',
|
|
}, zustand)
|
|
}
|
|
})
|
|
|
|
test('betriebVorschlag: die Website kommt nur aus liveDomain (https, ohne Pfad), nie aus der Vorschau-Adresse', async () => {
|
|
const faelle = [
|
|
['Live-Domain mit Schema und Pfad', { liveDomain: 'HTTPS://Www.Salon-Mueller.de/start?x=1' }, 'https://www.salon-mueller.de'],
|
|
['Live-Domain schlicht', { liveDomain: 'salon-mueller.de' }, 'https://salon-mueller.de'],
|
|
['Live-Domain ist die Vorschau-Adresse', { liveDomain: 'salon.project.webklar.com' }, ''],
|
|
['Live-Domain unbrauchbar', { liveDomain: 'kein host' }, ''],
|
|
['keine Live-Domain', { liveDomain: '' }, ''],
|
|
]
|
|
for (const [name, over, erwartet] of faelle) {
|
|
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon', subdomain: 'salon', ...over })
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(body.betriebVorschlag.websiteUrl, erwartet, name)
|
|
}
|
|
// ohne Subdomain gilt die Live-Domain ebenfalls
|
|
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon', liveDomain: 'salon-mueller.de' })
|
|
assert.equal((await send('GET', einstellungenUrl(), { cookie })).body.betriebVorschlag.websiteUrl, 'https://salon-mueller.de')
|
|
})
|
|
|
|
test('betriebVorschlag ohne Firma und Telefon im Konto: Name aus dem Kontonamen, Rest leer', async () => {
|
|
aw.seed('customers', { ...customers.c1 })
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.deepEqual(body.betriebVorschlag, { name: 'Kunde Eins', adresse: '', email: 'k@example.invalid', telefon: '', websiteUrl: '' })
|
|
})
|
|
|
|
test('betriebVorschlag gibt es nur ohne Einstellung: mit gespeicherter Einstellung fehlt das Feld (und das Konto wird nicht gelesen)', async () => {
|
|
setzeZustand('off')
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.ok(!('betriebVorschlag' in body))
|
|
for (const zustand of ['ordered', 'setup', 'active', 'disabled']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
assert.ok(!('betriebVorschlag' in (await send('GET', einstellungenUrl(), { cookie })).body), zustand)
|
|
}
|
|
})
|
|
|
|
test('betriebVorschlag auch in active und disabled ohne Einstellung (das Konto liefert die Vorgabe, nicht der Zustand)', async () => {
|
|
for (const zustand of ['active', 'disabled']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
setzeZustand(zustand, { stand: null })
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(body.einstellung, null)
|
|
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 übrigen Schreib-Routen behalten die Schranke active (der Feed-Link im Entwurf bleibt 409 "nicht aktiv"; Vorlage und PUT fragebogen: Teil 2)', 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/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)
|
|
}
|
|
})
|
|
|
|
// ================================================================ Teil 2 (C5): Branche und Vorlage im Entwurf, PUT /fragebogen
|
|
|
|
const VORLAGE_URL = '/api/termine/einstellungen/vorlage'
|
|
const FRAGEBOGEN_URL = '/api/termine/fragebogen'
|
|
const KUNDENANSICHT_TEXT = 'In der Kundenansicht kann nichts bestätigt werden.'
|
|
const GESUNDHEIT_TEXT = 'Für Betriebe im Gesundheitsbereich ist der Terminplaner nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.'
|
|
|
|
const vorlage = (body, auth = { cookie }) => send('POST', VORLAGE_URL, { ...auth, body: { projectId: 'p1', ...body } })
|
|
const putFragebogen = (fragebogen, auth = { cookie }, extra = {}) => send('PUT', FRAGEBOGEN_URL, { ...auth, body: { projectId: 'p1', fragebogen, ...extra } })
|
|
/** Anfrage an den Router ohne Waechter der Kundenansicht. */
|
|
async function sendDirekt(method, pfad, { body, cookie: c } = {}) {
|
|
const res = await fetch(direkt.url + pfad, {
|
|
method,
|
|
headers: { 'content-type': 'application/json', ...(c ? { cookie: c } : {}) },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const text = await res.text()
|
|
return { status: res.status, body: text ? JSON.parse(text) : null }
|
|
}
|
|
const gespeicherteEinstellung = () => JSON.parse(termineConfigDoc().einstellung)
|
|
const gespeicherterFragebogen = () => JSON.parse(termineConfigDoc().fragebogen)
|
|
const ids = (e) => [...e.ressourcen.map((r) => r.id), ...e.leistungen.map((l) => l.id)]
|
|
const feedTokens = (e) => e.ressourcen.map((r) => r.feedToken)
|
|
const EINBAU = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Unter den Öffnungszeiten', gestaltung: 'Dunkel mit goldener Schrift', bemerkung: 'Bitte vor Freitag' }
|
|
/** Bestellbarer Stand mit Einbauwünschen und Zustand des Funktionsdokuments (Version 1). */
|
|
const standMitEinbau = (zustand = 'off') => {
|
|
const stand = bestellbarerStand({ now: JETZT })
|
|
stand.fragebogen.einbau = { ...EINBAU }
|
|
setzeZustand(zustand, { stand })
|
|
return stand
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Vorlage mit Branche: Entwurf anlegen
|
|
|
|
test('Vorlage mit Branche salon ohne Dokument: 201, Funktionsdokument off mit dauerhaftem siteKey, termineConfig mit Fragebogen-Branche', async () => {
|
|
assert.equal(aw.docs('portalFeatures').size, 0)
|
|
const res = await vorlage({ branche: 'salon' })
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'fehlt', 'fragebogen', 'version'])
|
|
assert.equal(res.body.version, 1)
|
|
assert.deepEqual(res.body.fehlt, [])
|
|
assert.equal(res.body.einstellung.vorlage, 'salon')
|
|
assert.ok(res.body.einstellung.leistungen.length >= 2)
|
|
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)), 'der Browser bekommt nie ein Token')
|
|
assert.deepEqual(res.body.fragebogen, { ...leerFragebogen(), branche: 'salon' })
|
|
|
|
const feature = aw.docs('portalFeatures').get(FEATURE)
|
|
assert.equal(feature.state, 'off')
|
|
assert.equal(feature.enabled, false)
|
|
assert.equal(feature.unlockedByPurchase, false)
|
|
assert.match(feature.siteKey, /^[a-z2-7]{16}$/)
|
|
assert.equal(termineConfigDoc().version, 1)
|
|
assert.equal(termineConfigDoc().updatedBy, 'kunde:c1')
|
|
assert.equal(termineConfigDoc().customerId, 'c1')
|
|
assert.equal(termineConfigDoc().projectId, 'p1')
|
|
assert.equal(gespeicherterFragebogen().branche, 'salon')
|
|
assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen)
|
|
assert.ok(gespeicherteEinstellung().ressourcen.every((r) => r.feedToken.length === 43), 'gespeichert hat jede Person ein Token')
|
|
assert.equal(gespeicherteEinstellung().betrieb.name, 'Salon Müller', 'Betrieb aus dem Kundenkonto')
|
|
assert.equal(logEintraege().length, 0, 'kein Eintrag für den Kunden selbst')
|
|
|
|
// GET zeigt denselben Stand, der Entwurf ist öffentlich unsichtbar
|
|
const gelesen = (await send('GET', einstellungenUrl(), { cookie })).body
|
|
assert.equal(gelesen.state, 'off')
|
|
assert.equal(gelesen.fragebogen.branche, 'salon')
|
|
assert.equal(gelesen.version, 1)
|
|
assert.deepEqual((await oeffentlichConfig(feature.siteKey)).body, { aktiv: false })
|
|
const siteKey = feature.siteKey
|
|
assert.equal((await vorlage({ branche: 'salon' })).status, 409)
|
|
assert.equal(aw.docs('portalFeatures').get(FEATURE).siteKey, siteKey, 'der siteKey bleibt')
|
|
})
|
|
|
|
test('Vorlage mit Branche: jede Branche außer dem Gesundheitsbereich legt ihre Vorlage an, der Fragebogen nennt sie', async () => {
|
|
const mitVorlage = BRANCHEN.filter((b) => !b.gesundheit)
|
|
assert.ok(mitVorlage.length >= 9)
|
|
for (const branche of mitVorlage) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
const res = await vorlage({ branche: branche.key })
|
|
assert.equal(res.status, 201, `${branche.key}: ${JSON.stringify(res.body)}`)
|
|
assert.equal(res.body.einstellung.vorlage, branche.vorlage, branche.key)
|
|
assert.equal(res.body.fragebogen.branche, branche.key)
|
|
assert.equal(gespeicherterFragebogen().branche, branche.key)
|
|
}
|
|
})
|
|
|
|
test('Vorlage: Betriebsfelder aus dem Rumpf überschreiben das Konto, leere Felder nicht, unbekannte und fremde Schlüssel werden ignoriert', async () => {
|
|
const res = await vorlage({
|
|
branche: 'salon',
|
|
betrieb: { name: ' Haarstudio Neu ', email: '', telefon: ' ', adresse: '', websiteUrl: 'https://haarstudio.example.invalid', impressumUrl: 'https://boese.example.invalid/impressum' },
|
|
})
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
const betrieb = res.body.einstellung.betrieb
|
|
assert.equal(betrieb.name, 'Haarstudio Neu', 'ein nichtleerer Wert überschreibt das Konto')
|
|
assert.equal(betrieb.websiteUrl, 'https://haarstudio.example.invalid/', 'die Adresse wird wie bei jeder Einstellung normalisiert')
|
|
assert.equal(betrieb.email, 'k@example.invalid', 'ein leeres Feld lässt den Wert aus dem Konto')
|
|
assert.equal(betrieb.telefon, '06301 1234')
|
|
assert.equal(betrieb.adresse, 'Hauptstr. 1, 67722 Winnweiler')
|
|
assert.notEqual(betrieb.impressumUrl, 'https://boese.example.invalid/impressum', 'nur die fünf Betriebsfelder gelten')
|
|
assert.deepEqual(res.body.fehlt, [])
|
|
})
|
|
|
|
test('Vorlage: Betriebsfelder ohne Konto-Angaben werden aus dem Rumpf gefüllt und "fehlt" schrumpft', async () => {
|
|
await deleteDocument('customers', 'c1')
|
|
aw.seed('customers', customers.c1) // Konto ohne Firma, Anschrift und Telefon
|
|
const leer = await vorlage({ branche: 'salon' })
|
|
assert.equal(leer.status, 201, JSON.stringify(leer.body))
|
|
assert.deepEqual(leer.body.fehlt, FEHLT_BEIDES)
|
|
await deleteDocument('termineConfig', FEATURE)
|
|
const voll = await vorlage({ branche: 'salon', betrieb: { telefon: '0151 2345678', adresse: 'Weg 1, 12345 Ort' } })
|
|
assert.equal(voll.status, 201, JSON.stringify(voll.body))
|
|
assert.deepEqual(voll.body.fehlt, [])
|
|
assert.equal(voll.body.einstellung.betrieb.telefon, '0151 2345678')
|
|
})
|
|
|
|
test('Vorlage: Betriebsangaben in falscher Form => 400, nichts wird angelegt (auch kein Funktionsdokument)', async () => {
|
|
for (const betrieb of ['Salon', 5, ['a'], { name: 5 }, { telefon: {} }, { email: ['x'] }, { websiteUrl: true }]) {
|
|
const res = await vorlage({ branche: 'salon', betrieb })
|
|
assert.equal(res.status, 400, JSON.stringify(betrieb))
|
|
assert.ok(res.body.fehler?.betrieb || Object.keys(res.body.fehler ?? {}).some((k) => k.startsWith('betrieb.')), JSON.stringify(betrieb))
|
|
}
|
|
// eine Angabe, die die Einstellung ablehnt (ungültige Adresse): 400, ohne Dokument entsteht nichts
|
|
const ungueltig = await vorlage({ branche: 'salon', betrieb: { websiteUrl: 'javascript:alert(1)' } })
|
|
assert.equal(ungueltig.status, 400, JSON.stringify(ungueltig.body))
|
|
assert.equal(aw.docs('portalFeatures').size, 0)
|
|
assert.equal(aw.docs('termineConfig').size, 0)
|
|
})
|
|
|
|
test('Vorlage ohne Branche (nur vorlage) bleibt wie in TP1: Einstellung aus der Vorlage, der gespeicherte Fragebogen bleibt unberührt', async () => {
|
|
setzeZustand('active', { stand: null })
|
|
const res = await vorlage({ vorlage: 'kosmetik' })
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
assert.equal(res.body.einstellung.vorlage, 'kosmetik')
|
|
assert.deepEqual(res.body.fragebogen, leerFragebogen())
|
|
assert.equal(termineConfigDoc().fragebogen, undefined, 'ohne Branche wird kein Fragebogen geschrieben')
|
|
// und die Antwort auf eine Branche gewinnt gegenüber einer mitgeschickten vorlage
|
|
await deleteDocument('termineConfig', FEATURE)
|
|
const beides = await vorlage({ branche: 'tattoo', vorlage: 'salon' })
|
|
assert.equal(beides.status, 201)
|
|
assert.equal(beides.body.einstellung.vorlage, 'tattoo')
|
|
assert.equal(beides.body.fragebogen.branche, 'tattoo')
|
|
})
|
|
|
|
test('Vorlage: unbekannte Branche (auch Prototyp-Schlüssel, falsche Typen) => 400 mit Feld branche, nichts angelegt', async () => {
|
|
for (const branche of ['gibtsnicht', '__proto__', 'constructor', 'toString', 'hasOwnProperty', '', 5, true, {}, ['salon']]) {
|
|
const res = await vorlage({ branche })
|
|
assert.equal(res.status, 400, JSON.stringify(branche))
|
|
assert.equal(res.body.fehler.branche, 'Bitte wähle eine Branche aus der Liste.')
|
|
assert.equal(res.body.code, undefined)
|
|
}
|
|
assert.equal(aw.docs('portalFeatures').size, 0)
|
|
assert.equal(aw.docs('termineConfig').size, 0)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Gesundheitsbereich: harter Ausschluss
|
|
|
|
test('Vorlage mit Branche gesundheit => 400 gesundheitsbereich; ohne vorhandenes Dokument wird weder portalFeatures noch termineConfig angelegt', async () => {
|
|
const vorher = dokumente()
|
|
for (const auth of [{ cookie }, kv()]) {
|
|
const res = await vorlage({ branche: 'gesundheit' }, auth)
|
|
assert.equal(res.status, 400, JSON.stringify(res.body))
|
|
assert.equal(res.body.code, 'gesundheitsbereich')
|
|
assert.equal(res.body.error, GESUNDHEIT_TEXT)
|
|
assert.equal(res.body.fehler.branche, GESUNDHEIT_TEXT)
|
|
}
|
|
assert.equal(dokumente(), vorher)
|
|
assert.equal(aw.docs('portalFeatures').size, 0)
|
|
assert.equal(aw.docs('termineConfig').size, 0)
|
|
assert.equal(aw.docs('customerViewLog').size, 0, 'abgelehnt heißt nichts protokolliert')
|
|
})
|
|
|
|
test('Vorlage mit Branche gesundheit bei vorhandenem Entwurf (auch mit ersetzen): 400, der Entwurf und sein Fragebogen bleiben unverändert', async () => {
|
|
standMitEinbau('off')
|
|
const vorher = dokumente()
|
|
for (const extra of [{}, { ersetzen: true, version: 1 }]) {
|
|
const res = await vorlage({ branche: 'gesundheit', ...extra })
|
|
assert.equal(res.status, 400, JSON.stringify(extra))
|
|
assert.equal(res.body.code, 'gesundheitsbereich')
|
|
}
|
|
assert.equal(dokumente(), vorher)
|
|
assert.equal(gespeicherterFragebogen().branche, 'salon')
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zweimal / Zustandsmatrix
|
|
|
|
test('Vorlage zweimal => 409 einstellung_vorhanden, nichts überschrieben (Einstellung und Fragebogen); zwei gleichzeitige Anfragen: genau eine gewinnt', async () => {
|
|
assert.equal((await vorlage({ branche: 'salon' })).status, 201)
|
|
const einstellungVorher = termineConfigDoc().einstellung
|
|
const zweite = await vorlage({ branche: 'tattoo' })
|
|
assert.equal(zweite.status, 409)
|
|
assert.equal(zweite.body.code, 'einstellung_vorhanden')
|
|
assert.equal(zweite.body.error, 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.')
|
|
assert.equal(termineConfigDoc().einstellung, einstellungVorher)
|
|
assert.equal(gespeicherterFragebogen().branche, 'salon')
|
|
assert.equal(termineConfigDoc().version, 1)
|
|
|
|
await deleteDocument('termineConfig', FEATURE)
|
|
const [a, b] = await Promise.all(['salon', 'kosmetik'].map((branche) => vorlage({ branche })))
|
|
assert.deepEqual([a.status, b.status].sort(), [201, 409])
|
|
const gewinner = a.status === 201 ? a : b
|
|
assert.equal(gespeicherterFragebogen().branche, gewinner.body.fragebogen.branche, 'Einstellung und Fragebogen kommen vom selben Gewinner')
|
|
assert.equal(gespeicherteEinstellung().vorlage, gewinner.body.einstellung.vorlage)
|
|
})
|
|
|
|
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'
|
|
test(`Vorlage mit Branche: Zustand ${zustand}, ${zeile} => ${abgeschaltet ? '409 abgeschaltet' : gesperrt ? '409 gesperrt' : '201'}`, async () => {
|
|
setzeZustand(zustand, { stand: null })
|
|
const vorher = dokumente()
|
|
const res = await vorlage({ branche: 'salon' }, auth())
|
|
if (abgeschaltet || gesperrt) {
|
|
assert.equal(res.status, 409, JSON.stringify(res.body))
|
|
assert.equal(res.body.code, abgeschaltet ? 'abgeschaltet' : 'gesperrt')
|
|
if (gesperrt) assert.equal(res.body.error, GESPERRT)
|
|
assert.equal(dokumente(), vorher, 'nichts angelegt, nichts protokolliert')
|
|
return
|
|
}
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
assert.equal(gespeicherterFragebogen().branche, 'salon')
|
|
assert.equal(termineConfigDoc().updatedBy, kundenansicht ? 'team:u_staff2' : 'kunde:c1')
|
|
const feature = aw.docs('portalFeatures').get(FEATURE)
|
|
assert.equal(feature.state, zustand === 'keins' ? 'off' : zustand, 'der Zustand des Funktionsdokuments ändert sich nie')
|
|
assert.deepEqual(logEintraege().map((e) => [e.action, e.detail]), kundenansicht ? [['termin_einstellungen', 'Vorlage salon']] : [])
|
|
})
|
|
}
|
|
}
|
|
|
|
test('Vorlage mit Branche: ohne laufendes Wartungs-Abo 409 kein_abo und nichts angelegt; Abo nicht lesbar => 503, kein Rohtext', async () => {
|
|
await deleteDocument('subscriptions', 'sub_h')
|
|
const vorher = dokumente()
|
|
const ohne = await vorlage({ branche: 'salon' })
|
|
assert.equal(ohne.status, 409)
|
|
assert.equal(ohne.body.code, 'kein_abo')
|
|
assert.equal(ohne.body.error, aboMeldung(T, 'kein_abo'))
|
|
assert.equal(dokumente(), vorher)
|
|
|
|
wartung()
|
|
aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
|
|
const fehler = await vorlage({ branche: 'salon' })
|
|
assert.equal(fehler.status, 503)
|
|
assert.equal(fehler.body.code, 'abo_pruefung')
|
|
assert.ok(!JSON.stringify(fehler.body).includes('geheimer Appwrite-Text'))
|
|
assert.equal(dokumente(), vorher)
|
|
assert.equal((await vorlage({ branche: 'salon' })).status, 201, 'der nächste Versuch geht')
|
|
})
|
|
|
|
test('Vorlage: fremdes Projekt (Kunde c2) => 403, ohne Sitzung 401, ohne projectId 400, Pilot-Schalter 409; nichts angelegt', async () => {
|
|
const vorher = dokumente()
|
|
const fremd = await vorlage({ branche: 'salon' }, { cookie: customerCookie('c2') })
|
|
assert.equal(fremd.status, 403)
|
|
assert.equal(fremd.body.error, 'Kein Zugriff auf dieses Projekt.')
|
|
assert.equal((await send('POST', VORLAGE_URL, { body: { projectId: 'p1', branche: 'salon' } })).status, 401)
|
|
assert.equal((await send('POST', VORLAGE_URL, { cookie, body: { branche: 'salon' } })).status, 400)
|
|
config.termine.pilotEmails = ['andere@example.invalid']
|
|
const gesperrt = await vorlage({ branche: 'salon' })
|
|
assert.equal(gesperrt.status, 409)
|
|
assert.equal(gesperrt.body.code, 'nicht_verfuegbar')
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Vorlage ersetzen (nur Entwurf in off)
|
|
|
|
test('Vorlage ersetzen in off: neue Leistungen, neue IDs und Feed-Token, Fragebogen neu mit der Branche, bestaetigt leer, einbau bleibt', async () => {
|
|
const stand = standMitEinbau('off')
|
|
const alt = gespeicherteEinstellung()
|
|
assert.deepEqual(gespeicherterFragebogen().bestaetigt, [...SCHRITTE])
|
|
assert.equal(gespeicherterFragebogen().preiseGeprueft, true)
|
|
|
|
const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1 })
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
assert.equal(res.body.version, 2)
|
|
assert.equal(res.body.einstellung.vorlage, 'tattoo')
|
|
assert.deepEqual(res.body.einstellung.leistungen.map((l) => l.name), ['Beratungsgespräch', 'Kleines Tattoo', 'Tattoo-Session'])
|
|
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))
|
|
assert.deepEqual(res.body.fragebogen, { ...leerFragebogen(), branche: 'tattoo', einbau: stand.fragebogen.einbau })
|
|
assert.deepEqual(res.body.fragebogen.bestaetigt, [])
|
|
assert.equal(res.body.fragebogen.preiseGeprueft, false)
|
|
assert.equal(res.body.fragebogen.bundeslandGewaehlt, false)
|
|
assert.deepEqual(res.body.fragebogen.einbau, EINBAU, 'die Einbauwünsche bleiben')
|
|
|
|
const neu = gespeicherteEinstellung()
|
|
assert.equal(termineConfigDoc().version, 2)
|
|
assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen, 'gespeichert, was die Antwort nennt')
|
|
assert.ok(!ids(neu).some((id) => ids(alt).includes(id)), 'neue IDs für Personen und Leistungen')
|
|
assert.ok(!feedTokens(neu).some((t) => feedTokens(alt).includes(t)), 'neue Kalender-Token')
|
|
assert.ok(feedTokens(neu).every((t) => t.length === 43))
|
|
assert.equal(termineConfigDoc().updatedBy, 'kunde:c1')
|
|
assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off')
|
|
assert.deepEqual(res.body.fehlt, [])
|
|
})
|
|
|
|
test('Vorlage ersetzen: Einstellung und Fragebogen werden in einem Schreibvorgang gespeichert (genau ein Schreibzugriff auf termineConfig)', async () => {
|
|
standMitEinbau('off')
|
|
// der zweite passende Schreibzugriff scheitert: gäbe es einen zweiten, wäre die Antwort kein 201
|
|
aw.failNext({ collection: 'termineConfig', method: 'PATCH', status: 500, after: 1 })
|
|
const res = await vorlage({ branche: 'kosmetik', ersetzen: true, version: 1 })
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
assert.equal(gespeicherterFragebogen().branche, 'kosmetik')
|
|
assert.equal(gespeicherteEinstellung().vorlage, 'kosmetik')
|
|
// den eingeplanten Fehler verbrauchen: der nächste Schreibzugriff scheitert (500 ohne Rohtext), der Stand bleibt
|
|
const stand = termineConfigDoc()
|
|
const verbraucht = await putFragebogen({ ...res.body.fragebogen, bestaetigt: ['branche'] })
|
|
assert.equal(verbraucht.status, 500)
|
|
assert.deepEqual(termineConfigDoc(), stand)
|
|
})
|
|
|
|
test('Vorlage ersetzen mit vorlage statt branche: der Fragebogen nennt den Schlüssel der Vorlage als Branche; betrieb überschreibt', async () => {
|
|
standMitEinbau('off')
|
|
const res = await vorlage({ vorlage: 'fotograf', ersetzen: true, version: 1, betrieb: { name: 'Foto Neu' } })
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
assert.equal(res.body.fragebogen.branche, 'fotograf')
|
|
assert.equal(res.body.einstellung.betrieb.name, 'Foto Neu')
|
|
assert.deepEqual(res.body.fragebogen.einbau, EINBAU)
|
|
})
|
|
|
|
test('Vorlage ersetzen ohne gültige version => 400, mit falscher version => 409 EINSTELLUNG_VERALTET; nichts geändert', async () => {
|
|
standMitEinbau('off')
|
|
const vorher = dokumente()
|
|
for (const version of [undefined, null, '1', 1.5, -1, true, {}]) {
|
|
const res = await vorlage({ branche: 'tattoo', ersetzen: true, ...(version === undefined ? {} : { version }) })
|
|
assert.equal(res.status, 400, String(version))
|
|
assert.ok(res.body.fehler.version, String(version))
|
|
}
|
|
for (const version of [0, 2, 9]) {
|
|
const res = await vorlage({ branche: 'tattoo', ersetzen: true, version })
|
|
assert.equal(res.status, 409, String(version))
|
|
assert.equal(res.body.code, 'EINSTELLUNG_VERALTET')
|
|
}
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
test('Vorlage mit Einstellung ohne ersetzen (auch mit version) => 409 einstellung_vorhanden; ersetzen nur mit true', async () => {
|
|
standMitEinbau('off')
|
|
const vorher = dokumente()
|
|
for (const extra of [{}, { version: 1 }, { ersetzen: false, version: 1 }, { ersetzen: 'true', version: 1 }, { ersetzen: 1, version: 1 }]) {
|
|
const res = await vorlage({ branche: 'tattoo', ...extra })
|
|
assert.equal(res.status, 409, JSON.stringify(extra))
|
|
assert.equal(res.body.code, 'einstellung_vorhanden', JSON.stringify(extra))
|
|
}
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
for (const zustand of ['active', 'ordered', 'setup']) {
|
|
test(`Vorlage ersetzen in ${zustand} => 409 nicht_ersetzbar (auch mit richtiger version), Dokumente unverändert`, async () => {
|
|
standMitEinbau(zustand)
|
|
const vorher = dokumente()
|
|
// ordered und setup schreibt nur die Kundenansicht; dort gilt dieselbe Regel
|
|
for (const auth of zustand === 'active' ? [{ cookie }, kv()] : [kv()]) {
|
|
const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1 }, auth)
|
|
assert.equal(res.status, 409, JSON.stringify(res.body))
|
|
assert.equal(res.body.code, 'nicht_ersetzbar')
|
|
assert.equal(res.body.error, 'Die Vorlage kannst du nur wechseln, solange du den Terminplaner noch nicht bestellt hast.')
|
|
}
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
}
|
|
|
|
test('Vorlage ersetzen in disabled => 409 abgeschaltet, Dokumente unverändert', async () => {
|
|
standMitEinbau('disabled')
|
|
const vorher = dokumente()
|
|
const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1 })
|
|
assert.equal(res.status, 409)
|
|
assert.equal(res.body.code, 'abgeschaltet')
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
test('Vorlage ersetzen in der Kundenansicht (off): erlaubt, protokolliert ohne Betriebsdaten, updatedBy ist der Mitarbeiter', async () => {
|
|
standMitEinbau('off')
|
|
const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1, betrieb: { name: 'Geheimer Betrieb' } }, kv())
|
|
assert.equal(res.status, 201, JSON.stringify(res.body))
|
|
assert.equal(termineConfigDoc().updatedBy, 'team:u_staff2')
|
|
assert.deepEqual(logEintraege().map((e) => [e.action, e.detail]), [['termin_einstellungen', 'Vorlage tattoo ersetzt']])
|
|
assert.ok(!JSON.stringify(logEintraege()).includes('Geheimer Betrieb'))
|
|
})
|
|
|
|
test('Vorlage ersetzen bei unlesbarer Einstellung (off): mit der Version des Dokuments geht es, ohne ersetzen bleibt es 409', async () => {
|
|
aw.seed('portalFeatures', featureDoc('off'))
|
|
aw.seed('termineConfig', { $id: FEATURE, customerId: 'c1', projectId: 'p1', einstellung: '{kaputt', version: 3 })
|
|
const ohne = await vorlage({ branche: 'salon' })
|
|
assert.equal(ohne.status, 409)
|
|
assert.equal(ohne.body.code, 'einstellung_vorhanden')
|
|
assert.equal(termineConfigDoc().einstellung, '{kaputt')
|
|
const mit = await vorlage({ branche: 'salon', ersetzen: true, version: 3 })
|
|
assert.equal(mit.status, 201, JSON.stringify(mit.body))
|
|
assert.equal(mit.body.version, 4)
|
|
assert.equal(gespeicherteEinstellung().vorlage, 'salon')
|
|
assert.equal(gespeicherterFragebogen().branche, 'salon')
|
|
})
|
|
|
|
// ---------------------------------------------------------------- PUT /fragebogen
|
|
|
|
const beispielFragebogen = (over = {}) => ({
|
|
...leerFragebogen(),
|
|
branche: 'salon',
|
|
bestaetigt: ['branche', 'wer'],
|
|
bundeslandGewaehlt: true,
|
|
einbau: { modus: 'inline', knopfText: '', stelle: 'Auf der Startseite', gestaltung: '', bemerkung: '' },
|
|
...over,
|
|
})
|
|
|
|
test('PUT fragebogen speichert nur den Fragebogen: version und einstellung der Einstellung bleiben, updatedAt/updatedBy ändern sich, Antwort { fragebogen, offen }', async () => {
|
|
setzeZustand('off')
|
|
const vorher = termineConfigDoc()
|
|
const res = await putFragebogen(beispielFragebogen())
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
assert.deepEqual(Object.keys(res.body).sort(), ['fragebogen', 'offen'])
|
|
assert.deepEqual(res.body.fragebogen, beispielFragebogen())
|
|
assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen())
|
|
const danach = termineConfigDoc()
|
|
assert.equal(danach.version, vorher.version, 'die Version der Einstellung bleibt')
|
|
assert.equal(danach.einstellung, vorher.einstellung, 'die Einstellung bleibt byte-gleich')
|
|
assert.equal(danach.updatedBy, 'kunde:c1')
|
|
assert.equal(new Date(danach.updatedAt).toISOString(), JETZT.toISOString(), 'updatedAt ist die Uhrzeit der Anfrage')
|
|
assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off')
|
|
assert.equal(logEintraege().length, 0, 'kein Eintrag im Protokoll der Kundenansicht')
|
|
// GET liest den neuen Stand
|
|
assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.fragebogen, beispielFragebogen())
|
|
})
|
|
|
|
test('PUT fragebogen: bereinigt die Eingabe wie normalisiereFragebogen (Reihenfolge, Doppelte, Fremdes, Steuerzeichen) und ersetzt den ganzen Fragebogen', async () => {
|
|
setzeZustand('off')
|
|
const res = await putFragebogen({
|
|
branche: 'salon',
|
|
bestaetigt: ['website', 'branche', 'branche', 'unbekannt'],
|
|
einbau: { modus: 'knopf', knopfText: 'Jetzt\u0000 buchen', stelle: 'x', ignoriert: 'y' },
|
|
boese: true,
|
|
})
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
assert.deepEqual(res.body.fragebogen.bestaetigt, ['branche', 'website'])
|
|
assert.equal(res.body.fragebogen.einbau.knopfText, 'Jetzt buchen')
|
|
assert.ok(!('boese' in res.body.fragebogen) && !('ignoriert' in res.body.fragebogen.einbau))
|
|
assert.equal(res.body.fragebogen.preiseGeprueft, false, 'was fehlt, ist leer (PUT ersetzt, ergänzt nicht)')
|
|
assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen)
|
|
})
|
|
|
|
test('PUT fragebogen: ein offener Editor mit der alten version kann danach weiter per PATCH speichern (kein 409), auch zwei Tabs', async () => {
|
|
const stand = setzeZustand('off')
|
|
const geladen = (await send('GET', einstellungenUrl(), { cookie })).body // Tab A und Tab B haben version 1
|
|
assert.equal(geladen.version, 1)
|
|
for (let i = 0; i < 3; i += 1) assert.equal((await putFragebogen(beispielFragebogen({ bestaetigt: SCHRITTE.slice(0, i + 1) }))).status, 200)
|
|
assert.equal(termineConfigDoc().version, 1)
|
|
const tabA = await patch({ version: geladen.version, einstellung: neueEinstellung(stand) })
|
|
assert.equal(tabA.status, 200, JSON.stringify(tabA.body))
|
|
assert.equal(tabA.body.version, 2)
|
|
assert.equal((await putFragebogen(beispielFragebogen({ bestaetigt: [...SCHRITTE] }))).status, 200, 'Tab B bestätigt weiter')
|
|
const tabB = await patch({ version: 2, einstellung: neueEinstellung(stand, (e) => ({ ...e, betrieb: { ...e.betrieb, name: 'Salon B' } })) })
|
|
assert.equal(tabB.status, 200, JSON.stringify(tabB.body))
|
|
assert.equal(gespeicherteEinstellung().betrieb.name, 'Salon B')
|
|
assert.deepEqual(gespeicherterFragebogen().bestaetigt, [...SCHRITTE], 'der PATCH hat den Fragebogen nicht angefasst')
|
|
})
|
|
|
|
test('PUT fragebogen mit ungültigem Modus => 400 mit fehler[einbau.modus]; ungültige Formen => 400; gespeichert wird nichts', async () => {
|
|
setzeZustand('off')
|
|
const vorher = dokumente()
|
|
const modus = await putFragebogen(beispielFragebogen({ einbau: { modus: 'popup' } }))
|
|
assert.equal(modus.status, 400, JSON.stringify(modus.body))
|
|
assert.equal(modus.body.fehler['einbau.modus'], 'Bitte wähle, ob der Terminplaner im Text oder als Knopf erscheint.')
|
|
assert.equal(modus.body.error, modus.body.fehler['einbau.modus'])
|
|
for (const eingabe of [undefined, null, 'x', 5, [], { branche: 'gibtsnicht' }, { bestaetigt: 'branche' }, { preiseGeprueft: 'ja' }]) {
|
|
const res = await send('PUT', FRAGEBOGEN_URL, { cookie, body: { projectId: 'p1', ...(eingabe === undefined ? {} : { fragebogen: eingabe }) } })
|
|
assert.equal(res.status, 400, JSON.stringify(eingabe))
|
|
assert.ok(res.body.fehler && Object.keys(res.body.fehler).length > 0, JSON.stringify(eingabe))
|
|
}
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
test('PUT fragebogen ohne Einstellung => 409 keine_einstellung, nichts angelegt (kein Funktionsdokument, kein termineConfig)', async () => {
|
|
// kein Funktionsdokument
|
|
const vorher = dokumente()
|
|
const ohneDokument = await putFragebogen(beispielFragebogen())
|
|
assert.equal(ohneDokument.status, 409)
|
|
assert.equal(ohneDokument.body.code, 'keine_einstellung')
|
|
assert.equal(dokumente(), vorher)
|
|
assert.equal(aw.docs('portalFeatures').size, 0)
|
|
// Funktionsdokument ohne termineConfig
|
|
setzeZustand('off', { stand: null })
|
|
const ohneKonfig = await putFragebogen(beispielFragebogen())
|
|
assert.equal(ohneKonfig.status, 409)
|
|
assert.equal(ohneKonfig.body.code, 'keine_einstellung')
|
|
assert.equal(aw.docs('termineConfig').size, 0)
|
|
})
|
|
|
|
for (const zustand of ZUSTAENDE) {
|
|
const erwartet = { keins: '409 keine_einstellung', off: '200', ordered: '409 gesperrt', setup: '409 gesperrt', active: '200', disabled: '409 abgeschaltet' }[zustand]
|
|
test(`PUT fragebogen: Zustand ${zustand} => ${erwartet}`, async () => {
|
|
setzeZustand(zustand)
|
|
const vorher = dokumente()
|
|
const res = await putFragebogen(beispielFragebogen())
|
|
if (erwartet === '200') {
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen())
|
|
assert.equal(aw.docs('portalFeatures').get(FEATURE).state, zustand)
|
|
return
|
|
}
|
|
assert.equal(res.status, 409, JSON.stringify(res.body))
|
|
assert.equal(res.body.code, erwartet.split(' ')[1])
|
|
if (zustand === 'ordered' || zustand === 'setup') assert.equal(res.body.error, GESPERRT)
|
|
if (zustand === 'disabled') assert.match(res.body.error, /Wartungs-Abo beendet/)
|
|
assert.equal(dokumente(), vorher, 'nichts geändert')
|
|
})
|
|
}
|
|
|
|
test('PUT fragebogen: in off nur mit laufendem Wartungs-Abo (409 kein_abo, 503 bei Lesefehler); in active ohne Abo-Abfrage', async () => {
|
|
setzeZustand('off')
|
|
await deleteDocument('subscriptions', 'sub_h')
|
|
const vorher = dokumente()
|
|
const ohne = await putFragebogen(beispielFragebogen())
|
|
assert.equal(ohne.status, 409)
|
|
assert.equal(ohne.body.code, 'kein_abo')
|
|
assert.equal(ohne.body.error, aboMeldung(T, 'kein_abo'))
|
|
assert.equal(dokumente(), vorher)
|
|
|
|
wartung()
|
|
aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
|
|
const fehler = await putFragebogen(beispielFragebogen())
|
|
assert.equal(fehler.status, 503)
|
|
assert.equal(fehler.body.code, 'abo_pruefung')
|
|
assert.ok(!JSON.stringify(fehler.body).includes('geheimer Appwrite-Text'))
|
|
assert.equal(dokumente(), vorher)
|
|
|
|
await deleteDocument('subscriptions', 'sub_h')
|
|
await deleteDocument('portalFeatures', FEATURE)
|
|
await deleteDocument('termineConfig', FEATURE)
|
|
setzeZustand('active')
|
|
aw.resetListCalls()
|
|
assert.equal((await putFragebogen(beispielFragebogen())).status, 200, 'active läuft auch ohne Abo weiter (Abgleich: Block E)')
|
|
assert.deepEqual(aw.listCalls('subscriptions'), [])
|
|
})
|
|
|
|
test('PUT fragebogen: offen ist die Bestellprüfung des gespeicherten Stands (nur in off), null in active', async () => {
|
|
const stand = setzeZustand('off')
|
|
const unvollstaendig = await putFragebogen(beispielFragebogen())
|
|
assert.equal(unvollstaendig.status, 200)
|
|
const erwartet = bestellpruefung({ einstellung: stand.einstellung, fragebogen: unvollstaendig.body.fragebogen, feature: T, abo: { ok: true } })
|
|
assert.ok(erwartet.length > 0)
|
|
assert.deepEqual(unvollstaendig.body.offen, erwartet, 'dieselbe Liste wie im GET')
|
|
assert.ok(unvollstaendig.body.offen.some((p) => p.schritt === 'leistungen'), 'noch nicht bestätigte Schritte stehen drin')
|
|
|
|
const vollstaendig = await putFragebogen({ ...stand.fragebogen })
|
|
assert.equal(vollstaendig.status, 200)
|
|
assert.deepEqual(vollstaendig.body.offen, [], 'alles bestätigt und bestellbar')
|
|
assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.offen, [])
|
|
|
|
await deleteDocument('portalFeatures', FEATURE)
|
|
await deleteDocument('termineConfig', FEATURE)
|
|
setzeZustand('active')
|
|
assert.equal((await putFragebogen(beispielFragebogen())).body.offen, null)
|
|
})
|
|
|
|
test('PUT fragebogen: Branche gesundheit lässt sich speichern, das Gate (offen) blockt sie aber', async () => {
|
|
setzeZustand('off')
|
|
const res = await putFragebogen({ ...bestellbarerStand({ now: JETZT }).fragebogen, branche: 'gesundheit' })
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
assert.ok(res.body.offen.some((p) => p.schritt === 'branche'))
|
|
})
|
|
|
|
// ---------------------------------------------------------------- PUT: Kundenansicht
|
|
|
|
for (const zustand of ZUSTAENDE) {
|
|
test(`PUT fragebogen in der Kundenansicht (${zustand}) => 403 view_blocked über den Wächter; nichts geändert, nichts protokolliert`, async () => {
|
|
setzeZustand(zustand)
|
|
const vorher = dokumente()
|
|
const res = await putFragebogen(beispielFragebogen(), kv())
|
|
assert.equal(res.status, 403, JSON.stringify(res.body))
|
|
assert.equal(res.body.code, 'view_blocked')
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
test(`PUT fragebogen in der Kundenansicht (${zustand}) => 403 mit Text direkt am Router (ohne Wächter); vor jeder Zustandsprüfung, nichts geändert`, async () => {
|
|
setzeZustand(zustand)
|
|
const vorher = dokumente()
|
|
const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie: kundenansichtCookie(), body: { projectId: 'p1', fragebogen: beispielFragebogen() } })
|
|
assert.equal(res.status, 403, JSON.stringify(res.body))
|
|
assert.equal(res.body.error, KUNDENANSICHT_TEXT)
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
}
|
|
|
|
test('PUT fragebogen am Router: dieselbe Anfrage mit der Sitzung des Kunden selbst geht durch (der Router sperrt nur die Kundenansicht)', async () => {
|
|
setzeZustand('off')
|
|
const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie, body: { projectId: 'p1', fragebogen: beispielFragebogen() } })
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen())
|
|
})
|
|
|
|
test('PUT fragebogen am Router: auch eine alte Ansicht (viewAs ohne viewId) und die Ansicht eines anderen Kunden schreiben nichts', async () => {
|
|
setzeZustand('off')
|
|
const vorher = dokumente()
|
|
for (const c of [viewAsCookie('c1'), kundenansichtCookie({ customerId: 'c2' })]) {
|
|
const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie: c, body: { projectId: 'p1', fragebogen: beispielFragebogen() } })
|
|
assert.ok([401, 403].includes(res.status), String(res.status))
|
|
}
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- PUT: Mandanten und Zugang
|
|
|
|
test('PUT fragebogen: Kunde c2 mit dem Projekt von c1 => 403 in jedem Zustand; nichts angelegt, nichts geändert', async () => {
|
|
for (const zustand of ZUSTAENDE) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
const vorher = dokumente()
|
|
const res = await putFragebogen(beispielFragebogen(), { cookie: customerCookie('c2') })
|
|
assert.equal(res.status, 403, zustand)
|
|
assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.')
|
|
assert.equal(dokumente(), vorher, zustand)
|
|
}
|
|
})
|
|
|
|
test('PUT fragebogen: ohne Sitzung 401, ohne projectId 400, unbrauchbare projectId 403, Pilot-Schalter 409', async () => {
|
|
setzeZustand('off')
|
|
const vorher = dokumente()
|
|
assert.equal((await send('PUT', FRAGEBOGEN_URL, { body: { projectId: 'p1', fragebogen: beispielFragebogen() } })).status, 401)
|
|
assert.equal((await send('PUT', FRAGEBOGEN_URL, { cookie, body: { fragebogen: beispielFragebogen() } })).status, 400)
|
|
assert.equal((await putFragebogen(beispielFragebogen(), { cookie }, { projectId: 'gibt es nicht' })).status, 403)
|
|
config.termine.pilotEmails = ['andere@example.invalid']
|
|
const gesperrt = await putFragebogen(beispielFragebogen())
|
|
assert.equal(gesperrt.status, 409)
|
|
assert.equal(gesperrt.body.code, 'nicht_verfuegbar')
|
|
config.termine.pilotEmails = ['*']
|
|
config.termine.mode = 'off'
|
|
assert.equal((await putFragebogen(beispielFragebogen())).status, 409)
|
|
assert.equal(dokumente(), vorher)
|
|
})
|
|
|
|
test('PUT fragebogen und Vorlage: die Antworten sind nie zwischenspeicherbar', async () => {
|
|
setzeZustand('off')
|
|
const res = await putFragebogen(beispielFragebogen())
|
|
assert.equal(res.headers.get('cache-control'), 'no-store')
|
|
assert.equal((await vorlage({ branche: 'gesundheit' })).headers.get('cache-control'), 'no-store')
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Schreibbudget
|
|
|
|
test('Schreibbudget: ein kompletter Durchlauf (1 Vorlage, 8 PATCH, 8 PUT, 5 Editor-Speicherungen) bleibt unter 120 je Stunde, der 121. Aufruf ist 429', async () => {
|
|
const vorlageErgebnis = await vorlage({ branche: 'salon' })
|
|
assert.equal(vorlageErgebnis.status, 201, JSON.stringify(vorlageErgebnis.body))
|
|
let version = vorlageErgebnis.body.version
|
|
let einstellung = vorlageErgebnis.body.einstellung
|
|
let aufrufe = 1
|
|
|
|
const speichern = async (name) => {
|
|
einstellung = { ...einstellung, betrieb: { ...einstellung.betrieb, name } }
|
|
const res = await patch({ version, einstellung })
|
|
aufrufe += 1
|
|
assert.equal(res.status, 200, `Aufruf ${aufrufe}: ${JSON.stringify(res.body)}`)
|
|
version = res.body.version
|
|
einstellung = res.body.einstellung
|
|
}
|
|
for (let i = 0; i < 8; i += 1) await speichern(`Salon Schritt ${i + 1}`) // je Schritt ein PATCH ...
|
|
for (let i = 0; i < 8; i += 1) { // ... und ein PUT mit dem Haken
|
|
const res = await putFragebogen(beispielFragebogen({ bestaetigt: SCHRITTE.slice(0, i + 1) }))
|
|
aufrufe += 1
|
|
assert.equal(res.status, 200, `Aufruf ${aufrufe}: ${JSON.stringify(res.body)}`)
|
|
}
|
|
for (let i = 0; i < 5; i += 1) await speichern(`Salon Editor ${i + 1}`) // fünf Speicherungen im Editor
|
|
assert.equal(aufrufe, 22)
|
|
|
|
// bis 120 Aufrufe geht alles durch ...
|
|
while (aufrufe < 120) {
|
|
const res = await putFragebogen(beispielFragebogen())
|
|
aufrufe += 1
|
|
assert.equal(res.status, 200, `Aufruf ${aufrufe}`)
|
|
}
|
|
// ... der 121. ist 429, gleich welche Schreib-Route (PUT, PATCH, Vorlage teilen sich das Budget des Kunden)
|
|
const vorher = dokumente()
|
|
for (const res of [
|
|
await putFragebogen(beispielFragebogen()),
|
|
await patch({ version, einstellung }),
|
|
await vorlage({ branche: 'tattoo', ersetzen: true, version }),
|
|
]) {
|
|
assert.equal(res.status, 429)
|
|
assert.equal(res.body.error, 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.')
|
|
}
|
|
assert.equal(dokumente(), vorher, 'abgewiesen heißt nichts geschrieben')
|
|
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Lesen bleibt frei')
|
|
// eine Stunde später ist das Budget wieder da
|
|
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 61 * 60 * 1000))
|
|
assert.equal((await putFragebogen(beispielFragebogen())).status, 200)
|
|
})
|