diff --git a/server/index.js b/server/index.js index f1582fc..3abd086 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' import funktionenRoutes from './routes/funktionen.js' import termineRoutes from './routes/termine.js' +import zeitenRoutes from './routes/zeiten.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' @@ -79,6 +80,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) app.use('/api/termine', termineRoutes) +app.use('/api/zeiten', zeitenRoutes) app.use('/api/team', teamRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminPreviewRoutes) diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index 297d7a7..d406bb3 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -24,6 +24,9 @@ export const KUNDENANSICHT_ALLOWED = [ ['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/], ['PATCH', /^\/api\/termine\/einstellungen\/?$/], ['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/], + // Oeffnungszeiten: Speichern im Namen des Kunden (Spec 2026-10-04, Abschnitt 3.4); den WhatsApp-Haken sperrt die Route selbst (403). + // Nur dieser eine Pfad: die Vorschau (GET) braucht keinen Eintrag, alles andere unter /api/zeiten ist gesperrt. + ['PUT', /^\/api\/zeiten\/[^/]+\/?$/], ['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/], ['POST', /^\/api\/auth\/logout\/?$/], ['POST', /^\/api\/auth\/login\/?$/], diff --git a/server/routes/zeiten.js b/server/routes/zeiten.js new file mode 100644 index 0000000..850340f --- /dev/null +++ b/server/routes/zeiten.js @@ -0,0 +1,221 @@ +import { Router } from 'express' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' +import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' +import { feiertageImZeitraum } from '../services/termine/feiertage.js' +import { heute, plusTage } from '../services/termine/zeit.js' +import { createWriteLimiter } from '../services/writeLimiter.js' +import { ladeZeiten, speichereZeiten } from '../services/zeiten/speicher.js' +import { googleText, hauptplan, landVon, statusZu, zuUtcOderDanach } from '../services/zeiten/zeitlogik.js' + +/** + * Kunden-Schnittstelle der Oeffnungszeiten: /api/zeiten (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 3.4). + * + * GET /:projectId Zeiten laden: { state, daten|null, version, kundenansicht, vorschau|null, feiertage, googleText } + * PUT /:projectId { version, daten } speichern, Antwort wie GET + * GET /:projectId/vorschau ?zeit=JJJJ-MM-TTTHH:MM (Berliner Wanduhr, ohne zeit: jetzt): { offen, text, kurz } der gespeicherten Daten + * + * Zugang: Session, eigenes Projekt (403), Funktion im Zustand ordered, setup oder active (lesen und speichern). disabled mit + * gespeicherten Zeiten: nur lesen (PUT 403); disabled ohne Zeiten, off und kein Funktionsdokument: 403. Der Schalter + * ZEITEN_ENABLED entscheidet nur ueber das Einschalten (featureRegistry), nicht ueber diese Schnittstelle: wer die Funktion hat, bearbeitet sie. + * Version: 0 vor dem ersten Speichern; veraltet => 409 ZEITEN_VERALTET (die Sperre `zeiten:` nimmt der Speicher selbst). + * Kundenansicht (Mitarbeiter): speichern ist erlaubt (Erlaubnisliste des Waechters) und wird als `zeiten_gespeichert` protokolliert; + * den WhatsApp-Haken (kontakt.whatsapp.bestaetigtAt) setzen nur Kunden selbst (R12, siehe bestaetigtAtPruefen). + * Schreiben zaehlt gegen 60 Speichervorgaenge je Kunde und Stunde (im Speicher, 429); Lesen nicht. + * + * Fehler: { error: , fehler?: { : Text }, code? }. Texte von Appwrite oder unbekannten Fehlern verlassen den Server + * nie (500 "Interner Serverfehler"). Antworten sind nie zwischenspeicherbar. + */ + +const router = Router() + +const MAX_SPEICHERN_JE_STUNDE = 60 +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ // Form einer Appwrite-ID: andere Texte gehen nie als Dokument-ID an Appwrite +const FEIERTAGE_TAGE = 366 // 12 Monate ab heute: derselbe Kalendertag im naechsten Jahr ist immer dabei + +const MELDUNG = { + fremd: 'Kein Zugriff auf dieses Projekt.', + nichtEingeschaltet: 'Die Öffnungszeiten sind für diese Website nicht eingeschaltet.', + abgeschaltet: 'Die Öffnungszeiten sind für diese Website abgeschaltet. Du kannst sie nur noch ansehen.', + veraltet: 'Inzwischen geändert – bitte neu laden.', + whatsappKundenansicht: 'In der Kundenansicht kann der WhatsApp-Hinweis nicht bestätigt werden.', + zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', + zeit: 'Bitte gib Tag und Uhrzeit an (JJJJ-MM-TTTHH:MM).', + keineZeiten: 'Bitte speichere zuerst deine Öffnungszeiten.', +} + +// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). +let uhr = () => new Date() +export function setzeUhrFuerTests(neueUhr) { + uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() +} + +// 60 Speichervorgaenge je Kunde und Stunde (im Speicher); die Uhr folgt setzeUhrFuerTests +const limiter = createWriteLimiter({ max: MAX_SPEICHERN_JE_STUNDE, now: () => uhr().getTime() }) +const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) + +export function resetZeitenRoutesForTests() { + limiter.reset() + uhr = () => new Date() +} + +// ---------------------------------------------------------------- Fehler + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Fehler des Kunden (4xx) mit eigenem Text; mit feld zusaetzlich als Feldfehler. `fail` macht daraus die Antwort. */ +const stop = (status, text, code, feld) => + Object.assign(new Error(text), { status, kunde: true, ...(code ? { code } : {}), ...(feld ? { fehler: { [feld]: text } } : {}) }) + +/** + * Antwort fuer einen Fehler: eigene Fehler (stop), Pruefergebnisse des Speichers (400 mit `fehler`) und ZEITEN_VERALTET (409) + * wie sie sind; alles andere (auch Appwrite-Fehler mit 4xx) 500 ohne Rohtext. + */ +function fail(res, err) { + const status = Number.isInteger(err?.status) ? err.status : 500 + const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {} + const durchgelassen = err?.kunde === true || Object.keys(texte).length > 0 || err?.code === 'ZEITEN_VERALTET' + if (!durchgelassen || status < 400 || status >= 500) { + console.error(`[zeiten] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return res.status(500).json({ error: 'Interner Serverfehler' }) + } + const body = { error: String(err.message) } + if (Object.keys(texte).length > 0) body.fehler = texte + if (typeof err.code === 'string') body.code = err.code + return res.status(status).json(body) +} + +/** Handler mit Fehlerbehandlung (Express 4 fängt async-Fehler nicht selbst). */ +const route = (fn) => async (req, res, next) => { + try { + await fn(req, res, next) + } catch (err) { + fail(res, err) + } +} + +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store') + next() +}) + +// ---------------------------------------------------------------- Zugang + +/** + * Eigenes Projekt (403), Funktion im Zustand ordered, setup, active oder disabled mit gespeicherten Zeiten (sonst 403) und die + * gespeicherten Zeiten => req.ctx = { projectId, project, feature, stored } (stored: ladeZeiten, null ohne Dokument). + * schreiben: disabled ist nicht beschreibbar (403). + */ +const withZeiten = ({ schreiben = false } = {}) => route(async (req, _res, next) => { + const projectId = req.params.projectId + const customerId = getSessionCustomerId(req) + if (!ID_RE.test(projectId)) throw stop(403, MELDUNG.fremd) + let project + try { + project = await assertOwnProject(customerId, projectId) + } catch (err) { + // auch eine unbrauchbare ID (Appwrite 400) ist kein eigenes Projekt + throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err + } + const feature = await getFeatureDoc(customerId, projectId, 'zeiten') + if (!feature || !['ordered', 'setup', 'active', 'disabled'].includes(feature.state)) throw stop(403, MELDUNG.nichtEingeschaltet) + if (feature.state === 'disabled' && schreiben) throw stop(403, MELDUNG.abgeschaltet) + const stored = await ladeZeiten(feature.$id) + if (feature.state === 'disabled' && !stored?.daten) throw stop(403, MELDUNG.nichtEingeschaltet) + req.ctx = { projectId, project, feature, stored } + return next() +}) + +// ---------------------------------------------------------------- Antwort + +/** Alles, was der Editor nach dem Laden und nach dem Speichern braucht (daten null und version 0 vor dem ersten Speichern). */ +function ansicht(req, { feature, daten, version }, now) { + const plan = hauptplan(daten) + const von = heute(now) + return { + state: feature.state, + daten: daten ?? null, + version, + kundenansicht: isKundenansicht(req.session), + vorschau: daten && plan ? statusZu(daten, plan, now) : null, + feiertage: feiertageImZeitraum(von, plusTage(von, FEIERTAGE_TAGE), landVon(daten)), + googleText: daten ? googleText(daten, plan, now) : '', + } +} + +router.get('/:projectId', requireSession, withZeiten(), route(async (req, res) => { + const { feature, stored } = req.ctx + res.json(ansicht(req, { feature, daten: stored?.daten ?? null, version: stored?.version ?? 0 }, uhr())) +})) + +// ---------------------------------------------------------------- Vorschau + +const ZEIT_RE = /^(20\d{2}-\d{2}-\d{2})T([01]\d|2[0-3]):([0-5]\d)$/ + +/** ?zeit=JJJJ-MM-TTTHH:MM (Berliner Wanduhr) => Zeitpunkt; ohne zeit: jetzt; sonst 400. */ +function vorschauZeit(req) { + const roh = req.query.zeit + if (roh === undefined) return uhr() + const m = typeof roh === 'string' ? ZEIT_RE.exec(roh) : null + if (!m) throw stop(400, MELDUNG.zeit, undefined, 'zeit') + try { + return zuUtcOderDanach(m[1], `${m[2]}:${m[3]}`) + } catch { + throw stop(400, MELDUNG.zeit, undefined, 'zeit') // kein echter Kalendertag (30.02.) + } +} + +router.get('/:projectId/vorschau', requireSession, withZeiten(), route(async (req, res) => { + const { stored } = req.ctx + const plan = hauptplan(stored?.daten) + if (!plan) throw stop(409, MELDUNG.keineZeiten, 'keine_zeiten') + res.json(statusZu(stored.daten, plan, vorschauZeit(req))) +})) + +// ---------------------------------------------------------------- Speichern + +/** Zwei Zeitpunkt-Texte, die denselben Moment meinen (auch in anderer ISO-Schreibweise). */ +function gleicherZeitpunkt(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false + if (a === b) return true + const x = Date.parse(a) + return Number.isFinite(x) && x === Date.parse(b) +} + +/** Hat der Browser einen Zeitpunkt der Bestaetigung geschickt? (fehlt er, ist der Haken nicht gesetzt: das meldet die Pruefung als Eingabefehler) */ +const behauptetBestaetigung = (wert) => wert !== undefined && wert !== null && wert !== '' && wert !== false + +/** + * R12 (Spezifikation 3.4, Kontakt-Leiste): den Zeitpunkt der WhatsApp-Bestaetigung (kontakt.whatsapp.bestaetigtAt) glaubt der Server + * dem Browser nie. Ist er gegenueber dem gespeicherten Stand (bisher) neu oder anders: in der Kundenansicht 403 (Mitarbeiter + * bestaetigen nichts im Namen des Kunden), beim Kunden selbst setzt der Server ihn auf jetzt. Unveraendert bleibt er erhalten; + * fehlt er ganz, bleibt die Eingabe wie sie ist (die Pruefung meldet "Bitte bestaetige ..."). Veraendert die Eingabe nicht, sondern + * liefert eine Kopie. + */ +function bestaetigtAtPruefen(req, eingabe, bisher, jetzt) { + const whatsapp = eingabe?.kontakt?.whatsapp + if (!istObjekt(whatsapp) || !behauptetBestaetigung(whatsapp.bestaetigtAt)) return eingabe + const gespeichert = bisher?.kontakt?.whatsapp?.bestaetigtAt + const bestaetigtAt = gleicherZeitpunkt(whatsapp.bestaetigtAt, gespeichert) ? gespeichert : null + if (bestaetigtAt === null && isKundenansicht(req.session)) throw stop(403, MELDUNG.whatsappKundenansicht) + return { ...eingabe, kontakt: { ...eingabe.kontakt, whatsapp: { ...whatsapp, bestaetigtAt: bestaetigtAt ?? jetzt.toISOString() } } } +} + +/** Protokolltext der Kundenansicht: Projekt und neue Version (keine Inhalte). */ +const protokollText = (req, version) => `${String(req.ctx.project?.projectName || req.ctx.projectId).slice(0, 120)}, Version ${version}` + +router.put('/:projectId', requireSession, withZeiten({ schreiben: true }), limitWrites, route(async (req, res) => { + const { feature, stored } = req.ctx + const jetzt = uhr() + const { version, daten } = istObjekt(req.body) ? req.body : {} + // Veraltet gewinnt vor jeder anderen Pruefung (auch vor dem WhatsApp-Haken): der Editor soll erst neu laden + if (version !== (stored?.version ?? 0)) throw stop(409, MELDUNG.veraltet, 'ZEITEN_VERALTET') + + const eingabe = bestaetigtAtPruefen(req, daten, stored?.daten, jetzt) + const by = isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : 'kunde' + const gespeichert = await speichereZeiten({ feature, eingabe, version, by, now: jetzt }) + await logIfKundenansicht(req, 'zeiten_gespeichert', protokollText(req, gespeichert.version)) + res.json(ansicht(req, { feature, daten: gespeichert.daten, version: gespeichert.version }, jetzt)) +})) + +export default router diff --git a/server/services/customerViewLog.js b/server/services/customerViewLog.js index 9a8b2fe..2bcab99 100644 --- a/server/services/customerViewLog.js +++ b/server/services/customerViewLog.js @@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [ 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + 'zeiten_gespeichert', ] const DETAIL_MAX = 300 diff --git a/test/customerViewLog.test.js b/test/customerViewLog.test.js index 008fa44..ae51100 100644 --- a/test/customerViewLog.test.js +++ b/test/customerViewLog.test.js @@ -17,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()] const seedLog = (id, customerId, createdAt, action = 'geoeffnet') => aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt }) -test('VIEW_ACTIONS: genau die zehn Aktionen', () => { +test('VIEW_ACTIONS: genau die elf Aktionen', () => { assert.deepEqual(VIEW_ACTIONS, [ 'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht', - 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + 'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'zeiten_gespeichert', ]) }) diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 7929da4..787cf5a 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -27,6 +27,7 @@ const { createStaffPreviewToken } = await import('../server/services/staffPrevie // Kunden-Router mit denselben Mount-Pfaden wie server/index.js const termineRouter = (await import('../server/routes/termine.js')).default +const zeitenRouter = (await import('../server/routes/zeiten.js')).default const MOUNTS = [ ['/api/auth', (await import('../server/routes/auth.js')).default], ['/api/projects', (await import('../server/routes/projects.js')).default], @@ -38,6 +39,7 @@ const MOUNTS = [ ['/api/abo', (await import('../server/routes/abo.js')).default], ['/api/funktionen', (await import('../server/routes/funktionen.js')).default], ['/api/termine', termineRouter], + ['/api/zeiten', zeitenRouter], ] // Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist const oeffentlich = (await import('../server/routes/publicTermine.js')).default @@ -118,7 +120,7 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a } }) -test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => { +test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => { const ok = [ ['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'], ['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'], @@ -126,9 +128,10 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L ['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'], ['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'], ['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'], + ['PUT', '/api/zeiten/abc'], ['PUT', '/api/zeiten/abc/'], ] for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`) - assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard') + assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 1, 'genau sechs Termine-Eintraege und ein Zeiten-Eintrag dazu, keine Wildcard') for (const [m, p] of [ ['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'], // Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen @@ -140,6 +143,9 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L ['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'], ['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'], ['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'], + // Oeffnungszeiten: nur PUT auf das Projekt, sonst nichts + ['PUT', '/api/zeiten'], ['PUT', '/api/zeiten/'], ['PUT', '/api/zeiten/abc/vorschau'], ['PUT', '/api/zeiten/abc/def/ghi'], ['PUT', '/api/zeitenX/abc'], + ['POST', '/api/zeiten/abc'], ['PATCH', '/api/zeiten/abc'], ['DELETE', '/api/zeiten/abc'], ['POST', '/api/zeiten'], ]) { assert.ok(!allowed(m, p), `${m} ${p}`) } @@ -196,6 +202,31 @@ test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf d } }) +// Oeffnungszeiten: die Erlaubnisliste und die echten Schreib-Routen decken sich genau (eine Route, ein Eintrag); jedes andere +// Schreiben unter /api/zeiten (auch ohne Route, auch Gross-/Kleinschreibung) sperrt der Waechter, normale Kunden beruehrt er nicht. +test('Router-Abgleich: genau eine Schreib-Route der Öffnungszeiten (PUT /:projectId) steht auf der Erlaubnisliste', async () => { + const routen = writeRoutes([['/api/zeiten', zeitenRouter]]) + assert.deepEqual(routen.map(({ method, path: p }) => `${method} ${p}`), ['PUT /api/zeiten/x'], 'der Router schreibt nur per PUT /:projectId') + const eintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('zeiten')) + assert.equal(eintraege.length, 1) + assert.ok(allowed('PUT', '/api/zeiten/x')) + + for (const [method, p] of [['POST', '/api/zeiten/p1'], ['PATCH', '/api/zeiten/p1'], ['DELETE', '/api/zeiten/p1'], ['PUT', '/api/zeiten/p1/vorschau'], ['PUT', '/api/zeiten'], ['POST', '/API/Zeiten/p1']]) { + const res = await call(method, p, { ...kv(), body: {} }) + assert.equal(res.status, 403, `${method} ${p}`) + assert.equal(res.body.code, 'view_blocked', `${method} ${p}`) + } + // ohne Kennung im Header gilt auch der erlaubte Pfad nicht (wie bei allen Schreib-Routen) + const ohneKennung = await call('PUT', '/api/zeiten/p1', { cookie: kundenansichtCookie(), body: {} }) + assert.equal(ohneKennung.status, 409) + assert.equal(ohneKennung.body.code, 'view_replaced') + // der erlaubte Pfad erreicht den Router (hier: das Projekt gehoert c1, die Funktion gibt es nicht => 403 der Route, nicht des Waechters) + const durchgelassen = await call('PUT', '/api/zeiten/p1', { ...kv(), body: { version: 0, daten: {} } }) + assert.equal(durchgelassen.status, 403) + assert.notEqual(durchgelassen.body.code, 'view_blocked') + assert.match(durchgelassen.body.error, /nicht eingeschaltet/) +}) + test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => { const res = await call('POST', '/api/chat/read', kv()) assert.equal(res.status, 403) diff --git a/test/zeitenRoutes.test.js b/test/zeitenRoutes.test.js new file mode 100644 index 0000000..43652b2 --- /dev/null +++ b/test/zeitenRoutes.test.js @@ -0,0 +1,539 @@ +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 { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Kunden-Schnittstelle /api/zeiten (Aufgabe 10): Zugang (Projekt, Funktion nach Zustand), Laden, Speichern mit Version, Vorschau +// zur gewaehlten Berliner Uhrzeit, WhatsApp-Bestaetigung (R12), Kundenansicht (erlaubt, protokolliert) und Schreib-Limit. +// Aufbau wie test/termineRoutes.test.js (Nachbau von Appwrite mit Schema, Standard-Limit 25, feste Uhr, Waechter davor). +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + customerViewLog: schemaKeys('customerViewLog'), + }, + datetimes: ZEITEN_DATETIMES, + defaultLimit: 25, +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { startTestApp, send } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie } = await import('./support/fixtures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { default: zeitenRoutes, setzeUhrFuerTests, resetZeitenRoutesForTests } = await import('../server/routes/zeiten.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/zeiten': zeitenRoutes }) +after(() => Promise.all([app.close(), aw.close()])) + +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ) +const KUNDE = 'c1' +const VIEW = 'a'.repeat(32) +const cookie = customerCookie(KUNDE) +const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } }) +const P1 = 'p_aaaaaaa1' + +const featureDoc = (projectId, over = {}) => ({ + $id: `f_${projectId}_zeiten`, + customerId: KUNDE, + projectId, + featureKey: 'zeiten', + state: 'active', + enabled: true, + siteKey: `key${projectId}`.padEnd(16, 'x').slice(0, 16), + activatedAt: '2026-10-01T09:00:00.000Z', + ...over, +}) + +function tage(woche = {}) { + const t = {} + for (let d = 1; d <= 7; d += 1) { + const w = woche[d] + t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] } + } + return t +} +const plan = (extra = {}) => ({ id: P1, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }), ...extra }) +const eingabe = (extra = {}) => ({ + land: 'RP', + plaene: [plan()], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, + ...extra, +}) +const whatsapp = (bestaetigtAt) => ({ nummer: '0171 1234567', starttext: 'Hallo', bestaetigtAt }) +const mitWhatsapp = (bestaetigtAt) => eingabe({ kontakt: { an: true, telefon: '', adresse: '', whatsapp: whatsapp(bestaetigtAt) } }) + +const dokument = (projectId = 'p1') => aw.docs('oeffnungszeiten').get(`f_${projectId}_zeiten`) +const logEintraege = () => [...aw.docs('customerViewLog').values()] +const url = (projectId = 'p1', rest = '') => `/api/zeiten/${projectId}${rest}` +const get = (projectId, opts = { cookie }) => send('GET', url(projectId), opts) +const put = (projectId, body, opts = { cookie }) => send('PUT', url(projectId), { ...opts, body }) +/** Speichert ueber die Route (Version aus der Datenbank) und liefert die Antwort. */ +const speichere = async (projectId, daten, opts) => { + const version = dokument(projectId)?.version ?? 0 + const res = await put(projectId, { version, daten }, opts) + assert.equal(res.status, 200, JSON.stringify(res.body)) + return res +} + +beforeEach(async () => { + for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customerViewLog', 'websiteProjects']) { + for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) + } + resetZeitenCacheForTests() + resetZeitenRoutesForTests() + setzeUhrFuerTests(() => JETZT) + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + + for (const [id, kunde] of [['p1', KUNDE], ['p2', KUNDE], ['p3', 'c2'], ['p4', KUNDE], ['p5', KUNDE], ['p6', KUNDE], ['p7', KUNDE], ['p8', KUNDE]]) { + aw.seed('websiteProjects', { $id: id, customerId: kunde, projectName: `Projekt ${id}` }) + } + aw.seed('portalFeatures', featureDoc('p1')) // active + aw.seed('portalFeatures', featureDoc('p2', { state: 'off', enabled: false })) + aw.seed('portalFeatures', featureDoc('p3', { customerId: 'c2' })) // fremdes Projekt + aw.seed('portalFeatures', featureDoc('p4')) // active; abgeschalteteMitDaten() speichert Zeiten und schaltet danach ab + aw.seed('portalFeatures', featureDoc('p5', { state: 'disabled', enabled: false })) // disabled ohne Daten + // p6: Projekt ohne Funktionsdokument + aw.seed('portalFeatures', featureDoc('p7', { state: 'ordered', enabled: false })) + aw.seed('portalFeatures', featureDoc('p8', { state: 'setup', enabled: false })) +}) +afterEach(() => mock.restoreAll()) + +/** p4: Daten gespeichert (solange aktiv), danach abgeschaltet. */ +async function abgeschalteteMitDaten() { + await speichere('p4', eingabe()) + await updateDocument('portalFeatures', 'f_p4_zeiten', { state: 'disabled', enabled: false }) +} + +// ---------------------------------------------------------------- Zugang + +test('ohne Anmeldung ⇒ 401', async () => { + assert.equal((await send('GET', url('p1'))).status, 401) + assert.equal((await send('PUT', url('p1'), { body: { version: 0, daten: eingabe() } })).status, 401) + assert.equal((await send('GET', url('p1', '/vorschau'))).status, 401) +}) + +test('fremdes Projekt ⇒ 403 (GET, PUT, Vorschau), auch bei unbrauchbarer ID', async () => { + for (const projectId of ['p3', 'gibtsnicht', 'x'.repeat(80), '.hidden', '..%2Fcustomers%2Fc1', 'p1%00', 'p1%20']) { + for (const [method, pfad, body] of [['GET', url(projectId)], ['PUT', url(projectId), { version: 0, daten: eingabe() }], ['GET', url(projectId, '/vorschau')]]) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 403, `${method} ${pfad}`) + assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.') + } + } + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +test('ohne Funktion (off oder kein Dokument) ⇒ 403, nichts wird angelegt', async () => { + const vorher = aw.docs('portalFeatures').size + for (const projectId of ['p2', 'p6']) { + for (const [method, pfad, body] of [['GET', url(projectId)], ['PUT', url(projectId), { version: 0, daten: eingabe() }], ['GET', url(projectId, '/vorschau')]]) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 403, `${method} ${pfad}`) + assert.match(res.body.error, /nicht eingeschaltet/) + } + } + assert.equal(aw.docs('portalFeatures').size, vorher) + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +test('Funktion eines anderen Kunden am eigenen Projekt zählt nicht (Dokument gehört c2) ⇒ 403', async () => { + aw.seed('portalFeatures', featureDoc('p6', { customerId: 'c2' })) + assert.equal((await get('p6')).status, 403) +}) + +test('bestellt und im Aufbau (ordered, setup): lesen und speichern', async () => { + for (const projectId of ['p7', 'p8']) { + const vorher = await get(projectId) + assert.equal(vorher.status, 200, projectId) + assert.equal(vorher.body.state, aw.docs('portalFeatures').get(`f_${projectId}_zeiten`).state) + const res = await put(projectId, { version: 0, daten: eingabe() }) + assert.equal(res.status, 200, `${projectId}: ${JSON.stringify(res.body)}`) + assert.equal(res.body.version, 1) + } +}) + +test('Antworten sind nie zwischenspeicherbar', async () => { + assert.equal((await get('p1')).headers.get('cache-control'), 'no-store') + assert.equal((await put('p1', { version: 0, daten: eingabe() })).headers.get('cache-control'), 'no-store') + assert.equal((await get('p3')).headers.get('cache-control'), 'no-store') +}) + +// ---------------------------------------------------------------- Laden + +test('GET vor dem ersten Speichern: daten null, version 0, vorschau null, Feiertage und leerer Google-Text', async () => { + const res = await get('p1') + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body).sort(), ['daten', 'feiertage', 'googleText', 'kundenansicht', 'state', 'version', 'vorschau']) + assert.equal(res.body.state, 'active') + assert.equal(res.body.daten, null) + assert.equal(res.body.version, 0) + assert.equal(res.body.kundenansicht, false) + assert.equal(res.body.vorschau, null) + assert.equal(res.body.googleText, '') + assert.ok(res.body.feiertage.length > 5, 'Feiertage der nächsten 12 Monate auch ohne gespeicherte Daten (Bundesland RP)') + assert.equal(aw.docs('oeffnungszeiten').size, 0, 'Lesen legt nichts an') +}) + +test('GET liefert Daten, Version, Vorschau und Google-Text des Hauptplans', async () => { + await speichere('p1', eingabe()) + const res = await get('p1') + assert.equal(res.status, 200) + assert.equal(res.body.version, 1) + assert.equal(res.body.daten.plaene[0].kurz, 'standard') + assert.deepEqual(res.body.vorschau, { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' }) + assert.match(res.body.googleText, /^Montag: 09:00–18:00\nDienstag: geschlossen\n/) +}) + +test('Feiertage: genau die nächsten 12 Monate (heute bis heute + 366 Tage) im Bundesland der Daten', async () => { + const rp = (await get('p1')).body.feiertage + assert.ok(rp.every((f) => f.tag >= '2026-10-05' && f.tag <= '2027-10-06'), JSON.stringify(rp.map((f) => f.tag))) + assert.deepEqual(rp.map((f) => f.tag), [...rp.map((f) => f.tag)].sort()) + for (const f of rp) assert.deepEqual(Object.keys(f).sort(), ['art', 'name', 'tag']) + assert.ok(rp.some((f) => f.tag === '2026-12-25' && f.art === 'feiertag' && /Weihnachtstag/.test(f.name))) + assert.ok(rp.some((f) => f.tag === '2026-12-24' && f.art === 'pruefen'), 'Heiligabend: bitte prüfen') + assert.ok(rp.some((f) => f.tag === '2027-10-03'), 'letzter Tag der Spanne enthält die Einheit') + assert.ok(!rp.some((f) => f.tag === '2026-10-31'), 'Reformationstag gilt in RP nicht') + + await speichere('p1', eingabe({ land: 'BB' })) + const bb = (await get('p1')).body.feiertage + assert.ok(bb.some((f) => f.tag === '2026-10-31' && f.art === 'feiertag'), 'Reformationstag gilt in Brandenburg') +}) + +test('Daten ohne lesbaren Inhalt (nur Dokument): wie vor dem ersten Speichern, aber mit der Version des Dokuments', async () => { + aw.seed('oeffnungszeiten', { $id: 'f_p1_zeiten', customerId: KUNDE, projectId: 'p1', featureId: 'f_p1_zeiten', daten: '{kaputt', version: 3, teamVersion: 0 }) + const res = await get('p1') + assert.equal(res.status, 200) + assert.equal(res.body.daten, null) + assert.equal(res.body.version, 3) + assert.equal(res.body.vorschau, null) + const gespeichert = await put('p1', { version: 3, daten: eingabe() }) + assert.equal(gespeichert.status, 200, JSON.stringify(gespeichert.body)) + assert.equal(gespeichert.body.version, 4) +}) + +test('Fehler von Appwrite werden zu 500 ohne Rohtext', async () => { + aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'geheimer Rohtext' }) + const res = await get('p1') + assert.equal(res.status, 500) + assert.deepEqual(res.body, { error: 'Interner Serverfehler' }) + assert.ok(!JSON.stringify(res.body).includes('geheim')) +}) + +// ---------------------------------------------------------------- Speichern + +test('PUT speichert und liefert vorschau und googleText (wie GET)', async () => { + const res = await put('p1', { version: 0, daten: eingabe() }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.state, 'active') + assert.equal(res.body.version, 1) + assert.equal(res.body.kundenansicht, false) + assert.equal(res.body.vorschau.offen, true) + assert.equal(res.body.vorschau.text, 'Jetzt geöffnet · schließt um 18 Uhr') + assert.match(res.body.googleText, /^Montag: 09:00–18:00/) + assert.ok(res.body.feiertage.length > 5) + assert.deepEqual(JSON.parse(dokument().daten), res.body.daten, 'gespeichert wird das Normalisierte, wie geantwortet') + assert.equal(dokument().version, 1) + assert.equal(dokument().updatedBy, 'kunde') + assert.equal(dokument().customerId, KUNDE) + assert.equal(new Date(dokument().updatedAt).getTime(), JETZT.getTime(), 'die Uhr der Route gilt') + assert.deepEqual((await get('p1')).body, res.body, 'GET danach liefert dieselbe Antwort') + assert.equal(logEintraege().length, 0, 'der Kunde selbst wird nicht protokolliert') +}) + +test('PUT mit alter Version ⇒ 409 ZEITEN_VERALTET und nichts wird überschrieben', async () => { + await speichere('p1', eingabe()) + await speichere('p1', eingabe({ plaene: [plan({ name: 'Zwei' })] })) + const vorher = JSON.stringify(dokument()) + for (const version of [0, 1, 3, undefined, null, '2', 2.5]) { + const res = await put('p1', { version, daten: eingabe({ plaene: [plan({ name: 'Drei' })] }) }) + assert.equal(res.status, 409, `version ${String(version)}`) + assert.equal(res.body.code, 'ZEITEN_VERALTET') + assert.equal(res.body.error, 'Inzwischen geändert – bitte neu laden.') + } + assert.equal((await send('PUT', url('p1'), { cookie, body: { daten: eingabe() } })).status, 409, 'ohne Version') + assert.equal(JSON.stringify(dokument()), vorher) +}) + +test('PUT mit ungültigen Zeiten ⇒ 400 mit Fehlern je Feld, nichts gespeichert', async () => { + const res = await put('p1', { version: 0, daten: eingabe({ land: 'XX' }) }) + assert.equal(res.status, 400) + assert.equal(typeof res.body.error, 'string') + assert.equal(typeof res.body.fehler.land, 'string') + assert.equal(aw.docs('oeffnungszeiten').size, 0) + + for (const daten of [undefined, null, 'text', [], 5]) { + const kaputt = await put('p1', { version: 0, daten }) + assert.equal(kaputt.status, 400, JSON.stringify(daten)) + assert.equal(typeof kaputt.body.fehler.daten, 'string') + } + assert.equal((await send('PUT', url('p1'), { cookie, body: '{kaputt', headers: { 'content-type': 'application/json' } })).status, 400) + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +test('PUT: veraltete Version gewinnt vor falscher Eingabe (409 statt 400)', async () => { + await speichere('p1', eingabe()) + const res = await put('p1', { version: 0, daten: eingabe({ land: 'XX' }) }) + assert.equal(res.status, 409) +}) + +test('der Zugriff kommt nur aus der URL: projectId im Body oder in der Query ändert nichts', async () => { + const res = await send('PUT', `${url('p1')}?projectId=p3`, { cookie, body: { projectId: 'p3', version: 0, daten: eingabe() } }) + assert.equal(res.status, 200) + assert.ok(dokument('p1')) + assert.equal(dokument('p3'), undefined) +}) + +// ---------------------------------------------------------------- Abgeschaltet + +test('disabled mit Daten: GET ja, PUT 403', async () => { + await abgeschalteteMitDaten() + const gelesen = await get('p4') + assert.equal(gelesen.status, 200) + assert.equal(gelesen.body.state, 'disabled') + assert.equal(gelesen.body.daten.plaene[0].kurz, 'standard') + assert.equal(gelesen.body.version, 1) + assert.equal((await send('GET', url('p4', '/vorschau?zeit=2026-10-05T10:00'), { cookie })).status, 200) + + const vorher = JSON.stringify(dokument('p4')) + const res = await put('p4', { version: 1, daten: eingabe({ plaene: [plan({ name: 'Neu' })] }) }) + assert.equal(res.status, 403) + assert.match(res.body.error, /abgeschaltet/) + assert.equal(JSON.stringify(dokument('p4')), vorher) +}) + +test('disabled ohne gespeicherte Daten: nichts zu sehen, GET und PUT 403', async () => { + assert.equal((await get('p5')).status, 403) + assert.equal((await put('p5', { version: 0, daten: eingabe() })).status, 403) + assert.equal((await send('GET', url('p5', '/vorschau'), { cookie })).status, 403) + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +// ---------------------------------------------------------------- Kundenansicht und WhatsApp + +test('Kundenansicht: GET zeigt kundenansicht true und wird nicht protokolliert', async () => { + const res = await get('p1', kv()) + assert.equal(res.status, 200) + assert.equal(res.body.kundenansicht, true) + assert.equal(logEintraege().length, 0) +}) + +test('PUT in der Kundenansicht ist erlaubt und wird protokolliert (zeiten_gespeichert)', async () => { + const res = await put('p1', { version: 0, daten: eingabe() }, kv()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.kundenansicht, true) + assert.equal(dokument().updatedBy, 'team:u_staff2', 'der Mitarbeiter steht als Änderer im Dokument') + assert.equal(dokument().version, 1) + const log = logEintraege() + assert.equal(log.length, 1) + assert.equal(log[0].action, 'zeiten_gespeichert') + assert.equal(log[0].customerId, KUNDE) + assert.equal(log[0].staffName, 'Andrej') + assert.equal(log[0].staffUserId, 'u_staff2') + assert.match(log[0].detail, /Projekt p1/) + assert.match(log[0].detail, /1/, 'Version im Detail') +}) + +test('PUT in der Kundenansicht: kein Protokoll bei Fehlern (409, 400, 403)', async () => { + await speichere('p1', eingabe(), kv()) + const vorher = logEintraege().length + assert.equal((await put('p1', { version: 0, daten: eingabe() }, kv())).status, 409) + assert.equal((await put('p1', { version: 1, daten: eingabe({ land: 'XX' }) }, kv())).status, 400) + assert.equal((await put('p5', { version: 0, daten: eingabe() }, kv())).status, 403) // abgeschaltet + assert.equal(logEintraege().length, vorher) +}) + +test('PUT in einer Kundenansicht ohne passende Kennung wird vom Wächter gesperrt', async () => { + const res = await put('p1', { version: 0, daten: eingabe() }, { cookie: kundenansichtCookie() }) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'view_replaced') + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +test('Kundenansicht darf whatsapp.bestaetigtAt nicht neu setzen ⇒ 403, nichts gespeichert', async () => { + const res = await put('p1', { version: 0, daten: mitWhatsapp('2026-10-05T07:00:00.000Z') }, kv()) + assert.equal(res.status, 403) + assert.match(res.body.error, /Kundenansicht/) + assert.equal(aw.docs('oeffnungszeiten').size, 0) + assert.equal(logEintraege().length, 0) + + // auch ein anderer Wert als der gespeicherte, und ein Wert statt "nichts" bei bisher ohne WhatsApp + await speichere('p1', mitWhatsapp('2026-10-05T07:00:00.000Z')) // der Kunde selbst + const gespeichert = JSON.parse(dokument().daten).kontakt.whatsapp.bestaetigtAt + const aendern = await put('p1', { version: 1, daten: mitWhatsapp('2026-10-05T07:00:01.000Z') }, kv()) + assert.equal(aendern.status, 403) + assert.equal(JSON.parse(dokument().daten).kontakt.whatsapp.bestaetigtAt, gespeichert) + assert.equal(dokument().version, 1) +}) + +test('Kundenansicht: unveränderter bestaetigtAt (auch in anderer Schreibweise) bleibt erlaubt und erhalten', async () => { + await speichere('p1', mitWhatsapp('2026-10-05T07:00:00.000Z')) // der Kunde selbst: der Server setzt die Serverzeit + const stand = JSON.parse(dokument().daten).kontakt.whatsapp.bestaetigtAt + assert.equal(stand, JETZT.toISOString()) + + const geaendert = eingabe({ plaene: [plan({ name: 'Neu' })], kontakt: { an: true, telefon: '', adresse: '', whatsapp: whatsapp(stand) } }) + const res = await put('p1', { version: 1, daten: geaendert }, kv()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.daten.kontakt.whatsapp.bestaetigtAt, stand) + assert.equal(res.body.daten.plaene[0].name, 'Neu') + + const andereSchreibweise = mitWhatsapp('2026-10-05T10:00:00+02:00') // derselbe Zeitpunkt wie 08:00:00.000Z + const zweite = await put('p1', { version: 2, daten: andereSchreibweise }, kv()) + assert.equal(zweite.status, 200, JSON.stringify(zweite.body)) + assert.equal(zweite.body.daten.kontakt.whatsapp.bestaetigtAt, stand) +}) + +test('Kundenansicht: WhatsApp ohne Bestätigung bleibt ein Eingabefehler (400), WhatsApp entfernen ist erlaubt', async () => { + const ohne = await put('p1', { version: 0, daten: mitWhatsapp(undefined) }, kv()) + assert.equal(ohne.status, 400) + assert.equal(typeof ohne.body.fehler['kontakt.whatsapp.bestaetigtAt'], 'string') + + await speichere('p1', mitWhatsapp('2026-10-05T07:00:00.000Z')) + const entfernt = await put('p1', { version: 1, daten: eingabe() }, kv()) + assert.equal(entfernt.status, 200) + assert.equal(entfernt.body.daten.kontakt.whatsapp, null) +}) + +test('Kundenansicht mit veralteter Version und fremdem bestaetigtAt ⇒ 409 (neu laden), nicht 403', async () => { + await speichere('p1', eingabe()) + await speichere('p1', mitWhatsapp('2026-10-05T07:00:00.000Z')) + const res = await put('p1', { version: 1, daten: mitWhatsapp('2026-10-05T07:30:00.000Z') }, kv()) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'ZEITEN_VERALTET') +}) + +test('der Kunde selbst: bestaetigtAt setzt immer der Server (Serverzeit), nie der Browser', async () => { + const res = await put('p1', { version: 0, daten: mitWhatsapp('1999-01-01T00:00:00.000Z') }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.daten.kontakt.whatsapp.bestaetigtAt, JETZT.toISOString()) + assert.equal(JSON.parse(dokument().daten).kontakt.whatsapp.bestaetigtAt, JETZT.toISOString()) + + // zweites Speichern mit dem gespeicherten Wert: bleibt (auch wenn die Uhr weiterläuft) + setzeUhrFuerTests(() => new Date('2026-10-06T08:00:00.000Z')) + const unveraendert = await put('p1', { version: 1, daten: mitWhatsapp(JETZT.toISOString()) }) + assert.equal(unveraendert.status, 200) + assert.equal(unveraendert.body.daten.kontakt.whatsapp.bestaetigtAt, JETZT.toISOString()) + + // abweichender Wert (neu bestätigt): Serverzeit von jetzt + const neu = await put('p1', { version: 2, daten: mitWhatsapp('2026-10-05T07:00:00.000Z') }) + assert.equal(neu.status, 200) + assert.equal(neu.body.daten.kontakt.whatsapp.bestaetigtAt, '2026-10-06T08:00:00.000Z') +}) + +test('der Kunde selbst: ohne Bestätigung (kein bestaetigtAt) gibt es 400, kein Ersatz durch die Serverzeit', async () => { + for (const wert of [undefined, null, '', false]) { + const res = await put('p1', { version: 0, daten: mitWhatsapp(wert) }) + assert.equal(res.status, 400, JSON.stringify(wert)) + assert.equal(typeof res.body.fehler['kontakt.whatsapp.bestaetigtAt'], 'string') + } + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +test('die Eingabe des Browsers wird nicht verändert: andere Kontaktfelder bleiben, whatsapp null und fehlende Kontaktdaten gehen durch', async () => { + const res = await put('p1', { version: 0, daten: eingabe({ kontakt: undefined }) }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.daten.kontakt.whatsapp, null) + const mit = await put('p1', { version: 1, daten: { ...mitWhatsapp('x'), kontakt: { an: true, telefon: '06301 1234', adresse: 'Hauptstr. 1', whatsapp: whatsapp('2026-10-01T00:00:00.000Z') } } }) + assert.equal(mit.status, 200, JSON.stringify(mit.body)) + assert.equal(mit.body.daten.kontakt.an, true) + assert.equal(mit.body.daten.kontakt.adresse, 'Hauptstr. 1') + assert.equal(mit.body.daten.kontakt.whatsapp.nummer, '491711234567') +}) + +// ---------------------------------------------------------------- Vorschau + +test('vorschau rechnet zur angefragten Berliner Uhrzeit (gespeicherte Daten)', async () => { + await speichere('p1', eingabe()) + const frage = async (zeit) => { + const res = await send('GET', url('p1', `/vorschau?zeit=${encodeURIComponent(zeit)}`), { cookie }) + assert.equal(res.status, 200, `${zeit}: ${JSON.stringify(res.body)}`) + assert.deepEqual(Object.keys(res.body).sort(), ['kurz', 'offen', 'text']) + return res.body + } + const montagMittag = await frage('2026-10-12T12:00') + assert.equal(montagMittag.offen, true) + assert.equal(montagMittag.text, 'Jetzt geöffnet · schließt um 18 Uhr') + + const montagAbend = await frage('2026-10-12T18:30') + assert.equal(montagAbend.offen, false) + assert.match(montagAbend.text, /^Geschlossen/) + + const dienstag = await frage('2026-10-13T12:00') + assert.equal(dienstag.offen, false) + + // Winterzeit (nach dem 25.10.): 09:00 Berliner Wanduhr gilt, nicht UTC + assert.equal((await frage('2026-10-26T09:00')).offen, true) + assert.equal((await frage('2026-10-26T08:59')).offen, false) + // Sommerzeit-Umstellung im März: 02:30 gibt es nicht, die Anfrage wird zu 03:00 (kein Fehler) + assert.equal((await frage('2027-03-28T02:30')).offen, false) +}) + +test('vorschau ohne zeit gilt für jetzt (Uhr der Route)', async () => { + await speichere('p1', eingabe()) + const res = await send('GET', url('p1', '/vorschau'), { cookie }) + assert.equal(res.status, 200) + assert.equal(res.body.offen, true) + assert.equal(res.body.text, 'Jetzt geöffnet · schließt um 18 Uhr') +}) + +test('vorschau: ungültige zeit ⇒ 400, vor dem ersten Speichern ⇒ 409', async () => { + await speichere('p1', eingabe()) + for (const zeit of ['gestern', '2026-10-12', '2026-10-12 12:00', '2026-10-12T12:00:00', '2026-10-12T24:00', '2026-10-12T12:60', '2026-02-30T12:00', '2026-13-01T12:00', '1999-10-12T12:00', '+012026-10-12T12:00']) { + const res = await send('GET', url('p1', `/vorschau?zeit=${encodeURIComponent(zeit)}`), { cookie }) + assert.equal(res.status, 400, zeit) + assert.equal(typeof res.body.error, 'string') + assert.equal(typeof res.body.fehler.zeit, 'string') + } + assert.equal((await send('GET', `${url('p1', '/vorschau')}?zeit=2026-10-12T12:00&zeit=2026-10-13T12:00`, { cookie })).status, 400, 'zweimal zeit') + + const leer = await send('GET', url('p7', '/vorschau?zeit=2026-10-12T12:00'), { cookie }) + assert.equal(leer.status, 409) + assert.equal(leer.body.code, 'keine_zeiten') +}) + +test('vorschau ist in der Kundenansicht lesbar', async () => { + await speichere('p1', eingabe()) + const res = await send('GET', url('p1', '/vorschau?zeit=2026-10-12T12:00'), kv()) + assert.equal(res.status, 200) + assert.equal(res.body.offen, true) +}) + +// ---------------------------------------------------------------- Limit + +test('61. Speichern in einer Stunde ⇒ 429, je Kunde gezählt; Lesen zählt nicht', async () => { + for (let i = 0; i < 20; i += 1) assert.equal((await get('p1')).status, 200) + for (let i = 0; i < 60; i += 1) { + const res = await put('p1', { version: 99, daten: eingabe() }) // 409, zählt trotzdem + assert.equal(res.status, 409, `Versuch ${i + 1}`) + } + const zuViele = await put('p1', { version: 0, daten: eingabe() }) + assert.equal(zuViele.status, 429) + assert.match(zuViele.body.error, /Zu viele Änderungen/) + assert.equal(aw.docs('oeffnungszeiten').size, 0, 'der 61. Versuch speichert nichts') + assert.equal((await get('p1')).status, 200) + + // ein anderer Kunde ist nicht betroffen + aw.seed('websiteProjects', { $id: 'p9', customerId: 'c2', projectName: 'Anderer' }) + aw.seed('portalFeatures', featureDoc('p9', { customerId: 'c2' })) + assert.equal((await put('p9', { version: 0, daten: eingabe() }, { cookie: customerCookie('c2') })).status, 200) +}) + +test('das Limit gilt in der Kundenansicht je Kunde und nach einer Stunde läuft es ab', async () => { + for (let i = 0; i < 60; i += 1) await put('p1', { version: 99, daten: eingabe() }, kv()) + assert.equal((await put('p1', { version: 0, daten: eingabe() }, kv())).status, 429) + setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3600000 + 1)) + assert.equal((await put('p1', { version: 0, daten: eingabe() })).status, 200) +}) + +test('Versuche ohne Zugriff (403) zählen nicht gegen das Limit', async () => { + for (let i = 0; i < 70; i += 1) assert.equal((await put('p2', { version: 0, daten: eingabe() })).status, 403) + assert.equal((await put('p1', { version: 0, daten: eingabe() })).status, 200) +})