loeschKandidaten liest eine Seite (Standard 100, aelteste Bezugszeit zuerst, optional je Funktion) ohne Gastfelder; die Frist steht in der Abfrage. arbeiteSeiten (Seite lesen, bearbeiten, neu lesen) endet bei leerer Seite, am Deckel (config.termine.loeschDeckel, 2000 je Status und Lauf, Rest an den Folgetagen, gezaehlt als gedeckelt) oder bei einer Seite ohne Fortschritt. verfallen, abgesagt und abgelehnt laufen darueber, unter der Buchungssperre werden Status und Bezugszeit frisch geprueft. taeglicherJob liefert gedeckelt getrennt vom Loeschzaehler. Neue Indizes idx_status_ends und idx_status_updated auf termineBookings (scripts/termine-setup.mjs legt sie an). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
325 lines
16 KiB
JavaScript
325 lines
16 KiB
JavaScript
import { test, after } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { startFakeStripe } from './support/fakeStripe.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
import { TERMINE_COLLECTIONS, TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
|
|
|
// Das Skript und die Konfiguration laden erst nach useTestEnv (Endpunkt des Nachbaus), daher dynamische Imports
|
|
const aw = await startAppwriteMock({ schema: { termineConfig: schemaKeys('termineConfig') } })
|
|
const st = await startFakeStripe()
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
const { config } = await import('../server/config.js')
|
|
const { main } = await import('../scripts/termine-setup.mjs')
|
|
const { createDocument, updateDocument, getDocument } = await import('../server/services/appwriteAdmin.js')
|
|
after(() => Promise.all([aw.close(), st.close()]))
|
|
|
|
const NAMES = ['termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox']
|
|
const keysOf = (name) => TERMINE_COLLECTIONS[name].attributes.map((a) => a.key)
|
|
|
|
/**
|
|
* Appwrite-Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen
|
|
* Attributen (wie Appwrite). `initial` = { collectionId: { attrs: [...], indexes: [...] } }.
|
|
*/
|
|
function fakeClient(initial = {}) {
|
|
const state = new Map(
|
|
Object.entries(initial).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]),
|
|
)
|
|
const calls = { collections: [], attrs: [], indexes: [] }
|
|
const need = (id) => {
|
|
if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 })
|
|
return state.get(id)
|
|
}
|
|
return {
|
|
state,
|
|
calls,
|
|
async collectionExists(id) {
|
|
return state.has(id)
|
|
},
|
|
async ensureCollection(id, name) {
|
|
if (state.has(id)) return false
|
|
state.set(id, { attrs: new Map(), indexes: new Set() })
|
|
calls.collections.push({ id, name })
|
|
return true
|
|
},
|
|
async listAttributes(id) {
|
|
return [...need(id).attrs.keys()].map((key) => ({ key }))
|
|
},
|
|
async createAttribute(id, attr) {
|
|
const collection = need(id)
|
|
if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`)
|
|
collection.attrs.set(attr.key, attr)
|
|
calls.attrs.push(`${id}.${attr.key}`)
|
|
},
|
|
async waitForAttribute(id, key) {
|
|
assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`)
|
|
},
|
|
async listIndexes(id) {
|
|
return [...need(id).indexes].map((key) => ({ key }))
|
|
},
|
|
async createIndex(id, index) {
|
|
const collection = need(id)
|
|
for (const key of index.attributes) {
|
|
if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`)
|
|
}
|
|
collection.indexes.add(index.key)
|
|
calls.indexes.push(`${id}.${index.key}`)
|
|
},
|
|
}
|
|
}
|
|
|
|
const quiet = () => {}
|
|
const expectedAttrs = NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`))
|
|
const expectedIndexes = NAMES.flatMap((name) => TERMINE_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`))
|
|
|
|
test('Schema: Attribute je Collection wie in der Spezifikation', () => {
|
|
assert.deepEqual(Object.keys(TERMINE_COLLECTIONS), NAMES)
|
|
assert.deepEqual(keysOf('termineConfig'), ['customerId', 'projectId', 'einstellung', 'version', 'updatedAt', 'updatedBy'])
|
|
assert.deepEqual(keysOf('termineBookings'), [
|
|
'featureId', 'customerId', 'projectId', 'leistungId', 'leistungName', 'ressourceId', 'ressourceName',
|
|
'startsAt', 'endsAt', 'blockEndsAt', 'status', 'quelle',
|
|
'gastName', 'gastEmail', 'gastTelefon', 'gastNachricht', 'gastZusatz',
|
|
'erinnerung', 'erinnerungGesendetAt', 'heldUntil', 'verifyHash', 'manageHash',
|
|
'bestaetigtAt', 'angenommenAt', 'abgesagtAt', 'abgesagtVon', 'absageGrund', 'widerrufenAt', 'nachweis',
|
|
'preisCent', 'preisArt', 'buchungsart', 'verbraucher',
|
|
'zahlart', 'zahlStatus', 'stripeSessionId', 'stripePaymentIntent',
|
|
'icsSequenz', 'createdAt', 'updatedAt',
|
|
])
|
|
assert.deepEqual(keysOf('termineClaims'), ['featureId', 'ressourceId', 'bookingId', 'zelle', 'bis'])
|
|
assert.deepEqual(keysOf('termineOutbox'), [
|
|
'featureId', 'bookingId', 'art', 'an', 'fromName', 'replyTo', 'subject', 'html', 'text', 'ics', 'icsMethod',
|
|
'status', 'versuche', 'naechsterVersuch', 'gesendetAt', 'fehler', 'createdAt',
|
|
])
|
|
})
|
|
|
|
test('Schema: Typen und Groessen', () => {
|
|
const byKey = (name) => Object.fromEntries(TERMINE_COLLECTIONS[name].attributes.map((a) => [a.key, a]))
|
|
const settings = byKey('termineConfig')
|
|
assert.equal(settings.einstellung.size, 200000)
|
|
assert.equal(settings.version.type, 'integer')
|
|
assert.equal(settings.updatedAt.type, 'datetime')
|
|
assert.equal(settings.updatedBy.size, 100)
|
|
assert.equal(settings.projectId.size, 64)
|
|
|
|
const bookings = byKey('termineBookings')
|
|
const sizes = {
|
|
featureId: 64, customerId: 64, projectId: 64, leistungName: 120, ressourceName: 80, status: 16, quelle: 16,
|
|
gastName: 100, gastEmail: 254, gastTelefon: 40, gastNachricht: 500, gastZusatz: 2000, verifyHash: 64, manageHash: 64,
|
|
abgesagtVon: 16, absageGrund: 300, nachweis: 4000, preisArt: 8, buchungsart: 8, zahlart: 8, zahlStatus: 16,
|
|
stripeSessionId: 128, stripePaymentIntent: 64,
|
|
}
|
|
for (const [key, size] of Object.entries(sizes)) {
|
|
assert.deepEqual([bookings[key].type, bookings[key].size], ['string', size], `termineBookings.${key}`)
|
|
}
|
|
for (const key of ['preisCent', 'icsSequenz']) assert.equal(bookings[key].type, 'integer', key)
|
|
for (const key of ['erinnerung', 'verbraucher']) assert.equal(bookings[key].type, 'boolean', key)
|
|
for (const key of ['startsAt', 'endsAt', 'blockEndsAt', 'heldUntil', 'erinnerungGesendetAt', 'bestaetigtAt', 'angenommenAt', 'abgesagtAt', 'widerrufenAt', 'createdAt', 'updatedAt']) {
|
|
assert.equal(bookings[key].type, 'datetime', key)
|
|
}
|
|
|
|
const outbox = byKey('termineOutbox')
|
|
assert.deepEqual(
|
|
[outbox.an.size, outbox.fromName.size, outbox.replyTo.size, outbox.subject.size, outbox.html.size, outbox.text.size, outbox.ics.size, outbox.icsMethod.size, outbox.fehler.size],
|
|
[254, 120, 254, 300, 100000, 50000, 20000, 8, 300],
|
|
)
|
|
assert.equal(outbox.versuche.type, 'integer')
|
|
const claims = byKey('termineClaims')
|
|
assert.deepEqual([claims.zelle.type, claims.bis.type], ['datetime', 'datetime'])
|
|
})
|
|
|
|
test('Schema: jedes Attribut ist optional, hat eine gueltige Form und einen gueltigen Schluessel', () => {
|
|
for (const name of NAMES) {
|
|
const { attributes } = TERMINE_COLLECTIONS[name]
|
|
assert.equal(new Set(attributes.map((a) => a.key)).size, attributes.length, `${name}: doppelte Schluessel`)
|
|
for (const attr of attributes) {
|
|
assert.match(attr.key, /^[A-Za-z][A-Za-z0-9]{0,35}$/, `${name}.${attr.key}`)
|
|
assert.equal(attr.required, false, `${name}.${attr.key}`)
|
|
assert.ok(['string', 'integer', 'boolean', 'datetime'].includes(attr.type), `${name}.${attr.key}: Typ ${attr.type}`)
|
|
if (attr.type === 'string') assert.ok(Number.isInteger(attr.size) && attr.size > 0, `${name}.${attr.key}: Groesse`)
|
|
else assert.equal('size' in attr, false, `${name}.${attr.key}: Groesse nur bei Text`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attributen', () => {
|
|
const indexes = (name) => TERMINE_COLLECTIONS[name].indexes.map((i) => [i.key, ...i.attributes])
|
|
assert.deepEqual(indexes('termineConfig'), [])
|
|
assert.deepEqual(indexes('termineBookings'), [
|
|
['idx_feature_start', 'featureId', 'startsAt'],
|
|
['idx_status_held', 'status', 'heldUntil'],
|
|
['idx_feature_email', 'featureId', 'gastEmail'],
|
|
['idx_verify', 'verifyHash'],
|
|
['idx_manage', 'manageHash'],
|
|
['idx_status_start', 'status', 'startsAt'],
|
|
['idx_status_ends', 'status', 'endsAt'],
|
|
['idx_status_updated', 'status', 'updatedAt'],
|
|
])
|
|
assert.deepEqual(indexes('termineClaims'), [['idx_booking', 'bookingId'], ['idx_bis', 'bis']])
|
|
assert.deepEqual(indexes('termineOutbox'), [['idx_status_next', 'status', 'naechsterVersuch']])
|
|
for (const name of NAMES) {
|
|
for (const index of TERMINE_COLLECTIONS[name].indexes) {
|
|
assert.equal(index.type, 'key')
|
|
assert.ok(index.attributes.every((key) => keysOf(name).includes(key)), `${name}.${index.key}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('Schema: TERMINE_DATETIMES nennt je Collection genau die datetime-Attribute', () => {
|
|
assert.deepEqual(TERMINE_DATETIMES, {
|
|
termineConfig: ['updatedAt'],
|
|
termineBookings: [
|
|
'startsAt', 'endsAt', 'blockEndsAt', 'erinnerungGesendetAt', 'heldUntil', 'bestaetigtAt', 'angenommenAt',
|
|
'abgesagtAt', 'widerrufenAt', 'createdAt', 'updatedAt',
|
|
],
|
|
termineClaims: ['zelle', 'bis'],
|
|
termineOutbox: ['naechsterVersuch', 'gesendetAt', 'createdAt'],
|
|
})
|
|
})
|
|
|
|
test('schemaKeys kennt die Termine-Collections: einstellung ja, data nein', () => {
|
|
const keys = schemaKeys('termineConfig')
|
|
assert.ok(keys.includes('einstellung'))
|
|
assert.ok(!keys.includes('data'))
|
|
assert.deepEqual(keys, keysOf('termineConfig'))
|
|
assert.deepEqual(schemaKeys('termineOutbox'), keysOf('termineOutbox'))
|
|
assert.ok(schemaKeys('termineOutbox').includes('icsMethod'))
|
|
assert.deepEqual(schemaKeys('termineClaims'), ['featureId', 'ressourceId', 'bookingId', 'zelle', 'bis'])
|
|
assert.ok(schemaKeys('termineBookings').includes('blockEndsAt'))
|
|
// bestehende Setup-Skripte funktionieren weiter
|
|
assert.ok(schemaKeys('subscriptions').length > 0)
|
|
assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/)
|
|
})
|
|
|
|
test('Rundreise: Einstellung mit einstellung/version/updatedBy anlegen, aendern und zuruecklesen', async () => {
|
|
const collection = config.collections.termineConfig
|
|
const einstellung = JSON.stringify({ schema: 1, betrieb: { name: 'Salon Müller' }, ressourcen: [] })
|
|
const created = await createDocument(
|
|
collection,
|
|
{ customerId: 'c1', projectId: 'p1', einstellung, version: 1, updatedAt: '2026-10-02T10:00:00.000Z', updatedBy: 'kunde' },
|
|
'feature-1',
|
|
)
|
|
assert.equal(created.$id, 'feature-1')
|
|
|
|
const geaendert = JSON.stringify({ schema: 1, betrieb: { name: 'Salon Müller & Söhne' }, ressourcen: [] })
|
|
await updateDocument(collection, 'feature-1', { einstellung: geaendert, version: 2, updatedAt: '2026-10-02T11:00:00.000Z', updatedBy: 'team' })
|
|
|
|
const doc = await getDocument(collection, 'feature-1')
|
|
assert.equal(doc.einstellung, geaendert)
|
|
assert.equal(doc.version, 2)
|
|
assert.equal(doc.updatedBy, 'team')
|
|
assert.equal(doc.customerId, 'c1')
|
|
assert.equal(JSON.parse(doc.einstellung).betrieb.name, 'Salon Müller & Söhne')
|
|
})
|
|
|
|
test('Rundreise: unbekanntes Attribut lehnt der Nachbau ab wie Appwrite (Schema-Pruefung greift)', async () => {
|
|
await assert.rejects(
|
|
() => createDocument(config.collections.termineConfig, { customerId: 'c1', fremd: 'x' }, 'feature-2'),
|
|
/Unknown attribute/,
|
|
)
|
|
})
|
|
|
|
test('Konfiguration: Collection-IDs der Termine-Collections', () => {
|
|
assert.deepEqual(
|
|
NAMES.map((name) => config.collections[name]),
|
|
NAMES,
|
|
)
|
|
})
|
|
|
|
test('main legt auf leerer Datenbank 4 Collections, alle Attribute und Indizes an', async () => {
|
|
const client = fakeClient()
|
|
const result = await main({ client, log: quiet })
|
|
assert.deepEqual(client.calls.collections, NAMES.map((id) => ({ id, name: id })))
|
|
assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort())
|
|
assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort())
|
|
// die Loeschfristen (Tagesjob) lesen ueber Status + Bezugszeit: beide Indizes werden angelegt
|
|
assert.ok(client.calls.indexes.includes('termineBookings.idx_status_ends'))
|
|
assert.ok(client.calls.indexes.includes('termineBookings.idx_status_updated'))
|
|
assert.equal(result.missing.length, expectedAttrs.length)
|
|
assert.deepEqual(result.missingCollections, NAMES)
|
|
assert.equal(result.missingIndexes.length, expectedIndexes.length)
|
|
// jedes Attribut mit Typ und Groesse im Anfragekoerper, Datum ohne Groesse
|
|
const bookings = client.state.get('termineBookings').attrs
|
|
assert.deepEqual(bookings.get('gastEmail'), { key: 'gastEmail', type: 'string', size: 254, required: false })
|
|
assert.deepEqual(bookings.get('startsAt'), { key: 'startsAt', type: 'datetime', required: false })
|
|
assert.deepEqual(bookings.get('icsSequenz'), { key: 'icsSequenz', type: 'integer', required: false })
|
|
assert.deepEqual(bookings.get('verbraucher'), { key: 'verbraucher', type: 'boolean', required: false })
|
|
})
|
|
|
|
test('main: zweiter Lauf legt nichts mehr an', async () => {
|
|
const client = fakeClient()
|
|
await main({ client, log: quiet })
|
|
client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0
|
|
const result = await main({ client, log: quiet })
|
|
assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] })
|
|
assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] })
|
|
})
|
|
|
|
test('main: legt nur fehlende Attribute und Indizes einer vorhandenen Collection an', async () => {
|
|
const client = fakeClient({
|
|
termineClaims: { attrs: ['featureId', 'ressourceId', 'bookingId'], indexes: ['idx_booking'] },
|
|
})
|
|
await main({ client, log: quiet })
|
|
assert.ok(!client.calls.collections.some((c) => c.id === 'termineClaims'))
|
|
assert.deepEqual(
|
|
client.calls.attrs.filter((k) => k.startsWith('termineClaims.')),
|
|
['termineClaims.zelle', 'termineClaims.bis'],
|
|
)
|
|
assert.deepEqual(
|
|
client.calls.indexes.filter((k) => k.startsWith('termineClaims.')),
|
|
['termineClaims.idx_bis'],
|
|
)
|
|
assert.equal(client.calls.collections.length, 3)
|
|
})
|
|
|
|
test('main: eine vorhandene termineBookings-Collection bekommt nur die beiden Loesch-Indizes nachgezogen (--check meldet sie)', async () => {
|
|
const vorhanden = {
|
|
termineBookings: {
|
|
attrs: keysOf('termineBookings'),
|
|
indexes: ['idx_feature_start', 'idx_status_held', 'idx_feature_email', 'idx_verify', 'idx_manage', 'idx_status_start'],
|
|
},
|
|
}
|
|
const lines = []
|
|
const pruefung = fakeClient(vorhanden)
|
|
const offen = await main({ check: true, client: pruefung, log: (line) => lines.push(line) })
|
|
assert.deepEqual(offen.missingIndexes.filter((i) => i.collection === 'termineBookings').map((i) => i.key), ['idx_status_ends', 'idx_status_updated'])
|
|
assert.ok(lines.some((line) => line.includes('Index termineBookings.idx_status_ends FEHLT')))
|
|
assert.ok(lines.some((line) => line.includes('Index termineBookings.idx_status_updated FEHLT')))
|
|
|
|
const client = fakeClient(vorhanden)
|
|
await main({ client, log: quiet })
|
|
assert.deepEqual(
|
|
client.calls.indexes.filter((k) => k.startsWith('termineBookings.')),
|
|
['termineBookings.idx_status_ends', 'termineBookings.idx_status_updated'],
|
|
)
|
|
assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('termineBookings.')), [])
|
|
})
|
|
|
|
test('main --check legt nichts an und meldet Fehlendes', async () => {
|
|
const client = fakeClient()
|
|
const lines = []
|
|
const result = await main({ check: true, client, log: (line) => lines.push(line) })
|
|
assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] })
|
|
assert.equal(client.state.size, 0)
|
|
assert.deepEqual(result.missingCollections, NAMES)
|
|
assert.equal(result.missing.length, expectedAttrs.length)
|
|
assert.equal(result.missingIndexes.length, expectedIndexes.length)
|
|
assert.ok(lines.some((line) => line.includes('termineBookings') && line.includes('FEHLT')))
|
|
})
|
|
|
|
test('main --check meldet bei vollstaendigem Stand nichts', async () => {
|
|
const client = fakeClient()
|
|
await main({ client, log: quiet })
|
|
const result = await main({ check: true, client, log: quiet })
|
|
assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] })
|
|
})
|
|
|
|
test('main --check meldet ein einzelnes fehlendes Attribut', async () => {
|
|
const client = fakeClient()
|
|
await main({ client, log: quiet })
|
|
client.state.get('termineOutbox').attrs.delete('icsMethod')
|
|
const result = await main({ check: true, client, log: quiet })
|
|
assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), ['termineOutbox.icsMethod'])
|
|
assert.deepEqual(result.missingCollections, [])
|
|
})
|