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>
540 lines
27 KiB
JavaScript
540 lines
27 KiB
JavaScript
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)
|
||
})
|