Files
ClaudeandClaude Opus 5.5 7bf2ad2665 Ö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>
2026-10-05 01:01:24 +00:00

540 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})