diff --git a/server/routes/termine.js b/server/routes/termine.js index 4376bfe..27abf80 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -1,8 +1,11 @@ import { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { aboMemo } from '../services/aboStatus.js' +import { analyseHosts } from '../services/analyse.js' import { getDocument } from '../services/appwriteAdmin.js' import { analysePrivacyData } from '../services/analysePrivacy.js' +import { featureConfig } from '../services/featureRegistry.js' import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' import { createWriteLimiter } from '../services/writeLimiter.js' @@ -11,7 +14,8 @@ import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBe import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' import { loeschTag, loeschzeit } from '../services/termine/frist.js' -import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js' +import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } from '../services/termine/fragebogen.js' +import { kontextFuerPortal, kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js' import { zustellFehler } from '../services/termine/outbox.js' import { termineFuerKunde } from '../services/termine/schalter.js' import { belegungAus, freieZeiten } from '../services/termine/slots.js' @@ -24,6 +28,8 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine * * Jede Route: Session, projectId (Query oder Body), eigenes Projekt, Pilot-Schalter des Kunden, Funktion `active` oder * `disabled`. Abgeschaltete Funktionen (`disabled`) dürfen nur gelesen werden (GET, auch der Export); jedes Schreiben ist 409. + * Ausnahme Entwurf (TP2): GET /einstellungen liest in jedem Zustand (off, ordered, setup, active, disabled) und auch ohne + * Funktionsdokument (virtueller Kontext `off`, nichts wird angelegt), siehe withTermine({ entwurf: true }). * Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in * services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion, * termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail). @@ -155,8 +161,14 @@ const projectIdOf = (req) => { /** * Eigenes Projekt, Pilot-Schalter, Funktion active oder disabled => req.ctx (kontext.js). schreiben: nur active. * Reihenfolge der Prüfungen: Projekt (403), Schalter (409), Funktion (409), abgeschaltet beim Schreiben (409). + * + * entwurf (TP2, nur GET /einstellungen): Lesen in jedem Zustand des Funktionsdokuments (off, ordered, setup, active, + * disabled) über kontextFuerPortal. Fehlt das Dokument (oder gehört es einem anderen Kunden), ist der Kontext ein virtueller + * `off` ohne Einstellung, Version 0 und leeren Fragebogen; dafür wird nichts gelesen und nie etwas angelegt. Projekt (403) + * und Schalter (409) gelten wie sonst. Die Schreibregeln des Entwurfs folgen mit C4b: bis dahin behält jede Route mit + * `schreiben` die Schranke active, auch wenn `entwurf` gesetzt ist. */ -const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next) => { +const withTermine = ({ schreiben = false, entwurf = false } = {}) => route(async (req, _res, next) => { const projectId = projectIdOf(req) if (!projectId) throw stop(400, MELDUNG.projekt, undefined, 'projectId') const customerId = getSessionCustomerId(req) @@ -169,6 +181,10 @@ const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next req.projectId = projectId if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') const feature = await getFeatureDoc(customerId, projectId, 'termine') + if (entwurf && !schreiben) { + req.ctx = feature ? await kontextFuerPortal(feature) : virtuellerKontext(customerId, projectId, req.project) + return next() + } const ctx = feature && ['active', 'disabled'].includes(feature.state) ? await kontextPerFeature(feature.$id) : null if (!ctx || ctx.feature.customerId !== customerId) throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv') if (schreiben && ctx.feature.state !== 'active') throw stop(409, abgeschaltetMeldung(ctx.feature), 'abgeschaltet') @@ -176,6 +192,15 @@ const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next return next() }) +/** Kontext ohne Funktionsdokument: Zustand `off`, nichts gespeichert (ohne $id: das Dokument gibt es nicht und wird hier nie angelegt). */ +const virtuellerKontext = (customerId, projectId, project) => ({ + feature: { customerId, projectId, featureKey: 'termine', state: 'off', enabled: false }, + project, + einstellung: null, + version: 0, + fragebogen: leerFragebogen(), +}) + /** Wer ändert? Für updatedBy der Einstellung (Kundenansicht: der Mitarbeiter, sonst der Kunde). */ const von = (req) => (isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : `kunde:${getSessionCustomerId(req)}`) @@ -220,20 +245,50 @@ router.use((_req, res, next) => { next() }) -router.get('/einstellungen', requireSession, withTermine(), route(async (req, res) => { +/** Das Abo für die Bestell-Vorschau: { ok, grund? } oder null, wenn es nicht lesbar war (dann gilt der Abo-Punkt "nicht geprüft"). Nie ein Fehler nach außen. */ +async function aboFuerVorschau(customerId) { + try { + return await aboMemo().bestellbar(customerId) + } catch (err) { + // nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten) + console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + return null + } +} + +/** + * Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true). + * Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag + * ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der + * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links + * gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active). + */ +router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => { const { ctx } = req const e = ctx.einstellung + const state = ctx.feature.state + const laeuft = state === 'active' || state === 'disabled' + const customerId = getSessionCustomerId(req) + const register = featureConfig('termine') + const abo = state === 'off' ? await aboFuerVorschau(customerId) : null res.json({ - state: ctx.feature.state, - abgeschaltet: ctx.feature.state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null, + state, + abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null, aktivSeit: ctx.feature.activatedAt || null, einstellung: e ? fuerKunde(e) : null, version: ctx.version, vorlagen: vorlagenListe(), - feeds: e && ctx.feature.state === 'active' ? feedLinks(ctx) : {}, + branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })), + fragebogen: ctx.fragebogen, + kundenansicht: isKundenansicht(req.session), + ...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }), + abo, + offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null, + bestellung: state === 'off' ? await bestellungAnsicht(register) : null, + feeds: e && state === 'active' ? feedLinks(ctx) : {}, siteKey: ctx.feature.siteKey || null, - einbau: { snippet: einbauSnippet(ctx) }, - anfragenOffen: await zaehleAnfragen(ctx.feature.$id), + einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {}, + anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0, fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar }) })) @@ -250,6 +305,20 @@ async function betriebAusKonto(customerId) { return { name: daten.company, adresse: daten.address, email: daten.email, telefon: typeof kunde?.phone === 'string' ? kunde.phone : '' } } +/** Adresse der Live-Website des Projekts (nur aus liveDomain, nie die Vorschau-Adresse .project.webklar.com), sonst ''. */ +function liveWebsite(project) { + if (!project?.liveDomain) return '' + const vorschau = project.subdomain ? `${String(project.subdomain).trim().toLowerCase()}.project.webklar.com` : null + const host = analyseHosts(project).find((h) => h !== vorschau) + return host ? `https://${host}` : '' +} + +/** Vorschlag für die Betriebsangaben des Fragebogens, solange es noch keine Einstellung gibt: Konto (betriebAusKonto) und Live-Website. */ +async function betriebVorschlag(customerId, project) { + const { name, adresse, email, telefon } = await betriebAusKonto(customerId) + return { name, adresse, email, telefon, websiteUrl: liveWebsite(project) } +} + router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { const key = req.body?.vorlage if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage') diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js new file mode 100644 index 0000000..db88525 --- /dev/null +++ b/test/termineFragebogenRoutes.test.js @@ -0,0 +1,516 @@ +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; die +// Schreibregeln des Entwurfs (PATCH, Vorlage, PUT fragebogen) folgen in Teil 1b (C4b). +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, seedFixtures, subscriptionDoc, wartungDoc, customers } = await import('./support/fixtures.js') +const { bestellbarerStand, entwurfDoc } = await import('./support/fragebogenStand.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { default: termineRoutes, setzeUhrFuerTests, resetTermineRoutesForTests } = await import('../server/routes/termine.js') +const { resetKontextForTests } = await import('../server/services/termine/kontext.js') +const { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } = await import('../server/services/termine/fragebogen.js') +const { aboMeldung } = await import('../server/services/featureOrder.js') + +const app = await startTestApp({ + '/': kundenansichtGuard, + '/api/termine': termineRoutes, +}) +after(() => Promise.all([app.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 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() + setzeUhrFuerTests(() => JETZT) + config.termine.mode = 'pilot' + config.termine.pilotEmails = ['*'] + 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] +}) + +// ---------------------------------------------------------------- Zustandsmatrix: GET liest in jedem Zustand + +for (const zustand of ZUSTAENDE) { + for (const [zeile, auth] of ZEILEN) { + test(`GET einstellungen: Zustand ${zustand}, ${zeile} => 200 mit Zustand, Entwurf und Fragebogen; nichts wird angelegt`, async () => { + const stand = setzeZustand(zustand) + const vorher = groessen() + const res = await send('GET', einstellungenUrl(), auth()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const b = res.body + assert.equal(b.state, zustand === 'keins' ? 'off' : zustand) + assert.equal(b.kundenansicht, zeile === 'Kundenansicht') + if (stand) { + assert.equal(b.version, 1) + assert.equal(b.einstellung.betrieb.name, stand.einstellung.betrieb.name) + assert.equal(b.fragebogen.branche, 'salon', 'der gespeicherte Fragebogen kommt mit') + assert.deepEqual(b.fragebogen.bestaetigt, stand.fragebogen.bestaetigt) + } else { + assert.equal(b.einstellung, null) + assert.equal(b.version, 0) + assert.deepEqual(b.fragebogen, leerFragebogen()) + } + assert.ok(Array.isArray(b.vorlagen) && b.vorlagen.length === 9, 'die Vorlagen kommen in jedem Zustand') + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.deepEqual(groessen(), vorher, 'Lesen legt nie ein Dokument an, ändert keins und protokolliert nichts') + }) + } +} + +test('GET einstellungen ohne Funktionsdokument: 200 { state: off, einstellung: null, version: 0 }, portalFeatures und termineConfig bleiben leer', async () => { + const vorher = groessen() + const res = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(res.status, 200) + assert.equal(res.body.state, 'off') + assert.equal(res.body.einstellung, null) + assert.equal(res.body.version, 0) + assert.equal(res.body.siteKey, null, 'noch kein Dokument, also noch kein siteKey') + assert.equal(res.body.aktivSeit, null) + assert.equal(res.body.abgeschaltet, null) + assert.equal(res.body.anfragenOffen, 0) + assert.deepEqual(res.body.fehlt, []) + assert.deepEqual(res.body.feeds, {}) + assert.equal(aw.docs('portalFeatures').size, vorher.portalFeatures) + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) +}) + +test('GET einstellungen: ein termineConfig-Dokument ohne Funktionsdokument wird nie gelesen (kein Dokument heißt leerer Entwurf)', async () => { + aw.seed('termineConfig', entwurfDoc(FEATURE, { customerId: 'c1', projectId: 'p1' })) + const res = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(res.status, 200) + assert.equal(res.body.einstellung, null) + assert.equal(res.body.version, 0) + assert.deepEqual(res.body.fragebogen, leerFragebogen()) +}) + +// ---------------------------------------------------------------- Alle anderen Routen behalten die Schranke active|disabled + +const ANDERE_ROUTEN = [ + ['GET', '/api/termine/buchungen?projectId=p1&von=2026-10-01&bis=2026-10-31'], + ['GET', '/api/termine/zeiten?projectId=p1&leistung=x&tag=2026-10-12'], + ['GET', '/api/termine/export.csv?projectId=p1'], + ['POST', '/api/termine/ressourcen/r_aaaaaaa1/feed', { projectId: 'p1' }], + ['POST', '/api/termine/buchungen', { projectId: 'p1', art: 'sperre' }], + ['POST', '/api/termine/buchungen/b_x/aktion', { projectId: 'p1', aktion: 'annehmen' }], + ['DELETE', '/api/termine/buchungen/b_x?projectId=p1'], +] + +for (const zustand of ['keins', 'off', 'ordered', 'setup']) { + for (const [zeile, auth] of ZEILEN) { + test(`Zustand ${zustand}, ${zeile}: Buchungen, Zeiten, Export, Feed-Link und Termine eintragen bleiben 409 "nicht aktiv"`, async () => { + setzeZustand(zustand) + const vorher = groessen() + for (const [method, pfad, body] of ANDERE_ROUTEN) { + const res = await send(method, pfad, { ...auth(), body }) + assert.equal(res.status, 409, `${method} ${pfad}`) + assert.equal(res.body.error, NICHT_AKTIV, `${method} ${pfad}`) + assert.equal(res.body.code, 'nicht_aktiv', `${method} ${pfad}`) + } + assert.deepEqual(groessen(), vorher) + }) + } +} + +test('Zustand active und disabled: die Lese-Routen gehen weiter (Schranke active|disabled unverändert)', async () => { + for (const zustand of ['active', 'disabled']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const liste = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-01&bis=2026-10-31', { cookie }) + assert.equal(liste.status, 200, zustand) + assert.deepEqual(liste.body.buchungen, []) + assert.equal((await send('GET', '/api/termine/export.csv?projectId=p1', { cookie })).status, 200, zustand) + } +}) + +test('Pilot-Schalter gilt auch für das Lesen des Entwurfs: Modus off oder Kunde nicht in der Liste => 409 "noch nicht verfügbar", ohne und mit Dokument', async () => { + for (const zustand of ['keins', 'off']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + setzeZustand(zustand) + config.termine.pilotEmails = ['andere@example.invalid'] + for (const auth of [{ cookie }, kv()]) { + const res = await send('GET', einstellungenUrl(), auth) + assert.equal(res.status, 409, zustand) + assert.equal(res.body.error, 'Der Terminplaner ist noch nicht verfügbar.') + assert.equal(res.body.code, 'nicht_verfuegbar') + } + config.termine.pilotEmails = ['*'] + config.termine.mode = 'off' + assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409, `${zustand} bei Modus off`) + config.termine.mode = 'pilot' + } +}) + +test('ohne Sitzung 401, ohne projectId 400, unbrauchbare projectId 403', async () => { + assert.equal((await send('GET', einstellungenUrl())).status, 401) + assert.equal((await send('GET', '/api/termine/einstellungen', { cookie })).status, 400) + const unbrauchbar = await send('GET', einstellungenUrl('gibt es nicht'), { cookie }) + assert.equal(unbrauchbar.status, 403) + assert.equal(unbrauchbar.body.error, 'Kein Zugriff auf dieses Projekt.') +}) + +// ---------------------------------------------------------------- Mandanten (Review-Focus 3) + +test('Mandanten: Kunde c2 mit dem Projekt von c1 => 403 auf GET, in jedem Zustand und in der Kundenansicht; nichts angelegt, nichts gelesen', async () => { + for (const zustand of ['keins', 'off', 'ordered', 'setup', 'active', 'disabled']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const vorher = groessen() + for (const auth of [{ cookie: customerCookie('c2') }, kv({ customerId: 'c2' })]) { + const res = await send('GET', einstellungenUrl('p1'), auth) + assert.equal(res.status, 403, zustand) + assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.') + assert.ok(!JSON.stringify(res.body).includes('Salon'), 'keine Daten des fremden Entwurfs') + } + assert.deepEqual(groessen(), vorher, zustand) + } +}) + +test('Mandanten: gehört das Funktionsdokument eines eigenen Projekts einem anderen Kunden, sieht der Kunde einen leeren Entwurf statt dessen Daten', async () => { + aw.seed('portalFeatures', featureDoc('off', { $id: 'f_p3_termine', customerId: 'c1', projectId: 'p3', siteKey: 'bcdefghijklmnopq' })) + aw.seed('termineConfig', entwurfDoc('f_p3_termine', { customerId: 'c1', projectId: 'p3' })) + const res = await send('GET', einstellungenUrl('p3'), { cookie: customerCookie('c2') }) + assert.equal(res.status, 200) + assert.equal(res.body.state, 'off') + assert.equal(res.body.einstellung, null) + assert.equal(res.body.siteKey, null) + assert.ok(!JSON.stringify(res.body).includes('Salon Muster')) +}) + +// ---------------------------------------------------------------- Felder je Zustand + +test('branchen: Schlüssel, Name und gesundheit je Branche aus BRANCHEN (ohne Vorlage), der Gesundheitsbereich zuletzt', async () => { + const res = await send('GET', einstellungenUrl(), { cookie }) + assert.deepEqual(res.body.branchen, BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit }))) + assert.equal(res.body.branchen.length, 10) + assert.deepEqual(res.body.branchen.at(-1), { key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true }) + assert.ok(res.body.branchen.every((b) => Object.keys(b).join() === 'key,name,gesundheit'), 'keine internen Felder wie vorlage') + assert.equal(res.body.branchen.filter((b) => b.gesundheit).length, 1) +}) + +test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippet); in off, ordered, setup und ohne Dokument leer', async () => { + for (const zustand of ['keins', 'off', 'ordered', 'setup', 'active', 'disabled']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + const stand = setzeZustand(zustand) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + if (zustand === 'active' || zustand === 'disabled') { + assert.match(body.einbau.snippet, new RegExp(`data-webklar-termine="${SITE_KEY}"`), zustand) + } else { + assert.equal(body.einbau.snippet, undefined, zustand) + } + if (zustand === 'active') { + assert.equal(Object.keys(body.feeds).length, body.einstellung.ressourcen.length, 'Kalender-Link je Person') + assert.ok(Object.keys(body.feeds).length > 0) + } else { + assert.deepEqual(body.feeds, {}, zustand) + } + // die Token stehen höchstens in den Links, nie in der Einstellung + if (stand) { + for (const r of stand.einstellung.ressourcen) { + assert.equal(typeof r.feedToken, 'string') + assert.ok(!JSON.stringify(body.einstellung).includes(r.feedToken), `${zustand}: kein Feed-Token in der Einstellung`) + if (zustand !== 'active') assert.ok(!JSON.stringify(body).includes(r.feedToken), `${zustand}: auch sonst nirgends`) + } + } + } +}) + +test('abo, offen und bestellung gibt es nur in off; sonst null', async () => { + for (const zustand of ['ordered', 'setup', 'active', 'disabled']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(body.abo, null, zustand) + assert.equal(body.offen, null, zustand) + assert.equal(body.bestellung, null, zustand) + } + setzeZustand('off') + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.notEqual(body.abo, null) + assert.notEqual(body.offen, null) + assert.notEqual(body.bestellung, null) +}) + +test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => { + setzeZustand('disabled') + const lesen = await send('GET', einstellungenUrl(), { cookie }) + assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' }) + const schreiben = await send('POST', '/api/termine/buchungen', { cookie, body: { projectId: 'p1', art: 'sperre' } }) + assert.equal(schreiben.status, 409) + assert.equal(schreiben.body.code, 'abgeschaltet') + assert.match(schreiben.body.error, /Wartungs-Abo beendet/) + for (const zustand of ['keins', 'off', 'ordered', 'setup', 'active']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + setzeZustand(zustand) + assert.equal((await send('GET', einstellungenUrl(), { cookie })).body.abgeschaltet, null, zustand) + } +}) + +// ---------------------------------------------------------------- Bestellung in off: abo, offen, bestellung + +test('off mit bestellbarem Entwurf und laufendem Wartungs-Abo: abo { ok: true }, offen leer, bestellung = Sicht des Registers', async () => { + setzeZustand('off') + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.deepEqual(body.abo, { ok: true }) + assert.deepEqual(body.offen, []) + assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T)))) + assert.equal(body.bestellung.consent.knopf, T.consent.knopf, 'der Knopf zeigt genau den Registertext') + assert.equal(body.bestellung.consent.version, T.consent.version) + assert.deepEqual(body.bestellung.consent.items, T.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) }))) + assert.equal(body.bestellung.preis, T.priceLabel) + assert.equal(body.bestellung.preisHinweis, config.ustHinweis) + assert.equal(typeof body.bestellung.belehrung, 'string') + assert.ok(!('ticket' in body.bestellung) && !('mails' in body.bestellung), 'keine internen Registerfelder') +}) + +test('off mit offenem Fragebogen: offen nennt die Punkte aus bestellpruefung mit Schritt und Text (ohne Abo-Punkt, wenn das Abo läuft)', async () => { + const stand = bestellbarerStand({ now: JETZT }) + stand.fragebogen.bestaetigt = stand.fragebogen.bestaetigt.filter((schritt) => schritt !== 'wer') + stand.fragebogen.bundeslandGewaehlt = false + setzeZustand('off', { stand }) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.deepEqual(body.offen, [ + { schritt: 'wer', text: 'Bitte bestätige den Schritt „Wer wird gebucht“.' }, + { schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.' }, + ]) + assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, abo: { ok: true } })) +}) + +test('off ohne Entwurf (Dokument ohne Einstellung, oder gar kein Dokument): genau ein offener Punkt, die Wahl der Branche', async () => { + const erwartet = [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }] + assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.offen, erwartet, 'kein Dokument') + aw.seed('portalFeatures', featureDoc('off')) + const body = (await send('GET', einstellungenUrl(), { cookie })).body + assert.deepEqual(body.offen, erwartet, 'Dokument ohne termineConfig') + assert.deepEqual(body.abo, { ok: true }) + assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T)))) +}) + +test('Abo ohne laufendes Wartungs-Abo (nur Hosting, oder gar keins): abo { ok: false, grund: kein_abo }, offen mit dem Abo-Punkt (h) unter "bestellen"', async () => { + setzeZustand('off') + const text = 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.' + assert.equal(aboMeldung(T, 'kein_abo'), text) + const faelle = [ + ['nur Hosting', async () => {}], + ['gar kein Abo', () => deleteDocument('subscriptions', 'sub_h')], + ] + for (const [name, vorbereitung] of faelle) { + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) // wie seedFixtures + await vorbereitung() + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(body.state, 'off', name) + assert.deepEqual(body.abo, { ok: false, grund: 'kein_abo' }, name) + assert.deepEqual(body.offen, [{ schritt: 'bestellen', text }], name) + } +}) + +test('Abo mit vorgemerkter Kündigung: grund gekuendigt; past_due: grund nicht_aktiv; jeweils mit dem Text aus aboMeldung', async () => { + setzeZustand('off') + wartung({ cancelAtPeriodEnd: true }) + let body = (await send('GET', einstellungenUrl(), { cookie })).body + assert.deepEqual(body.abo, { ok: false, grund: 'gekuendigt' }) + assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'gekuendigt') }]) + assert.equal(body.offen[0].text, 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.') + + wartung({ status: 'past_due' }) + body = (await send('GET', einstellungenUrl(), { cookie })).body + assert.deepEqual(body.abo, { ok: false, grund: 'nicht_aktiv' }) + assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'nicht_aktiv') }]) +}) + +test('Lesefehler beim Abo: GET bleibt 200, abo null und der offene Punkt "nicht geprüft" (Text aus aboMeldung unbekannt); nichts Technisches nach außen', async () => { + setzeZustand('off') + aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' }) + const res = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(res.status, 200) + assert.equal(res.body.abo, null) + assert.deepEqual(res.body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'unbekannt') }]) + assert.equal(res.body.offen[0].text, 'Das Abo konnte gerade nicht geprüft werden.') + assert.ok(!JSON.stringify(res.body).includes('geheimer')) + assert.ok(console.error.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log') + // der nächste Aufruf liest wieder (nichts wird gemerkt) + assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.abo, { ok: true }) +}) + +test('Lesefehler beim Abo und offener Fragebogen: die übrigen Punkte bleiben, der Abo-Punkt kommt dazu (sortiert nach Schrittfolge, "bestellen" zuletzt)', async () => { + const stand = bestellbarerStand({ now: JETZT }) + stand.fragebogen.bestaetigt = [] + setzeZustand('off', { stand }) + aw.failNext({ collection: 'subscriptions', status: 503 }) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(body.abo, null) + assert.equal(body.offen.at(-1).schritt, 'bestellen') + assert.equal(body.offen.at(-1).text, aboMeldung(T, 'unbekannt')) + assert.equal(body.offen.length, 8 + 1, 'acht unbestätigte Schritte und der Abo-Punkt') +}) + +test('Das Abo wird nur in off gelesen: in ordered, setup, active und disabled gibt es keine Abfrage auf subscriptions', async () => { + for (const zustand of ['ordered', 'setup', 'active', 'disabled']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + aw.resetListCalls() + assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, zustand) + assert.deepEqual(aw.listCalls('subscriptions'), [], zustand) + } + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + setzeZustand('off') + aw.resetListCalls() + await send('GET', einstellungenUrl(), { cookie }) + assert.equal(aw.listCalls('subscriptions').length, 1, 'in off genau eine Abfrage') +}) + +// ---------------------------------------------------------------- betriebVorschlag + +test('betriebVorschlag ohne Einstellung: Name, Anschrift, E-Mail und Telefon aus dem Konto; Website leer, wenn es nur die Vorschau-Adresse gibt', async () => { + for (const zustand of ['keins', 'off']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + if (zustand === 'off') aw.seed('portalFeatures', featureDoc('off')) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.deepEqual(body.betriebVorschlag, { + name: 'Salon Müller', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + email: 'k@example.invalid', + telefon: '06301 1234', + websiteUrl: '', + }, zustand) + } +}) + +test('betriebVorschlag: die Website kommt nur aus liveDomain (https, ohne Pfad), nie aus der Vorschau-Adresse', async () => { + const faelle = [ + ['Live-Domain mit Schema und Pfad', { liveDomain: 'HTTPS://Www.Salon-Mueller.de/start?x=1' }, 'https://www.salon-mueller.de'], + ['Live-Domain schlicht', { liveDomain: 'salon-mueller.de' }, 'https://salon-mueller.de'], + ['Live-Domain ist die Vorschau-Adresse', { liveDomain: 'salon.project.webklar.com' }, ''], + ['Live-Domain unbrauchbar', { liveDomain: 'kein host' }, ''], + ['keine Live-Domain', { liveDomain: '' }, ''], + ] + for (const [name, over, erwartet] of faelle) { + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon', subdomain: 'salon', ...over }) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(body.betriebVorschlag.websiteUrl, erwartet, name) + } + // ohne Subdomain gilt die Live-Domain ebenfalls + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon', liveDomain: 'salon-mueller.de' }) + assert.equal((await send('GET', einstellungenUrl(), { cookie })).body.betriebVorschlag.websiteUrl, 'https://salon-mueller.de') +}) + +test('betriebVorschlag ohne Firma und Telefon im Konto: Name aus dem Kontonamen, Rest leer', async () => { + aw.seed('customers', { ...customers.c1 }) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.deepEqual(body.betriebVorschlag, { name: 'Kunde Eins', adresse: '', email: 'k@example.invalid', telefon: '', websiteUrl: '' }) +}) + +test('betriebVorschlag gibt es nur ohne Einstellung: mit gespeicherter Einstellung fehlt das Feld (und das Konto wird nicht gelesen)', async () => { + setzeZustand('off') + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.ok(!('betriebVorschlag' in body)) + for (const zustand of ['ordered', 'setup', 'active', 'disabled']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + setzeZustand(zustand) + assert.ok(!('betriebVorschlag' in (await send('GET', einstellungenUrl(), { cookie })).body), zustand) + } +}) + +test('betriebVorschlag auch in active und disabled ohne Einstellung (das Konto liefert die Vorgabe, nicht der Zustand)', async () => { + for (const zustand of ['active', 'disabled']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + setzeZustand(zustand, { stand: null }) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(body.einstellung, null) + assert.equal(body.betriebVorschlag.name, 'Salon Müller', zustand) + } +}) diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js index 74ded21..07908bd 100644 --- a/test/termineRoutes.test.js +++ b/test/termineRoutes.test.js @@ -246,23 +246,40 @@ test('ohne Pilot (Kunde nicht in der Liste, Modus off) 409 "noch nicht verfuegba assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409) }) -test('Funktion aus, noch nicht bestellt oder ohne Dokument: 409 "nicht aktiv"', async () => { +test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen geht (Entwurf), alles andere bleibt 409 "nicht aktiv"', async () => { + // Lesen der Einstellungen: in jedem Zustand und auch ohne Dokument (Zustandsmatrix: test/termineFragebogenRoutes.test.js) for (const projectId of ['p2', 'p5']) { + const lesen = await send('GET', einstellungenUrl(projectId), { cookie }) + assert.equal(lesen.status, 200, projectId) + assert.equal(lesen.body.state, 'off', projectId) + assert.equal(lesen.body.einstellung, null, projectId) + assert.equal(lesen.body.version, 0, projectId) + // alles andere, auch das Schreiben der Einstellungen (Schreibregeln des Entwurfs folgen in C4b) for (const [method, pfad, body] of [ - ['GET', einstellungenUrl(projectId)], ['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`], + ['GET', `/api/termine/zeiten?projectId=${projectId}`], ['GET', `/api/termine/export.csv?projectId=${projectId}`], ['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }], ['PATCH', '/api/termine/einstellungen', { projectId, version: 0, einstellung: {} }], + ['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }], + ['POST', '/api/termine/buchungen', { projectId, art: 'sperre' }], ]) { const res = await send(method, pfad, { cookie, body }) assert.equal(res.status, 409, `${projectId} ${method} ${pfad}`) assert.equal(res.body.error, 'Der Terminplaner ist für diese Website nicht aktiv.') } } + assert.equal(aw.docs('portalFeatures').size, 4, 'p5 hat kein Dokument bekommen (nur die vier aus beforeEach)') for (const state of ['ordered', 'setup']) { aw.seed('portalFeatures', featureDoc({ $id: 'f_p5_termine', projectId: 'p5', state, enabled: false, siteKey: 'efghijklmnopqrst' })) - assert.equal((await send('GET', einstellungenUrl('p5'), { cookie })).status, 409, state) + const lesen = await send('GET', einstellungenUrl('p5'), { cookie }) + assert.equal(lesen.status, 200, state) + assert.equal(lesen.body.state, state) + assert.equal( + (await send('GET', `/api/termine/buchungen?projectId=p5&von=2026-10-01&bis=2026-10-31`, { cookie })).status, + 409, + state + ) } })