Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen: off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id> und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1, die Adresse wird einmal gelesen und bereinigt. R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf, solange die Bestellung offen ist). Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und Status der Einstellungen erst ab der Freischaltung. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1658 lines
89 KiB
JavaScript
1658 lines
89 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 gibt es nur in off; offen und bestellung nur in off und ordered (R110); in setup, active und disabled null', async () => {
|
|
for (const zustand of ['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)
|
|
}
|
|
for (const zustand of ['off', 'ordered']) {
|
|
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
|
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
|
setzeZustand(zustand)
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.notEqual(body.offen, null, zustand)
|
|
assert.notEqual(body.bestellung, null, zustand)
|
|
assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`)
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------- ordered: bestellung und offen nur lesend (R110)
|
|
// "Zahlung fortsetzen" ohne Nachweis (409 zustimmung_fehlt) zeigt im Zustand ordered wieder das Bestellformular; dafuer braucht der
|
|
// Client bestellung und offen auch dort. Das Abo gehoert nicht dazu: es wird in ordered nicht gelesen, offen hat keinen Abo-Punkt (h).
|
|
|
|
test('ordered: bestellung ist die Sicht des Registers wie in off, offen die Bestellprüfung ohne Abo-Punkt, abo null, nichts wird geschrieben', async () => {
|
|
const stand = setzeZustand('ordered')
|
|
aw.resetListCalls()
|
|
const vorher = groessen()
|
|
const { status, body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(status, 200)
|
|
assert.equal(body.state, 'ordered')
|
|
assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T))))
|
|
assert.equal(body.bestellung.consent.knopf, T.consent.knopf)
|
|
assert.deepEqual(body.offen, [], 'bestellbarer Entwurf: keine offenen Punkte, auch ohne Abo-Punkt')
|
|
assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, mitAbo: false }))
|
|
assert.equal(body.abo, null)
|
|
assert.deepEqual(aw.listCalls('subscriptions'), [], 'in ordered wird das Abo nicht gelesen')
|
|
assert.deepEqual(groessen(), vorher, 'read-only: nichts angelegt, nichts protokolliert')
|
|
})
|
|
|
|
test('ordered: ein unvollständiger Entwurf zeigt seine offenen Punkte (nie den Abo-Punkt), ohne Einstellung den Punkt „Branche“', async () => {
|
|
setzeZustand('ordered', { stand: { ...bestellbarerStand({ now: JETZT }), fragebogen: leerFragebogen() } })
|
|
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.ok(body.offen.length > 0)
|
|
assert.ok(body.offen.some((p) => p.schritt === 'leistungen'))
|
|
assert.ok(body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Abo-Punkt (h)')
|
|
await deleteDocument('termineConfig', FEATURE)
|
|
const ohne = await send('GET', einstellungenUrl(), { cookie })
|
|
assert.equal(ohne.status, 200)
|
|
assert.deepEqual(ohne.body.offen, [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }])
|
|
assert.notEqual(ohne.body.bestellung, null)
|
|
})
|
|
|
|
test('ordered in der Kundenansicht: bestellung und offen ebenfalls da, kundenansicht true', async () => {
|
|
setzeZustand('ordered')
|
|
const { status, body } = await send('GET', einstellungenUrl(), kv())
|
|
assert.equal(status, 200)
|
|
assert.equal(body.kundenansicht, true)
|
|
assert.notEqual(body.bestellung, null)
|
|
assert.deepEqual(body.offen, [])
|
|
})
|
|
|
|
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)
|
|
})
|