Ö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:
1 parent
097036c9a5
commit
7bf2ad2665
7 files changed
+801
-4
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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\/?$/],
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user