Ö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,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()]
|
||||
const seedLog = (id, customerId, createdAt, action = 'geoeffnet') =>
|
||||
aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt })
|
||||
|
||||
test('VIEW_ACTIONS: genau die zehn Aktionen', () => {
|
||||
test('VIEW_ACTIONS: genau die elf Aktionen', () => {
|
||||
assert.deepEqual(VIEW_ACTIONS, [
|
||||
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
|
||||
'termin_einstellungen', 'termin_aktion', 'termin_manuell',
|
||||
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'zeiten_gespeichert',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const { createStaffPreviewToken } = await import('../server/services/staffPrevie
|
||||
|
||||
// Kunden-Router mit denselben Mount-Pfaden wie server/index.js
|
||||
const termineRouter = (await import('../server/routes/termine.js')).default
|
||||
const zeitenRouter = (await import('../server/routes/zeiten.js')).default
|
||||
const MOUNTS = [
|
||||
['/api/auth', (await import('../server/routes/auth.js')).default],
|
||||
['/api/projects', (await import('../server/routes/projects.js')).default],
|
||||
@@ -38,6 +39,7 @@ const MOUNTS = [
|
||||
['/api/abo', (await import('../server/routes/abo.js')).default],
|
||||
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
|
||||
['/api/termine', termineRouter],
|
||||
['/api/zeiten', zeitenRouter],
|
||||
]
|
||||
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
|
||||
const oeffentlich = (await import('../server/routes/publicTermine.js')).default
|
||||
@@ -118,7 +120,7 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a
|
||||
}
|
||||
})
|
||||
|
||||
test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => {
|
||||
test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => {
|
||||
const ok = [
|
||||
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
||||
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
|
||||
@@ -126,9 +128,10 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
|
||||
['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
|
||||
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
|
||||
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
|
||||
['PUT', '/api/zeiten/abc'], ['PUT', '/api/zeiten/abc/'],
|
||||
]
|
||||
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
|
||||
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard')
|
||||
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 1, 'genau sechs Termine-Eintraege und ein Zeiten-Eintrag dazu, keine Wildcard')
|
||||
for (const [m, p] of [
|
||||
['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'],
|
||||
// Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen
|
||||
@@ -140,6 +143,9 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
|
||||
['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'],
|
||||
['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'],
|
||||
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'],
|
||||
// Oeffnungszeiten: nur PUT auf das Projekt, sonst nichts
|
||||
['PUT', '/api/zeiten'], ['PUT', '/api/zeiten/'], ['PUT', '/api/zeiten/abc/vorschau'], ['PUT', '/api/zeiten/abc/def/ghi'], ['PUT', '/api/zeitenX/abc'],
|
||||
['POST', '/api/zeiten/abc'], ['PATCH', '/api/zeiten/abc'], ['DELETE', '/api/zeiten/abc'], ['POST', '/api/zeiten'],
|
||||
]) {
|
||||
assert.ok(!allowed(m, p), `${m} ${p}`)
|
||||
}
|
||||
@@ -196,6 +202,31 @@ test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf d
|
||||
}
|
||||
})
|
||||
|
||||
// Oeffnungszeiten: die Erlaubnisliste und die echten Schreib-Routen decken sich genau (eine Route, ein Eintrag); jedes andere
|
||||
// Schreiben unter /api/zeiten (auch ohne Route, auch Gross-/Kleinschreibung) sperrt der Waechter, normale Kunden beruehrt er nicht.
|
||||
test('Router-Abgleich: genau eine Schreib-Route der Öffnungszeiten (PUT /:projectId) steht auf der Erlaubnisliste', async () => {
|
||||
const routen = writeRoutes([['/api/zeiten', zeitenRouter]])
|
||||
assert.deepEqual(routen.map(({ method, path: p }) => `${method} ${p}`), ['PUT /api/zeiten/x'], 'der Router schreibt nur per PUT /:projectId')
|
||||
const eintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('zeiten'))
|
||||
assert.equal(eintraege.length, 1)
|
||||
assert.ok(allowed('PUT', '/api/zeiten/x'))
|
||||
|
||||
for (const [method, p] of [['POST', '/api/zeiten/p1'], ['PATCH', '/api/zeiten/p1'], ['DELETE', '/api/zeiten/p1'], ['PUT', '/api/zeiten/p1/vorschau'], ['PUT', '/api/zeiten'], ['POST', '/API/Zeiten/p1']]) {
|
||||
const res = await call(method, p, { ...kv(), body: {} })
|
||||
assert.equal(res.status, 403, `${method} ${p}`)
|
||||
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
|
||||
}
|
||||
// ohne Kennung im Header gilt auch der erlaubte Pfad nicht (wie bei allen Schreib-Routen)
|
||||
const ohneKennung = await call('PUT', '/api/zeiten/p1', { cookie: kundenansichtCookie(), body: {} })
|
||||
assert.equal(ohneKennung.status, 409)
|
||||
assert.equal(ohneKennung.body.code, 'view_replaced')
|
||||
// der erlaubte Pfad erreicht den Router (hier: das Projekt gehoert c1, die Funktion gibt es nicht => 403 der Route, nicht des Waechters)
|
||||
const durchgelassen = await call('PUT', '/api/zeiten/p1', { ...kv(), body: { version: 0, daten: {} } })
|
||||
assert.equal(durchgelassen.status, 403)
|
||||
assert.notEqual(durchgelassen.body.code, 'view_blocked')
|
||||
assert.match(durchgelassen.body.error, /nicht eingeschaltet/)
|
||||
})
|
||||
|
||||
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
|
||||
const res = await call('POST', '/api/chat/read', kv())
|
||||
assert.equal(res.status, 403)
|
||||
|
||||
@@ -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