Öffnungszeiten: Kundenschnittstelle zum Bearbeiten

GET/PUT /api/zeiten/:projectId und GET .../vorschau für den Editor im Portal.
Zugang nach Funktionszustand (ordered/setup/active lesen und speichern,
disabled mit Daten nur lesen), Version 0 vor dem ersten Speichern, 409
ZEITEN_VERALTET, 60 Speichervorgänge je Kunde und Stunde. Die Kundenansicht darf
speichern (Protokoll zeiten_gespeichert), den WhatsApp-Haken setzt nur der
Kunde selbst: bestaetigtAt kommt vom Server (R12), in der Kundenansicht 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 01:01:24 +00:00
1 parent 097036c9a5
commit 7bf2ad2665
7 files changed
+801 -4

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js' import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js' import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js' import termineRoutes from './routes/termine.js'
import zeitenRoutes from './routes/zeiten.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js' import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.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/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes) app.use('/api/funktionen', funktionenRoutes)
app.use('/api/termine', termineRoutes) app.use('/api/termine', termineRoutes)
app.use('/api/zeiten', zeitenRoutes)
app.use('/api/team', teamRoutes) app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes) app.use('/api/admin/website-projects', adminPreviewRoutes)
+3
View File
@@ -24,6 +24,9 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/], ['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/], ['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/], ['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\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/], ['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/], ['POST', /^\/api\/auth\/login\/?$/],
+221
View File
@@ -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:<projectId>` 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: <Text>, fehler?: { <pfad>: 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
+1
View File
@@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [
'termin_einstellungen', 'termin_einstellungen',
'termin_aktion', 'termin_aktion',
'termin_manuell', 'termin_manuell',
'zeiten_gespeichert',
] ]
const DETAIL_MAX = 300 const DETAIL_MAX = 300
+2 -2
View File
@@ -17,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()]
const seedLog = (id, customerId, createdAt, action = 'geoeffnet') => const seedLog = (id, customerId, createdAt, action = 'geoeffnet') =>
aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt }) 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, [ assert.deepEqual(VIEW_ACTIONS, [
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht', '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',
]) ])
}) })
+33 -2
View File
@@ -27,6 +27,7 @@ const { createStaffPreviewToken } = await import('../server/services/staffPrevie
// Kunden-Router mit denselben Mount-Pfaden wie server/index.js // Kunden-Router mit denselben Mount-Pfaden wie server/index.js
const termineRouter = (await import('../server/routes/termine.js')).default const termineRouter = (await import('../server/routes/termine.js')).default
const zeitenRouter = (await import('../server/routes/zeiten.js')).default
const MOUNTS = [ const MOUNTS = [
['/api/auth', (await import('../server/routes/auth.js')).default], ['/api/auth', (await import('../server/routes/auth.js')).default],
['/api/projects', (await import('../server/routes/projects.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/abo', (await import('../server/routes/abo.js')).default],
['/api/funktionen', (await import('../server/routes/funktionen.js')).default], ['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
['/api/termine', termineRouter], ['/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 // 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 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 = [ const ok = [
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'], ['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'], ['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'], ['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'], ['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'], ['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}`) 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 [ 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'], ['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 // 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'], ['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/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'],
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'], ['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}`) 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 () => { test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
const res = await call('POST', '/api/chat/read', kv()) const res = await call('POST', '/api/chat/read', kv())
assert.equal(res.status, 403) assert.equal(res.status, 403)
+539
View File
@@ -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)
})