raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel }) liest
Zellen mit bis < jetzt - 1 Tag in Seiten zu 100 ueber idx_bis (aelteste
zuerst, ohne Cursor neu gelesen), gruppiert je Ressourcensperre mit Map und
loescht unter der Sperre nur, was dort noch dieselbe Buchung hat und noch alt
ist. Die Buchungen werden nicht gelesen. Der Lauf endet am Deckel (5000,
gedeckelt wird im Tagesjob gezaehlt und im Log gewarnt), bei einer leeren
Seite oder einer Seite ohne Erfolg; der erste Fehler wird am Ende geworfen.
ungueltigeZellen prueft zuerst das Zeitfenster (auch fuer belegeZellen).
verwaisteZellen, alleZellen und ALLE_ZELLEN_MAX entfallen.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1200 lines
64 KiB
JavaScript
1200 lines
64 KiB
JavaScript
import { test, after, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
|
|
|
// Nachbau wie in den Termine-Tests: Schema, Datumsfelder und Appwrites Standard-Limit 25 (alles, was mehr als 25
|
|
// Dokumente liefern kann, muss seitenweise lesen)
|
|
const aw = await startAppwriteMock({
|
|
schema: { termineClaims: schemaKeys('termineClaims'), termineBookings: schemaKeys('termineBookings') },
|
|
datetimes: TERMINE_DATETIMES,
|
|
defaultLimit: 25,
|
|
})
|
|
after(() => aw.close())
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
|
|
|
const { config } = await import('../server/config.js')
|
|
const { deleteDocument, listDocuments } = await import('../server/services/appwriteAdmin.js')
|
|
const { withLocks } = await import('../server/services/keyedLock.js')
|
|
const { zellenFuer, zellenId, belegeZellen, gibZellenFrei, raeumeVerwaisteZellen } = await import('../server/services/termine/zellen.js')
|
|
const { neuesToken, hashToken, verwaltungsToken, buchungsNummer, formToken, pruefeFormToken } = await import(
|
|
'../server/services/termine/token.js'
|
|
)
|
|
const B = await import('../server/services/termine/buchungen.js')
|
|
|
|
const F = 'f_feature01'
|
|
const R = 'r_aaaaaaa1'
|
|
const MIN = 60000
|
|
const STUNDE = 60 * MIN
|
|
const TAG = 24 * STUNDE
|
|
const HALB = 30 * MIN
|
|
const JETZT = new Date('2026-10-05T08:00:00.000Z')
|
|
/** Uhrzeit am Termintag 2026-10-12 (UTC) */
|
|
const T = (hhmm) => new Date(`2026-10-12T${hhmm}:00.000Z`)
|
|
const iso = (date) => new Date(date).toISOString()
|
|
const hhmm = (wert) => new Date(wert).toISOString().slice(11, 16)
|
|
|
|
beforeEach(async () => {
|
|
for (const collection of ['termineClaims', 'termineBookings']) {
|
|
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
|
}
|
|
})
|
|
|
|
const buchung = (id, over = {}) => ({
|
|
$id: id,
|
|
featureId: F,
|
|
customerId: 'c_kunde01',
|
|
projectId: 'p_projekt01',
|
|
leistungId: 's_aaaaaaa1',
|
|
leistungName: 'Damenschnitt',
|
|
ressourceId: R,
|
|
ressourceName: 'Anna',
|
|
startsAt: iso(T('10:00')),
|
|
endsAt: iso(T('10:45')),
|
|
blockEndsAt: iso(T('10:50')),
|
|
status: 'bestaetigt',
|
|
quelle: 'online',
|
|
gastEmail: 'max@gmail.com',
|
|
createdAt: iso(JETZT),
|
|
updatedAt: iso(JETZT),
|
|
...over,
|
|
})
|
|
const seedBuchung = (id, over) => aw.seed('termineBookings', buchung(id, over))
|
|
|
|
/** Buchung anlegen (Dokument zuerst, dann Zellen) und die Zellen von von bis bis belegen. */
|
|
async function belege(id, von, bis, over = {}) {
|
|
seedBuchung(id, { startsAt: iso(von), endsAt: iso(bis), blockEndsAt: iso(bis), ...over })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: id, start: von, blockEnde: bis, now: JETZT })
|
|
}
|
|
|
|
const claimsOf = (bookingId) => [...aw.docs('termineClaims').values()].filter((doc) => doc.bookingId === bookingId)
|
|
const zeitenOf = (bookingId) => claimsOf(bookingId).map((doc) => hhmm(doc.zelle)).sort()
|
|
const alleClaims = () => aw.docs('termineClaims').size
|
|
const schlafe = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
/** Die ersten n DELETE-Anfragen an Appwrite brauchen ms laenger (ein langsamer Aufraeum-Lauf). */
|
|
async function mitVerzoegertemLoeschen(n, ms, fn) {
|
|
const echt = globalThis.fetch
|
|
let gezaehlt = 0
|
|
globalThis.fetch = async (url, optionen) => {
|
|
if (optionen?.method === 'DELETE' && gezaehlt++ < n) await schlafe(ms)
|
|
return echt(url, optionen)
|
|
}
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
globalThis.fetch = echt
|
|
}
|
|
}
|
|
|
|
/** Haelt die Ressourcensperre, bis freigeben() gerufen wird. */
|
|
function haltePerSperre(featureId = F, ressourceId = R) {
|
|
let freigeben
|
|
const gehalten = new Promise((resolve) => (freigeben = resolve))
|
|
const fertig = withLocks([`termine-res:${featureId}:${ressourceId}`], () => gehalten)
|
|
return { freigeben, fertig }
|
|
}
|
|
const vergeben = (err) => err.status === 409 && err.code === 'SLOT_VERGEBEN'
|
|
|
|
// ---------------------------------------------------------------- Tokens
|
|
|
|
test('neuesToken: 32 Zufallsbytes base64url, gespeichert wird nur der SHA-256-Hex', () => {
|
|
const a = neuesToken()
|
|
const b = neuesToken()
|
|
assert.match(a.token, /^[A-Za-z0-9_-]{43}$/)
|
|
assert.equal(Buffer.from(a.token, 'base64url').length, 32)
|
|
assert.match(a.hash, /^[0-9a-f]{64}$/)
|
|
assert.equal(a.hash, createHash('sha256').update(a.token).digest('hex'))
|
|
assert.equal(a.hash, hashToken(a.token))
|
|
assert.notEqual(a.token, b.token)
|
|
assert.notEqual(a.hash, b.hash)
|
|
})
|
|
|
|
test('hashToken: fremde Eingaben (kein Text) werfen nicht und ergeben den Hash des leeren Textes', () => {
|
|
assert.equal(hashToken(undefined), hashToken(''))
|
|
assert.equal(hashToken({ a: 1 }), hashToken(''))
|
|
assert.notEqual(hashToken('abc'), hashToken('abd'))
|
|
})
|
|
|
|
test('Verwaltungstoken ist stabil, je Buchung verschieden und vom Sitzungsgeheimnis abhaengig', () => {
|
|
const id = randomUUID()
|
|
const token = verwaltungsToken(id)
|
|
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
|
|
assert.equal(verwaltungsToken(id), token)
|
|
assert.notEqual(verwaltungsToken(randomUUID()), token)
|
|
assert.notEqual(token, hashToken(id))
|
|
const geheimnis = config.sessionSecret
|
|
try {
|
|
config.sessionSecret = `${geheimnis}-anders`
|
|
assert.notEqual(verwaltungsToken(id), token)
|
|
} finally {
|
|
config.sessionSecret = geheimnis
|
|
}
|
|
assert.equal(verwaltungsToken(id), token)
|
|
assert.throws(() => verwaltungsToken(''), TypeError)
|
|
assert.throws(() => verwaltungsToken(undefined), TypeError)
|
|
})
|
|
|
|
test('ohne Sitzungsgeheimnis gibt es kein Token (kein Schluessel aus dem leeren Text)', () => {
|
|
const geheimnis = config.sessionSecret
|
|
try {
|
|
config.sessionSecret = ''
|
|
assert.throws(() => verwaltungsToken(randomUUID()), /SESSION_SECRET/)
|
|
assert.throws(() => formToken('abc', JETZT), /SESSION_SECRET/)
|
|
} finally {
|
|
config.sessionSecret = geheimnis
|
|
}
|
|
})
|
|
|
|
test('Buchungsnummer: 8 Zeichen A-Z0-9, stabil, praktisch eindeutig', () => {
|
|
const id = randomUUID()
|
|
assert.match(buchungsNummer(id), /^[A-Z0-9]{8}$/)
|
|
assert.equal(buchungsNummer(id), buchungsNummer(id))
|
|
const nummern = new Set(Array.from({ length: 500 }, () => buchungsNummer(randomUUID())))
|
|
assert.equal(nummern.size, 500)
|
|
for (const nummer of nummern) assert.match(nummer, /^[A-Z0-9]{8}$/)
|
|
assert.throws(() => buchungsNummer(''), TypeError)
|
|
})
|
|
|
|
test('Formular-Token: zu jung, passend, zu alt, gefaelscht', () => {
|
|
const vorher = config.termine.formMinSekunden
|
|
config.termine.formMinSekunden = 3
|
|
try {
|
|
const token = formToken('site1', JETZT)
|
|
const nach = (ms) => new Date(JETZT.getTime() + ms)
|
|
assert.equal(pruefeFormToken('site1', token, JETZT), 'zu_schnell')
|
|
assert.equal(pruefeFormToken('site1', token, nach(2999)), 'zu_schnell')
|
|
assert.equal(pruefeFormToken('site1', token, nach(3000)), 'ok')
|
|
assert.equal(pruefeFormToken('site1', token, nach(2 * STUNDE)), 'ok')
|
|
assert.equal(pruefeFormToken('site1', token, nach(2 * STUNDE + 1)), 'abgelaufen')
|
|
assert.equal(pruefeFormToken('site1', token, nach(30 * TAG)), 'abgelaufen')
|
|
|
|
// anderer Betrieb, veraenderter Zeitstempel, veraenderte Pruefsumme, Muell
|
|
assert.equal(pruefeFormToken('site2', token, nach(10000)), 'ungueltig')
|
|
const [zeit, mac] = token.split('.')
|
|
const juenger = (parseInt(zeit, 36) + 60000).toString(36)
|
|
assert.equal(pruefeFormToken('site1', `${juenger}.${mac}`, nach(120000)), 'ungueltig')
|
|
const kaputt = `${zeit}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`
|
|
assert.equal(pruefeFormToken('site1', kaputt, nach(10000)), 'ungueltig')
|
|
assert.equal(pruefeFormToken('site1', `${zeit}.`, nach(10000)), 'ungueltig')
|
|
for (const muell of ['', 'abc', '.', 'a.b.c', undefined, null, 42, {}, 'x'.repeat(5000), `${zeit}.${'A'.repeat(43)}`]) {
|
|
assert.equal(pruefeFormToken('site1', muell, nach(10000)), 'ungueltig', String(muell).slice(0, 20))
|
|
}
|
|
assert.equal(pruefeFormToken(undefined, token, nach(10000)), 'ungueltig')
|
|
assert.equal(pruefeFormToken('', token, nach(10000)), 'ungueltig')
|
|
} finally {
|
|
config.termine.formMinSekunden = vorher
|
|
}
|
|
})
|
|
|
|
test('Formular-Token: die Mindestzeit wird bei jedem Aufruf aus config.termine gelesen', () => {
|
|
const vorher = config.termine.formMinSekunden
|
|
try {
|
|
const token = formToken('site1', JETZT)
|
|
config.termine.formMinSekunden = 10
|
|
assert.equal(pruefeFormToken('site1', token, new Date(JETZT.getTime() + 5000)), 'zu_schnell')
|
|
assert.equal(pruefeFormToken('site1', token, new Date(JETZT.getTime() + 10000)), 'ok')
|
|
config.termine.formMinSekunden = 0
|
|
assert.equal(pruefeFormToken('site1', token, JETZT), 'ok')
|
|
} finally {
|
|
config.termine.formMinSekunden = vorher
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zellen: reine Funktionen
|
|
|
|
test('zellenFuer: 5-Minuten-Zellen von Beginn bis Blockende (Ende ausgeschlossen)', () => {
|
|
const zellen = zellenFuer(T('10:00'), T('10:50'))
|
|
assert.equal(zellen.length, 10)
|
|
assert.ok(zellen.every((zelle) => zelle instanceof Date))
|
|
assert.equal(hhmm(zellen[0]), '10:00')
|
|
assert.equal(hhmm(zellen.at(-1)), '10:45')
|
|
assert.equal(zellenFuer(T('10:00'), T('10:05')).length, 1)
|
|
// ueber Mitternacht, ISO-Text mit +00:00 wie von Appwrite
|
|
const nacht = zellenFuer('2026-10-12T23:50:00.000+00:00', '2026-10-13T00:10:00.000+00:00')
|
|
assert.deepEqual(nacht.map((zelle) => zelle.toISOString()), [
|
|
'2026-10-12T23:50:00.000Z',
|
|
'2026-10-12T23:55:00.000Z',
|
|
'2026-10-13T00:00:00.000Z',
|
|
'2026-10-13T00:05:00.000Z',
|
|
])
|
|
})
|
|
|
|
test('zellenFuer: krumme Zeiten werden auf das 5-Minuten-Raster erweitert, Unsinn wirft', () => {
|
|
const zellen = zellenFuer(new Date('2026-10-12T10:02:00Z'), new Date('2026-10-12T10:11:00Z'))
|
|
assert.deepEqual(zellen.map(hhmm), ['10:00', '10:05', '10:10'])
|
|
assert.throws(() => zellenFuer(T('10:00'), T('10:00')), RangeError)
|
|
assert.throws(() => zellenFuer(T('11:00'), T('10:00')), RangeError)
|
|
assert.throws(() => zellenFuer('kaputt', T('10:00')), RangeError)
|
|
assert.equal(zellenFuer(T('08:00'), T('16:20')).length, 100)
|
|
assert.throws(() => zellenFuer(T('08:00'), T('16:25')), /100 Zellen/)
|
|
})
|
|
|
|
test('zellenFuer richtet sich nach config.termine.zelleMin', () => {
|
|
const vorher = config.termine.zelleMin
|
|
try {
|
|
config.termine.zelleMin = 15
|
|
assert.deepEqual(zellenFuer(T('10:00'), T('10:50')).map(hhmm), ['10:00', '10:15', '10:30', '10:45'])
|
|
} finally {
|
|
config.termine.zelleMin = vorher
|
|
}
|
|
})
|
|
|
|
test('zellenId: c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso), 36 Zeichen', () => {
|
|
const id = zellenId(F, R, T('10:00'))
|
|
assert.equal(id.length, 36)
|
|
assert.match(id, /^c_[0-9a-f]{34}$/)
|
|
const erwartet = `c_${createHash('sha1').update(`${F}|${R}|2026-10-12T10:00:00.000Z`).digest('hex').slice(0, 34)}`
|
|
assert.equal(id, erwartet)
|
|
// Date und Appwrite-Text sind dieselbe Zelle
|
|
assert.equal(zellenId(F, R, '2026-10-12T10:00:00.000+00:00'), id)
|
|
assert.notEqual(zellenId(F, R, T('10:05')), id)
|
|
assert.notEqual(zellenId(F, 'r_aaaaaaa2', T('10:00')), id)
|
|
assert.notEqual(zellenId('f_feature02', R, T('10:00')), id)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zellen belegen
|
|
|
|
test('belegeZellen legt je Zelle ein Dokument mit festem Schluessel und bis = Blockende an', async () => {
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_eins', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
const claims = [...aw.docs('termineClaims').values()]
|
|
assert.equal(claims.length, 10)
|
|
for (const claim of claims) {
|
|
assert.equal(claim.featureId, F)
|
|
assert.equal(claim.ressourceId, R)
|
|
assert.equal(claim.bookingId, 'b_eins')
|
|
assert.equal(new Date(claim.bis).getTime(), T('10:50').getTime())
|
|
assert.equal(claim.$id, zellenId(F, R, claim.zelle))
|
|
}
|
|
assert.deepEqual(zeitenOf('b_eins'), zellenFuer(T('10:00'), T('10:50')).map(hhmm))
|
|
})
|
|
|
|
test('zweite Buchung derselben Zeit: SLOT_VERGEBEN, die erste bleibt unberuehrt', async () => {
|
|
await belege('b_eins', T('10:00'), T('10:50'))
|
|
const vorher = [...aw.docs('termineClaims').entries()]
|
|
seedBuchung('b_zwei', { startsAt: iso(T('10:00')), blockEndsAt: iso(T('10:50')) })
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_zwei', start: T('10:00'), blockEnde: T('10:50'), now: JETZT }),
|
|
(err) => vergeben(err) && /gerade vergeben/.test(err.message)
|
|
)
|
|
assert.deepEqual([...aw.docs('termineClaims').entries()], vorher)
|
|
assert.equal(claimsOf('b_zwei').length, 0)
|
|
})
|
|
|
|
test('teilweise Ueberlappung: SLOT_VERGEBEN ohne Teil-Schreibung', async () => {
|
|
await belege('b_eins', T('10:00'), T('11:00'))
|
|
seedBuchung('b_zwei')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_zwei', start: T('10:30'), blockEnde: T('11:30'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_zwei').length, 0)
|
|
assert.equal(claimsOf('b_eins').length, 12)
|
|
})
|
|
|
|
test('angrenzende Zeiten, andere Ressource und andere Funktion stoeren sich nicht', async () => {
|
|
await belege('b_eins', T('10:00'), T('11:00'))
|
|
seedBuchung('b_zwei')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_zwei', start: T('11:00'), blockEnde: T('12:00'), now: JETZT })
|
|
seedBuchung('b_drei', { ressourceId: 'r_aaaaaaa2' })
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_drei', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
seedBuchung('b_vier', { featureId: 'f_feature02' })
|
|
await belegeZellen({ featureId: 'f_feature02', ressourceId: R, bookingId: 'b_vier', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
assert.equal(alleClaims(), 48)
|
|
})
|
|
|
|
test('10 parallele Buchungen derselben Zeit: genau eine gewinnt', async () => {
|
|
const ids = Array.from({ length: 10 }, (_, i) => `b_par${i}`)
|
|
for (const id of ids) seedBuchung(id, { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() + 30 * MIN)) })
|
|
const ergebnisse = await Promise.allSettled(
|
|
ids.map((id) => belegeZellen({ featureId: F, ressourceId: R, bookingId: id, start: T('10:00'), blockEnde: T('10:50'), now: JETZT }))
|
|
)
|
|
const gewonnen = ids.filter((_, i) => ergebnisse[i].status === 'fulfilled')
|
|
assert.equal(gewonnen.length, 1)
|
|
for (const ergebnis of ergebnisse.filter((e) => e.status === 'rejected')) assert.ok(vergeben(ergebnis.reason), ergebnis.reason?.message)
|
|
assert.equal(alleClaims(), 10)
|
|
assert.equal(claimsOf(gewonnen[0]).length, 10)
|
|
})
|
|
|
|
test('10 parallele Buchungen ueberlappender Zeiten: die Zellen gehoeren nie zwei Buchungen', async () => {
|
|
const ids = Array.from({ length: 10 }, (_, i) => `b_ue${i}`)
|
|
for (const id of ids) seedBuchung(id)
|
|
await Promise.allSettled(
|
|
ids.map((id, i) =>
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: id, start: new Date(T('10:00').getTime() + i * 15 * MIN), blockEnde: new Date(T('11:00').getTime() + i * 15 * MIN), now: JETZT })
|
|
)
|
|
)
|
|
const gesehen = new Set()
|
|
for (const claim of aw.docs('termineClaims').values()) {
|
|
assert.ok(!gesehen.has(claim.zelle), `Zelle ${claim.zelle} doppelt`)
|
|
gesehen.add(claim.zelle)
|
|
}
|
|
// jede Buchung hat ganz oder gar nicht belegt
|
|
for (const id of ids) assert.ok([0, 12].includes(claimsOf(id).length), `${id}: ${claimsOf(id).length}`)
|
|
})
|
|
|
|
test('abgelaufene Haltung blockiert nicht, gueltige schon (heldUntil == jetzt ist abgelaufen)', async () => {
|
|
const haltung = (ms) => ({ status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() + ms)) })
|
|
await belege('b_alt', T('10:00'), T('11:00'), haltung(-1))
|
|
seedBuchung('b_neu')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
assert.equal(claimsOf('b_neu').length, 12)
|
|
assert.equal(claimsOf('b_alt').length, 0, 'die ungueltigen Zellen sind geloescht, nicht nur uebergangen')
|
|
|
|
await belege('b_genau', T('12:00'), T('13:00'), haltung(0))
|
|
seedBuchung('b_neu2')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu2', start: T('12:00'), blockEnde: T('13:00'), now: JETZT })
|
|
assert.equal(claimsOf('b_neu2').length, 12)
|
|
|
|
await belege('b_gueltig', T('14:00'), T('15:00'), haltung(1))
|
|
seedBuchung('b_neu3')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu3', start: T('14:00'), blockEnde: T('15:00'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_gueltig').length, 12)
|
|
})
|
|
|
|
test('Zellen ohne Buchung blockieren nicht; nicht blockierende Altlasten bleiben stehen', async () => {
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_weg', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_weg2', start: T('16:00'), blockEnde: T('17:00'), now: JETZT })
|
|
seedBuchung('b_neu')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:30'), blockEnde: T('11:30'), now: JETZT })
|
|
assert.equal(claimsOf('b_neu').length, 12)
|
|
// nur die blockierenden Zellen (10:30 bis 11:00) wurden von b_weg geraeumt; der Rest und b_weg2 bleiben
|
|
assert.equal(claimsOf('b_weg').length, 6)
|
|
assert.equal(claimsOf('b_weg2').length, 12)
|
|
})
|
|
|
|
test('nur angefragt und bestaetigt belegen; alle anderen Status geben die Zellen frei', async () => {
|
|
let n = 0
|
|
for (const [status, blockiert] of [
|
|
['bestaetigt', true],
|
|
['angefragt', true],
|
|
['abgesagt', false],
|
|
['abgelehnt', false],
|
|
['verfallen', false],
|
|
['erledigt', false],
|
|
['nichterschienen', false],
|
|
]) {
|
|
n += 1
|
|
const alt = `b_alt${n}`
|
|
const neu = `b_neu${n}`
|
|
await belege(alt, T('10:00'), T('10:30'), { status })
|
|
seedBuchung(neu)
|
|
const versuch = belegeZellen({ featureId: F, ressourceId: R, bookingId: neu, start: T('10:00'), blockEnde: T('10:30'), now: JETZT })
|
|
if (blockiert) await assert.rejects(versuch, vergeben, status)
|
|
else await versuch
|
|
await gibZellenFrei(alt)
|
|
await gibZellenFrei(neu)
|
|
}
|
|
assert.equal(alleClaims(), 0)
|
|
})
|
|
|
|
test('Sperre (quelle sperre) belegt wie eine Buchung', async () => {
|
|
await belege('b_sperre', T('12:00'), T('13:00'), { quelle: 'sperre', status: 'bestaetigt', gastEmail: '' })
|
|
seedBuchung('b_gast')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_gast', start: T('12:30'), blockEnde: T('13:30'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
})
|
|
|
|
test('gueltige und ungueltige Blockierer gemischt: SLOT_VERGEBEN nach genau einem zweiten Versuch, gueltige bleiben', async () => {
|
|
await belege('b_gueltig', T('10:00'), T('10:30'))
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_weg', start: T('10:30'), blockEnde: T('11:00'), now: JETZT })
|
|
seedBuchung('b_neu')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('11:00'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_gueltig').length, 6)
|
|
assert.equal(claimsOf('b_neu').length, 0)
|
|
})
|
|
|
|
test('belegeZellen wartet auf die Sperre seiner Ressource, andere Ressourcen laufen weiter', async () => {
|
|
let freigeben
|
|
const gehalten = new Promise((resolve) => (freigeben = resolve))
|
|
const sperre = withLocks([`termine-res:${F}:${R}`], () => gehalten)
|
|
let fertig = false
|
|
const versuch = belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_wartet', start: T('10:00'), blockEnde: T('10:30'), now: JETZT }).then(() => {
|
|
fertig = true
|
|
})
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_frei', start: T('10:00'), blockEnde: T('10:30'), now: JETZT })
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
assert.equal(fertig, false)
|
|
assert.equal(claimsOf('b_wartet').length, 0)
|
|
freigeben()
|
|
await Promise.all([sperre, versuch])
|
|
assert.equal(fertig, true)
|
|
assert.equal(claimsOf('b_wartet').length, 6)
|
|
assert.equal(claimsOf('b_frei').length, 6)
|
|
})
|
|
|
|
test('Verschieben 10:00 nach 10:30 mit ausser: nur die fehlenden Zellen entstehen, dann raeumt gibZellenFrei auf', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
const alt = new Map(claimsOf('b_x').map((doc) => [doc.$id, doc.$createdAt]))
|
|
assert.equal(alt.size, 12)
|
|
|
|
// ohne ausser blockiert sich die Buchung selbst
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:30'), blockEnde: T('11:30'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_x').length, 12)
|
|
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:30'), blockEnde: T('11:30'), now: JETZT, ausser: 'b_x' })
|
|
assert.deepEqual(zeitenOf('b_x'), zellenFuer(T('10:00'), T('11:30')).map(hhmm))
|
|
for (const claim of claimsOf('b_x')) {
|
|
if (alt.has(claim.$id)) assert.equal(claim.$createdAt, alt.get(claim.$id), 'bestehende Zellen werden nicht neu angelegt')
|
|
}
|
|
assert.equal(new Date(claimsOf('b_x').find((doc) => hhmm(doc.zelle) === '11:25').bis).getTime(), T('11:30').getTime())
|
|
|
|
const geloescht = await gibZellenFrei('b_x', { behalte: zellenFuer(T('10:30'), T('11:30')) })
|
|
assert.equal(geloescht, 6)
|
|
assert.deepEqual(zeitenOf('b_x'), zellenFuer(T('10:30'), T('11:30')).map(hhmm))
|
|
})
|
|
|
|
test('Verschieben in einen fremden Bereich: SLOT_VERGEBEN, alte Zellen unveraendert', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
await belege('b_y', T('11:00'), T('12:00'))
|
|
const vorher = [...aw.docs('termineClaims').entries()]
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:30'), blockEnde: T('11:30'), now: JETZT, ausser: 'b_x' }),
|
|
vergeben
|
|
)
|
|
assert.deepEqual([...aw.docs('termineClaims').entries()], vorher)
|
|
})
|
|
|
|
test('Verschieben auf eine andere Ressource zur selben Zeit: gibZellenFrei mit ressourceId raeumt die alte Ressource', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_x', start: T('10:00'), blockEnde: T('11:00'), now: JETZT, ausser: 'b_x' })
|
|
assert.equal(claimsOf('b_x').length, 24, 'andere Ressource = andere Zellen, die alten bleiben bis zum Aufraeumen')
|
|
const neu = zellenFuer(T('10:00'), T('11:00'))
|
|
// nur nach Zeit: beide Ressourcen haben dieselben Zeiten, nichts wird geloescht
|
|
assert.equal(await gibZellenFrei('b_x', { behalte: neu }), 0)
|
|
assert.equal(await gibZellenFrei('b_x', { behalte: neu, ressourceId: 'r_aaaaaaa2' }), 12)
|
|
assert.deepEqual([...new Set(claimsOf('b_x').map((doc) => doc.ressourceId))], ['r_aaaaaaa2'])
|
|
assert.equal(claimsOf('b_x').length, 12)
|
|
await gibZellenFrei('b_x')
|
|
assert.equal(claimsOf('b_x').length, 0)
|
|
})
|
|
|
|
test('ausser: ist schon alles belegt, entsteht nichts (keine Anfrage)', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
const vorher = [...aw.docs('termineClaims').entries()]
|
|
aw.failNext({ collection: 'termineClaims', method: 'POST', status: 500 })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:15'), blockEnde: T('10:45'), now: JETZT, ausser: 'b_x' })
|
|
assert.deepEqual([...aw.docs('termineClaims').entries()], vorher)
|
|
// den eingeplanten Fehler wieder verbrauchen
|
|
await assert.rejects(belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_y', start: T('13:00'), blockEnde: T('13:30'), now: JETZT }), (err) => err.status === 500)
|
|
})
|
|
|
|
test('andere Fehler als 409 gehen unveraendert durch und schreiben nichts', async () => {
|
|
aw.failNext({ collection: 'termineClaims', method: 'POST', status: 503 })
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:00'), blockEnde: T('11:00'), now: JETZT }),
|
|
(err) => err.status === 500 && err.code !== 'SLOT_VERGEBEN'
|
|
)
|
|
assert.equal(alleClaims(), 0)
|
|
})
|
|
|
|
test('belegeZellen prueft die Eingaben: Textfelder, now, Reihenfolge, mehr als 100 Zellen', async () => {
|
|
const gut = { featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:00'), blockEnde: T('11:00'), now: JETZT }
|
|
await assert.rejects(belegeZellen({ ...gut, featureId: '' }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, ressourceId: undefined }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, bookingId: 5 }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, now: undefined }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, now: 'heute' }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, blockEnde: T('10:00') }), RangeError)
|
|
await assert.rejects(belegeZellen({ ...gut, start: T('08:00'), blockEnde: T('16:25') }), /100 Zellen/)
|
|
assert.equal(alleClaims(), 0)
|
|
// genau 100 Zellen (500 Minuten) gehen in einem Bulk
|
|
await belegeZellen({ ...gut, start: T('08:00'), blockEnde: T('16:20') })
|
|
assert.equal(alleClaims(), 100)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zellen freigeben und aufraeumen
|
|
|
|
test('gibZellenFrei loescht 40 Zellen (mehr als eine 25er-Seite) und nur die der Buchung', async () => {
|
|
await belege('b_gross', T('08:00'), T('11:20')) // 200 Minuten = 40 Zellen
|
|
await belege('b_andere', T('12:00'), T('12:30'))
|
|
assert.equal(claimsOf('b_gross').length, 40)
|
|
// Gegenprobe: ohne limit liefert der Nachbau wie Appwrite nur 25
|
|
const unbegrenzt = await listDocuments('termineClaims', [])
|
|
assert.equal(unbegrenzt.length, 25)
|
|
|
|
const geloescht = await gibZellenFrei('b_gross')
|
|
assert.equal(geloescht, 40)
|
|
assert.equal(claimsOf('b_gross').length, 0)
|
|
assert.equal(claimsOf('b_andere').length, 6)
|
|
assert.equal(await gibZellenFrei('b_gross'), 0)
|
|
assert.equal(await gibZellenFrei('b_gibt_es_nicht'), 0)
|
|
})
|
|
|
|
test('gibZellenFrei behaelt die genannten Zellzeiten (Date oder Text)', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
const behalte = [T('10:00'), iso(T('10:05')), '2026-10-12T10:10:00.000+00:00']
|
|
assert.equal(await gibZellenFrei('b_x', { behalte }), 9)
|
|
assert.deepEqual(zeitenOf('b_x'), ['10:00', '10:05', '10:10'])
|
|
})
|
|
|
|
test('gibZellenFrei: ein Fehler bleibt nicht unbemerkt, die uebrigen Zellen werden trotzdem geloescht', async () => {
|
|
await belege('b_x', T('10:00'), T('10:30'))
|
|
aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 503 })
|
|
await assert.rejects(gibZellenFrei('b_x'), (err) => err.status === 500)
|
|
assert.equal(claimsOf('b_x').length, 1)
|
|
})
|
|
|
|
test('spaetes Aufraeumen einer alten Buchung loescht nicht die frisch belegten Zellen einer neuen (verzoegertes DELETE)', async () => {
|
|
// X: abgelaufene Haltung mit Zellen; W belegt dieselbe Zeit, waehrend der Aufraeum-Lauf von X noch loescht
|
|
seedBuchung('b_X', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - 1000)) })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_X', start: T('10:00'), blockEnde: T('10:50'), now: new Date(JETZT.getTime() - 60000) })
|
|
seedBuchung('b_W')
|
|
await mitVerzoegertemLoeschen(10, 400, async () => {
|
|
const lauf = gibZellenFrei('b_X')
|
|
await schlafe(20)
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_W', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
await lauf
|
|
})
|
|
assert.equal(claimsOf('b_W').length, 10, 'W behaelt alle Zellen')
|
|
assert.equal(claimsOf('b_X').length, 0)
|
|
})
|
|
|
|
test('gibZellenFrei loescht nur, was nach dem Warten auf die Sperre noch zur Buchung gehoert', async () => {
|
|
await belege('b_X', T('10:00'), T('10:30'))
|
|
seedBuchung('b_W')
|
|
const sperre = haltePerSperre()
|
|
const lauf = gibZellenFrei('b_X')
|
|
await schlafe(100) // der Lauf hat gelesen und wartet auf die Sperre
|
|
// in der Zwischenzeit gehen die Zellen an W (X abgesagt, W belegt dieselben IDs neu)
|
|
seedBuchung('b_X', { status: 'abgesagt' })
|
|
for (const claim of claimsOf('b_X')) aw.seed('termineClaims', { ...claim, bookingId: 'b_W' })
|
|
sperre.freigeben()
|
|
await sperre.fertig
|
|
assert.equal(await lauf, 0)
|
|
assert.equal(claimsOf('b_W').length, 6)
|
|
})
|
|
|
|
test('gibZellenFrei sperrt jede Ressource der Buchung, auch mehrere zugleich', async () => {
|
|
await belege('b_X', T('10:00'), T('10:30'))
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_X', start: T('10:00'), blockEnde: T('10:30'), now: JETZT, ausser: 'b_X' })
|
|
const sperre = haltePerSperre(F, 'r_aaaaaaa2')
|
|
let fertig = false
|
|
const lauf = gibZellenFrei('b_X').then((n) => {
|
|
fertig = true
|
|
return n
|
|
})
|
|
await schlafe(100)
|
|
assert.equal(fertig, false, 'wartet auf die Sperre der zweiten Ressource')
|
|
assert.equal(claimsOf('b_X').length, 12)
|
|
sperre.freigeben()
|
|
await sperre.fertig
|
|
assert.equal(await lauf, 12)
|
|
assert.equal(claimsOf('b_X').length, 0)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zellen-Sweep (raeumeVerwaisteZellen) und Zeitfenster
|
|
|
|
/** Zellen mit eigener ID in der Datenbank anlegen (ohne belegeZellen, damit auch ungueltige Altlasten entstehen). */
|
|
function seedZelle(id, bookingId, bis, extra = {}) {
|
|
aw.seed('termineClaims', {
|
|
$id: id,
|
|
featureId: F,
|
|
ressourceId: R,
|
|
bookingId,
|
|
zelle: iso(new Date(new Date(bis).getTime() - 5 * MIN)),
|
|
bis: iso(bis),
|
|
...extra,
|
|
})
|
|
}
|
|
|
|
/** Ende einer alten Zelle: mehr als einen Tag vor JETZT (n ms weiter zurueck, damit die Sortierung nach bis eindeutig ist). */
|
|
const alt = (n = 0) => new Date(JETZT.getTime() - TAG - 1 - n)
|
|
const nr = (i, stellen = 3) => String(i).padStart(stellen, '0')
|
|
|
|
/** Alle Anfragen (Methode und Adresse) aufzeichnen, die fn an Appwrite stellt. */
|
|
async function mitAnfragen(fn) {
|
|
const echt = globalThis.fetch
|
|
const anfragen = []
|
|
globalThis.fetch = async (url, optionen) => {
|
|
anfragen.push({ methode: optionen?.method ?? 'GET', url: String(url) })
|
|
return echt(url, optionen)
|
|
}
|
|
try {
|
|
await fn()
|
|
} finally {
|
|
globalThis.fetch = echt
|
|
}
|
|
return anfragen
|
|
}
|
|
const anBuchungen = (anfragen) => anfragen.filter((anfrage) => anfrage.url.includes('/collections/termineBookings/'))
|
|
|
|
/** DELETE-Anfragen an termineClaims scheitern, solange scheitert(id) wahr ist (503); alles andere geht durch. */
|
|
async function mitScheiternderZellenLoeschung(scheitert, fn) {
|
|
const echt = globalThis.fetch
|
|
globalThis.fetch = async (url, optionen) => {
|
|
const treffer = optionen?.method === 'DELETE' && String(url).match(/\/collections\/termineClaims\/documents\/([^/?]+)$/)
|
|
if (treffer && scheitert(treffer[1])) {
|
|
return new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 503, type: 'general_unknown' }), {
|
|
status: 503,
|
|
headers: { 'content-type': 'application/json' },
|
|
})
|
|
}
|
|
return echt(url, optionen)
|
|
}
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
globalThis.fetch = echt
|
|
}
|
|
}
|
|
|
|
/** Abfragen des Sweeps (die Seiten ueber bis), nicht die ID-Abfragen unter der Sperre. */
|
|
const seitenAbfragen = () => aw.listCalls('termineClaims').filter((aufruf) => aufruf.queries.some((q) => q.method === 'lessThan'))
|
|
const limitVon = (aufruf) => aufruf.queries.find((q) => q.method === 'limit')?.values[0]
|
|
|
|
test('Zellen-Sweep: bis genau jetzt - 1 Tag bleibt, eine Millisekunde aelter geht, die Buchungen werden nie abgefragt', async () => {
|
|
const grenze = new Date(JETZT.getTime() - TAG)
|
|
seedBuchung('b_ok') // bestaetigt: die Buchung belegt noch
|
|
seedZelle('c_genau', 'b_ok', grenze)
|
|
seedZelle('c_aelter', 'b_ok', new Date(grenze.getTime() - 1)) // ueber einen Tag vorbei: ungueltig, egal was die Buchung sagt
|
|
seedZelle('c_fehlt_genau', 'b_gibt_es_nicht', grenze) // Buchung fehlt, aber das Zeitfenster zaehlt: bleibt
|
|
seedZelle('c_fehlt_kuenftig', 'b_gibt_es_nicht', new Date(JETZT.getTime() + 2 * TAG))
|
|
let erg
|
|
const anfragen = await mitAnfragen(async () => {
|
|
erg = await raeumeVerwaisteZellen(JETZT)
|
|
})
|
|
assert.deepEqual(erg, { geloescht: 1, gedeckelt: false })
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_fehlt_genau', 'c_fehlt_kuenftig', 'c_genau'])
|
|
assert.deepEqual(anBuchungen(anfragen), [], 'keine einzige Anfrage an termineBookings')
|
|
|
|
// eine Millisekunde spaeter sind auch die beiden Zellen mit bis == jetzt - 1 Tag ueber einen Tag vorbei
|
|
assert.deepEqual(await raeumeVerwaisteZellen(new Date(JETZT.getTime() + 1)), { geloescht: 2, gedeckelt: false })
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_fehlt_kuenftig'])
|
|
await assert.rejects(raeumeVerwaisteZellen(undefined), TypeError)
|
|
})
|
|
|
|
test('Zellen-Sweep loescht genau die alten Zellen (jeder Funktion und Ressource) und zaehlt sie; kuenftige bleiben, auch ohne gueltige Buchung', async () => {
|
|
const bald = new Date(JETZT.getTime() + 2 * TAG)
|
|
seedBuchung('b_ok')
|
|
seedBuchung('b_abgesagt', { status: 'abgesagt' })
|
|
seedBuchung('b_haltung_alt', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - MIN)) })
|
|
// kuenftiges Ende: bleibt, auch wenn die Buchung fehlt oder nichts belegt (siehe die Waisen-Regel im Kopfkommentar)
|
|
seedZelle('c_ok', 'b_ok', bald)
|
|
seedZelle('c_abgesagt', 'b_abgesagt', bald, { ressourceId: 'r_aaaaaaa2' })
|
|
seedZelle('c_fehlt', 'b_gibt_es_nicht', bald, { featureId: 'f_feature02' })
|
|
// Ende mehr als einen Tag vorbei: weg, egal was die Buchung sagt
|
|
seedZelle('c_alt_ok', 'b_ok', alt(1000))
|
|
seedZelle('c_alt_abgesagt', 'b_abgesagt', alt(2000), { ressourceId: 'r_aaaaaaa2' })
|
|
seedZelle('c_alt_haltung', 'b_haltung_alt', alt(3000))
|
|
seedZelle('c_alt_fehlt', 'b_gibt_es_nicht', alt(4000), { featureId: 'f_feature02' })
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 4, gedeckelt: false })
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_abgesagt', 'c_fehlt', 'c_ok'])
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false })
|
|
})
|
|
|
|
test('Zellen-Sweep: 260 alte Zellen auf zwei Ressourcen in Seiten zu hoechstens 100 ueber bis (Standard-Deckel liegt darueber)', async () => {
|
|
assert.equal(config.termine.zellenDeckel, 5000)
|
|
for (let i = 0; i < 260; i += 1) {
|
|
seedZelle(`c_viele${nr(i)}`, `b_nie${i}`, alt(i * 1000), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R })
|
|
}
|
|
seedBuchung('b_ok')
|
|
seedZelle('c_ok', 'b_ok', T('23:00'))
|
|
aw.resetListCalls()
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 260, gedeckelt: false })
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok'])
|
|
|
|
const abfragen = aw.listCalls('termineClaims')
|
|
assert.ok(abfragen.length >= 4, `drei Seiten, eine leere Seite und die ID-Abfragen: ${abfragen.length}`)
|
|
assert.ok(abfragen.every((aufruf) => Number.isInteger(limitVon(aufruf)) && limitVon(aufruf) <= 100), abfragen.map(limitVon).join(','))
|
|
const seiten = seitenAbfragen()
|
|
assert.equal(seiten.length, 4, '100 + 100 + 60 Zellen, dann eine leere Seite')
|
|
const erste = seiten[0].queries
|
|
assert.deepEqual(erste.find((q) => q.method === 'lessThan'), { method: 'lessThan', attribute: 'bis', values: [iso(new Date(JETZT.getTime() - TAG))] })
|
|
assert.equal(erste.find((q) => q.method === 'orderAsc')?.attribute, 'bis')
|
|
assert.deepEqual(erste.find((q) => q.method === 'select')?.values, ['$id', 'featureId', 'ressourceId', 'bookingId', 'bis'])
|
|
assert.equal(erste.find((q) => q.method === 'cursorAfter'), undefined, 'ohne Cursor, die Liste wird neu gelesen')
|
|
assert.deepEqual(seiten.map(limitVon), [100, 100, 100, 100])
|
|
})
|
|
|
|
test('Zellen-Sweep: der Deckel zaehlt Zellen genau, der Rest folgt im naechsten Lauf', async () => {
|
|
// c_deckel000 ist die aelteste
|
|
for (let i = 0; i < 600; i += 1) seedZelle(`c_deckel${nr(i)}`, `b_nie${i}`, alt((600 - i) * 1000), { ressourceId: i % 3 ? 'r_aaaaaaa2' : R })
|
|
const vorher = config.termine.zellenDeckel
|
|
config.termine.zellenDeckel = 250
|
|
try {
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 250, gedeckelt: true })
|
|
assert.equal(aw.docs('termineClaims').size, 350)
|
|
assert.ok(!aw.docs('termineClaims').has('c_deckel249') && aw.docs('termineClaims').has('c_deckel250'), 'die aeltesten zuerst')
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 250, gedeckelt: true })
|
|
assert.equal(aw.docs('termineClaims').size, 100)
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 100, gedeckelt: false })
|
|
assert.equal(alleClaims(), 0)
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false })
|
|
} finally {
|
|
config.termine.zellenDeckel = vorher
|
|
}
|
|
})
|
|
|
|
test('Zellen-Sweep: deckel als Option gilt vor config.termine.zellenDeckel; genau so viele Zellen wie der Deckel sind nicht gedeckelt', async () => {
|
|
for (let i = 0; i < 10; i += 1) seedZelle(`c_opt${nr(i)}`, `b_nie${i}`, alt((10 - i) * 1000))
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 4 }), { geloescht: 4, gedeckelt: true })
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 0 }), { geloescht: 0, gedeckelt: true }, 'Deckel 0: nur nachsehen, ob etwas ansteht')
|
|
assert.equal(alleClaims(), 6)
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 6 }), { geloescht: 6, gedeckelt: false }, 'der Deckel reicht genau')
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 0 }), { geloescht: 0, gedeckelt: false })
|
|
for (const falsch of [-1, 1.5, '5', null, NaN]) {
|
|
await assert.rejects(raeumeVerwaisteZellen(JETZT, { deckel: falsch }), TypeError, String(falsch))
|
|
}
|
|
})
|
|
|
|
test('Zellen-Sweep: 3000 alte Zellen einer einzigen Ressource (ein Sperrschluessel) werden alle geloescht', async () => {
|
|
for (let i = 0; i < 3000; i += 1) seedZelle(`c_eine${nr(i, 4)}`, `b_nie${i % 500}`, alt((3000 - i) * 1000))
|
|
seedBuchung('b_ok')
|
|
seedZelle('c_ok', 'b_ok', T('23:00'))
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 3000, gedeckelt: false })
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok'])
|
|
})
|
|
|
|
test('Zellen-Sweep: ein verzoegertes DELETE trifft nicht die Zellen einer gleichzeitig belegten Zeit derselben Ressource', async () => {
|
|
seedBuchung('b_X', { status: 'abgesagt' })
|
|
for (let i = 0; i < 10; i += 1) seedZelle(`c_x${nr(i, 2)}`, 'b_X', alt(i * 1000))
|
|
seedBuchung('b_W')
|
|
await mitVerzoegertemLoeschen(10, 400, async () => {
|
|
const lauf = raeumeVerwaisteZellen(JETZT)
|
|
await schlafe(20)
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_W', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
assert.deepEqual(await lauf, { geloescht: 10, gedeckelt: false })
|
|
})
|
|
assert.equal(claimsOf('b_W').length, 10, 'W behaelt alle Zellen')
|
|
assert.equal(claimsOf('b_X').length, 0)
|
|
})
|
|
|
|
test('Zellen-Sweep prueft nach dem Warten auf die Sperre erneut: nur dieselbe Buchung und noch alt wird geloescht', async () => {
|
|
const bald = new Date(JETZT.getTime() + 2 * TAG)
|
|
seedBuchung('b_W')
|
|
seedZelle('c_x1', 'b_X1', alt(1000)) // unveraendert: weg
|
|
seedZelle('c_x2', 'b_X2', alt(2000)) // wird einer neuen Buchung mit kuenftigem Ende
|
|
seedZelle('c_x3', 'b_X3', alt(3000)) // ist inzwischen schon weg
|
|
seedZelle('c_x4', 'b_X1', alt(4000)) // andere Buchung, deren Dokument erst noch folgt
|
|
seedZelle('c_x5', 'b_X5', alt(5000)) // gleiche Buchung, aber das Ende liegt jetzt in der Zukunft
|
|
seedZelle('c_x6', 'b_X6', alt(6000)) // andere Buchung, das Ende ist noch alt: bleibt bis zum naechsten Lauf
|
|
const sperre = haltePerSperre()
|
|
aw.resetListCalls()
|
|
const lauf = raeumeVerwaisteZellen(JETZT)
|
|
await schlafe(100) // der Lauf hat die Seite gelesen und wartet auf die Sperre
|
|
const zelle = (id) => aw.docs('termineClaims').get(id)
|
|
aw.seed('termineClaims', { ...zelle('c_x2'), bookingId: 'b_W', bis: iso(bald) })
|
|
await deleteDocument('termineClaims', 'c_x3')
|
|
aw.seed('termineClaims', { ...zelle('c_x4'), bookingId: 'b_neu_ohne_buchung', bis: iso(bald) })
|
|
aw.seed('termineClaims', { ...zelle('c_x5'), bis: iso(bald) })
|
|
aw.seed('termineClaims', { ...zelle('c_x6'), bookingId: 'b_W' })
|
|
sperre.freigeben()
|
|
await sperre.fertig
|
|
assert.deepEqual(await lauf, { geloescht: 1, gedeckelt: false })
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x2', 'c_x4', 'c_x5', 'c_x6'])
|
|
assert.equal(zelle('c_x2').bookingId, 'b_W')
|
|
assert.equal(zelle('c_x4').bookingId, 'b_neu_ohne_buchung')
|
|
assert.equal(seitenAbfragen().length <= 3, true, 'keine Endlosschleife ueber die unveraenderte c_x6')
|
|
// im naechsten Lauf gehoert c_x6 der Buchung, die der Sweep liest: weg
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false })
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x2', 'c_x4', 'c_x5'])
|
|
})
|
|
|
|
test('Zellen-Sweep: eine Waise mit kuenftigem Ende bleibt stehen, blockiert aber keine Neubuchung', async () => {
|
|
seedBuchung('b_abgesagt', { status: 'abgesagt' })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_abgesagt', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
seedZelle('c_fehlt', 'b_gibt_es_nicht', T('23:00'))
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false })
|
|
assert.equal(alleClaims(), 11, 'der Sweep findet Waisen mit kuenftigem Ende nicht mehr')
|
|
|
|
seedBuchung('b_neu')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
assert.equal(claimsOf('b_neu').length, 10, 'belegeZellen raeumt den Blockierer')
|
|
assert.equal(claimsOf('b_abgesagt').length, 0)
|
|
|
|
// spaetestens einen Tag nach bis faellt alles in den Sweep
|
|
assert.deepEqual(await raeumeVerwaisteZellen(new Date(T('23:00').getTime() + TAG + MIN)), { geloescht: 11, gedeckelt: false })
|
|
assert.equal(alleClaims(), 0)
|
|
})
|
|
|
|
test('Zellen-Sweep: ein Fehler beim Loeschen stoppt die uebrigen Ressourcen nicht und wird am Ende geworfen', async () => {
|
|
seedZelle('c_a1', 'b_nie1', alt(2000))
|
|
seedZelle('c_b1', 'b_nie2', alt(1000), { ressourceId: 'r_aaaaaaa2' })
|
|
aw.resetListCalls()
|
|
aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 503 })
|
|
await assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500)
|
|
assert.equal(aw.docs('termineClaims').size, 1, 'die andere Ressource ist trotzdem geraeumt')
|
|
assert.ok(seitenAbfragen().length <= 2, `kein Endlosdurchlauf: ${seitenAbfragen().length} Seiten`)
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false })
|
|
})
|
|
|
|
test('Zellen-Sweep: scheitert jedes DELETE, endet der Lauf nach einer Seite und wirft den ersten Fehler', async () => {
|
|
for (let i = 0; i < 150; i += 1) seedZelle(`c_kaputt${nr(i)}`, `b_nie${i}`, alt((150 - i) * 1000), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R })
|
|
aw.resetListCalls()
|
|
await mitScheiternderZellenLoeschung(
|
|
() => true,
|
|
() => assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500)
|
|
)
|
|
assert.equal(alleClaims(), 150)
|
|
assert.equal(seitenAbfragen().length, 1, 'eine Seite ohne Erfolg beendet den Lauf')
|
|
})
|
|
|
|
test('Zellen-Sweep: eine Zelle, die sich nicht loeschen laesst, haelt den Rest nicht auf; was geloescht ist, zaehlt fuer den naechsten Lauf', async () => {
|
|
// c_stur000 ist die aelteste und steht in jeder Liste vorn
|
|
for (let i = 0; i < 150; i += 1) seedZelle(`c_stur${nr(i)}`, `b_nie${i}`, alt((150 - i) * 1000))
|
|
aw.resetListCalls()
|
|
await mitScheiternderZellenLoeschung(
|
|
(id) => id === 'c_stur000',
|
|
() => assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500)
|
|
)
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_stur000'])
|
|
assert.ok(seitenAbfragen().length <= 4, `keine Endlosschleife: ${seitenAbfragen().length} Seiten`)
|
|
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false })
|
|
})
|
|
|
|
test('Blockierer mit Ende vor mehr als einem Tag sind ohne Buchungsabfrage ungueltig (dieselbe Regel wie im Sweep)', async () => {
|
|
await belege('b_alt', T('10:00'), T('11:00')) // bestaetigt: belegt
|
|
seedBuchung('b_neu')
|
|
const spaet = new Date(T('11:00').getTime() + TAG + MIN)
|
|
const anfragen = await mitAnfragen(() =>
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('11:00'), now: spaet })
|
|
)
|
|
assert.equal(claimsOf('b_neu').length, 12)
|
|
assert.equal(claimsOf('b_alt').length, 0)
|
|
assert.deepEqual(anBuchungen(anfragen), [], 'das Zeitfenster entscheidet, die Buchung wird nicht gelesen')
|
|
|
|
// genau einen Tag nach bis ist noch nichts ueberfaellig: die Buchung entscheidet und belegt
|
|
await belege('b_alt2', T('12:00'), T('13:00'))
|
|
seedBuchung('b_neu2')
|
|
const knapp = new Date(T('13:00').getTime() + TAG)
|
|
const anfragen2 = await mitAnfragen(() =>
|
|
assert.rejects(belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu2', start: T('12:00'), blockEnde: T('13:00'), now: knapp }), vergeben)
|
|
)
|
|
assert.ok(anBuchungen(anfragen2).length > 0)
|
|
assert.equal(claimsOf('b_alt2').length, 12)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Buchungsspeicher
|
|
|
|
test('legeAn, hole, aendere: Anlegen mit fester ID, Lesen (fehlt => null), Aendern setzt updatedAt', async () => {
|
|
const id = randomUUID()
|
|
const vorLegeAn = Date.now()
|
|
const angelegt = await B.legeAn({ featureId: F, status: 'gehalten', gastEmail: 'max@gmail.com' }, id)
|
|
assert.equal(angelegt.$id, id)
|
|
assert.equal(angelegt.status, 'gehalten')
|
|
const geladen = await B.hole(id)
|
|
assert.equal(geladen.gastEmail, 'max@gmail.com')
|
|
for (const feld of ['createdAt', 'updatedAt']) {
|
|
const ms = new Date(geladen[feld]).getTime()
|
|
assert.ok(ms >= vorLegeAn - 1000 && ms <= Date.now() + 1000, `${feld}: ${geladen[feld]}`)
|
|
}
|
|
assert.equal(await B.hole('b_gibt_es_nicht'), null)
|
|
|
|
const vorAendere = Date.now()
|
|
aw.seed('termineBookings', { ...geladen, updatedAt: '2020-01-01T00:00:00.000Z' })
|
|
const geaendert = await B.aendere(id, { status: 'bestaetigt', bestaetigtAt: iso(JETZT) })
|
|
assert.equal(geaendert.status, 'bestaetigt')
|
|
assert.equal(geaendert.gastEmail, 'max@gmail.com', 'andere Felder bleiben')
|
|
assert.ok(new Date(geaendert.updatedAt).getTime() >= vorAendere - 1000)
|
|
assert.equal((await B.hole(id)).status, 'bestaetigt')
|
|
// ein ganzes gelesenes Dokument darf zurueckgegeben werden: $-Felder werden nicht mitgeschickt
|
|
const wieder = await B.aendere(id, { ...(await B.hole(id)), status: 'abgesagt' })
|
|
assert.equal(wieder.status, 'abgesagt')
|
|
await assert.rejects(B.aendere('b_gibt_es_nicht', { status: 'abgesagt' }), (err) => err.status === 404)
|
|
})
|
|
|
|
test('legeAn: eigene Zeitstempel bleiben, die ID darf auch als $id im Dokument stehen', async () => {
|
|
const angelegt = await B.legeAn(buchung('b_mit_id', { createdAt: '2026-10-05T08:00:00.000Z', updatedAt: '2026-10-05T08:00:00.000Z' }))
|
|
assert.equal(angelegt.$id, 'b_mit_id')
|
|
const geladen = await B.hole('b_mit_id')
|
|
assert.equal(new Date(geladen.createdAt).getTime(), JETZT.getTime())
|
|
assert.equal(new Date(geladen.updatedAt).getTime(), JETZT.getTime())
|
|
})
|
|
|
|
test('legeAn vergibt ohne ID eine neue, nimmt keine doppelte ID an und schreibt keine unbekannten Felder', async () => {
|
|
const a = await B.legeAn({ featureId: F, status: 'gehalten' })
|
|
assert.match(a.$id, /^[0-9a-f-]{36}$/)
|
|
await assert.rejects(B.legeAn({ featureId: F }, a.$id), (err) => err.status === 409)
|
|
await assert.rejects(B.legeAn({ featureId: F, bogus: 1 }), (err) => err.status === 400)
|
|
})
|
|
|
|
test('imZeitraum findet die Belegung vom Vortag und auch mehr als 25 Treffer', async () => {
|
|
// 22:00 bis 06:00 Berlin (Sommerzeit): 20:00Z bis 04:00Z des Folgetags
|
|
seedBuchung('b_nacht', { startsAt: '2026-10-11T20:00:00Z', endsAt: '2026-10-12T04:00:00Z', blockEndsAt: '2026-10-12T04:00:00Z' })
|
|
seedBuchung('b_vorher', { startsAt: '2026-10-11T08:00:00Z', endsAt: '2026-10-11T09:00:00Z', blockEndsAt: '2026-10-11T09:00:00Z' })
|
|
seedBuchung('b_endet_genau', { startsAt: '2026-10-11T21:00:00Z', endsAt: '2026-10-11T22:00:00Z', blockEndsAt: '2026-10-11T22:00:00Z' })
|
|
seedBuchung('b_beginnt_genau', { startsAt: '2026-10-12T22:00:00Z', endsAt: '2026-10-12T23:00:00Z', blockEndsAt: '2026-10-12T23:00:00Z' })
|
|
seedBuchung('b_nachher', { startsAt: '2026-10-13T08:00:00Z', endsAt: '2026-10-13T09:00:00Z', blockEndsAt: '2026-10-13T09:00:00Z' })
|
|
seedBuchung('b_fremd', { featureId: 'f_feature02', startsAt: '2026-10-12T08:00:00Z', blockEndsAt: '2026-10-12T09:00:00Z' })
|
|
seedBuchung('b_abgesagt', { status: 'abgesagt', startsAt: '2026-10-12T08:00:00Z', blockEndsAt: '2026-10-12T09:00:00Z' })
|
|
const von = new Date('2026-10-11T22:00:00Z') // Beginn des 12.10. in Berlin
|
|
const bis = new Date('2026-10-12T22:00:00Z') // Ende des 12.10. in Berlin
|
|
const treffer = await B.imZeitraum(F, von, bis)
|
|
assert.deepEqual(treffer.map((doc) => doc.$id).sort(), ['b_abgesagt', 'b_nacht'])
|
|
|
|
for (let i = 0; i < 30; i += 1) {
|
|
seedBuchung(`b_tag${String(i).padStart(2, '0')}`, { startsAt: iso(new Date(T('06:00').getTime() + i * 5 * MIN)), blockEndsAt: iso(new Date(T('06:00').getTime() + (i + 1) * 5 * MIN)) })
|
|
}
|
|
const viele = await B.imZeitraum(F, von, bis)
|
|
assert.equal(viele.length, 32)
|
|
// Text- und Date-Grenzen sind gleichwertig
|
|
assert.equal((await B.imZeitraum(F, von.toISOString(), bis.toISOString())).length, 32)
|
|
})
|
|
|
|
test('perHash findet ueber verifyHash oder manageHash, nie ueber leere oder fremde Werte', async () => {
|
|
const t = neuesToken()
|
|
const m = hashToken(verwaltungsToken('b_eins'))
|
|
seedBuchung('b_eins', { verifyHash: t.hash, manageHash: m })
|
|
seedBuchung('b_leer', { verifyHash: '', manageHash: '' })
|
|
assert.equal((await B.perHash('verifyHash', t.hash)).$id, 'b_eins')
|
|
assert.equal((await B.perHash('manageHash', m)).$id, 'b_eins')
|
|
assert.equal(await B.perHash('verifyHash', m), null)
|
|
assert.equal(await B.perHash('verifyHash', hashToken('anderes')), null)
|
|
assert.equal(await B.perHash('verifyHash', ''), null)
|
|
assert.equal(await B.perHash('verifyHash', undefined), null)
|
|
assert.equal(await B.perHash('verifyHash', 'abc'), null)
|
|
await assert.rejects(B.perHash('gastEmail', t.hash), TypeError)
|
|
})
|
|
|
|
test('offeneFuerEmail: gueltige Haltungen, Anfragen und kuenftige bestaetigte Termine dieser Adresse', async () => {
|
|
const spaeter = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const termin = (ms) => ({ startsAt: spaeter(ms), endsAt: spaeter(ms + 45 * MIN), blockEndsAt: spaeter(ms + 50 * MIN) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', ...termin(2 * TAG) })
|
|
seedBuchung('b_anfr', { status: 'angefragt', ...termin(3 * TAG) })
|
|
seedBuchung('b_halt', { status: 'gehalten', heldUntil: spaeter(10 * MIN), ...termin(4 * TAG) })
|
|
seedBuchung('b_halt_alt', { status: 'gehalten', heldUntil: spaeter(-MIN), ...termin(4 * TAG) })
|
|
seedBuchung('b_abg', { status: 'abgesagt', ...termin(2 * TAG) })
|
|
seedBuchung('b_verg', { status: 'bestaetigt', ...termin(-2 * TAG) })
|
|
seedBuchung('b_laeuft', { status: 'bestaetigt', ...termin(-10 * MIN) })
|
|
seedBuchung('b_fremd_mail', { status: 'bestaetigt', gastEmail: 'anna@gmail.com', ...termin(2 * TAG) })
|
|
seedBuchung('b_fremd_f', { status: 'bestaetigt', featureId: 'f_feature02', ...termin(2 * TAG) })
|
|
const offen = await B.offeneFuerEmail(F, 'max@gmail.com', JETZT)
|
|
assert.deepEqual(offen.map((doc) => doc.$id).sort(), ['b_anfr', 'b_best', 'b_halt', 'b_laeuft'])
|
|
// dieselbe Adresse in anderer Schreibweise
|
|
assert.equal((await B.offeneFuerEmail(F, ' Max@GMail.com ', JETZT)).length, 4)
|
|
assert.deepEqual(await B.offeneFuerEmail(F, 'niemand@gmail.com', JETZT), [])
|
|
})
|
|
|
|
test('perEmailUndNummer: Adresse und Buchungsnummer muessen zusammen passen', async () => {
|
|
seedBuchung('b_eins')
|
|
seedBuchung('b_zwei')
|
|
seedBuchung('b_drei', { gastEmail: 'anna@gmail.com' })
|
|
seedBuchung('b_vier', { featureId: 'f_feature02' })
|
|
const nummer = buchungsNummer('b_zwei')
|
|
assert.equal((await B.perEmailUndNummer(F, 'max@gmail.com', nummer)).$id, 'b_zwei')
|
|
assert.equal((await B.perEmailUndNummer(F, ' MAX@gmail.com ', nummer.toLowerCase())).$id, 'b_zwei')
|
|
assert.equal(await B.perEmailUndNummer(F, 'anna@gmail.com', nummer), null)
|
|
assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', buchungsNummer('b_drei')), null)
|
|
assert.equal(await B.perEmailUndNummer('f_feature02', 'max@gmail.com', buchungsNummer('b_vier')).then((d) => d.$id), 'b_vier')
|
|
assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', buchungsNummer('b_vier')), null)
|
|
assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', ''), null)
|
|
assert.equal(await B.perEmailUndNummer(F, '', nummer), null)
|
|
})
|
|
|
|
test('zaehleKuenftige: belegende Termine je Leistung und Ressource, ohne Sperren und Vergangenes', async () => {
|
|
const spaeter = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const termin = (ms, over = {}) => ({ startsAt: spaeter(ms), endsAt: spaeter(ms + 45 * MIN), blockEndsAt: spaeter(ms + 50 * MIN), ...over })
|
|
seedBuchung('b_1', termin(TAG, { leistungId: 's_aaaaaaa1', ressourceId: 'r_aaaaaaa1' }))
|
|
seedBuchung('b_2', termin(2 * TAG, { leistungId: 's_aaaaaaa1', ressourceId: 'r_aaaaaaa2', status: 'angefragt' }))
|
|
seedBuchung('b_3', termin(3 * TAG, { leistungId: 's_aaaaaaa2', ressourceId: 'r_aaaaaaa1', status: 'gehalten', heldUntil: spaeter(MIN) }))
|
|
seedBuchung('b_4', termin(3 * TAG, { leistungId: 's_aaaaaaa2', ressourceId: 'r_aaaaaaa1', status: 'gehalten', heldUntil: spaeter(-MIN) }))
|
|
seedBuchung('b_5', termin(TAG, { status: 'abgesagt' }))
|
|
seedBuchung('b_6', termin(-TAG))
|
|
seedBuchung('b_7', termin(TAG, { quelle: 'sperre', leistungId: '', ressourceId: 'r_aaaaaaa1' }))
|
|
seedBuchung('b_8', termin(TAG, { featureId: 'f_feature02' }))
|
|
const zahlen = await B.zaehleKuenftige(F, JETZT)
|
|
assert.deepEqual(zahlen, {
|
|
leistungen: { s_aaaaaaa1: 2, s_aaaaaaa2: 1 },
|
|
ressourcen: { r_aaaaaaa1: 2, r_aaaaaaa2: 1 },
|
|
})
|
|
assert.deepEqual(await B.zaehleKuenftige('f_leer', JETZT), { leistungen: {}, ressourcen: {} })
|
|
})
|
|
|
|
test('abgelaufeneHaltungen: gehalten mit heldUntil <= jetzt, ueber alle Funktionen', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
seedBuchung('b_alt', { status: 'gehalten', heldUntil: mit(-HALB) })
|
|
seedBuchung('b_genau', { status: 'gehalten', heldUntil: mit(0), featureId: 'f_feature02' })
|
|
seedBuchung('b_noch', { status: 'gehalten', heldUntil: mit(1) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', heldUntil: mit(-HALB) })
|
|
seedBuchung('b_verf', { status: 'verfallen', heldUntil: mit(-HALB) })
|
|
const liste = await B.abgelaufeneHaltungen(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_genau'])
|
|
})
|
|
|
|
test('erinnerungsKandidaten: bestaetigt, Erinnerung gewuenscht, noch nicht gesendet, Beginn in (jetzt + 1 h, jetzt + 48 h]', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const k = (id, ms, over = {}) => seedBuchung(id, { status: 'bestaetigt', erinnerung: true, startsAt: mit(ms), endsAt: mit(ms + 45 * MIN), blockEndsAt: mit(ms + 50 * MIN), ...over })
|
|
k('b_ok1', 2 * STUNDE)
|
|
k('b_ok2', 24 * STUNDE, { featureId: 'f_feature02' })
|
|
k('b_ok3', 48 * STUNDE)
|
|
k('b_ok4', STUNDE + 1)
|
|
k('b_zu_nah', STUNDE)
|
|
k('b_zu_fern', 48 * STUNDE + 1)
|
|
k('b_ohne_haken', 5 * STUNDE, { erinnerung: false })
|
|
k('b_gesendet', 5 * STUNDE, { erinnerungGesendetAt: mit(-MIN) })
|
|
k('b_anfrage', 5 * STUNDE, { status: 'angefragt' })
|
|
k('b_abgesagt', 5 * STUNDE, { status: 'abgesagt' })
|
|
const liste = await B.erinnerungsKandidaten(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_ok1', 'b_ok2', 'b_ok3', 'b_ok4'])
|
|
})
|
|
|
|
test('zuErledigen: bestaetigt mit Ende vor jetzt - 1 h', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const k = (id, endeMs, over = {}) => seedBuchung(id, { status: 'bestaetigt', startsAt: mit(endeMs - 45 * MIN), endsAt: mit(endeMs), blockEndsAt: mit(endeMs + 5 * MIN), ...over })
|
|
k('b_alt', -STUNDE - 1, { featureId: 'f_feature02' })
|
|
k('b_alt2', -3 * TAG)
|
|
k('b_genau', -STUNDE)
|
|
k('b_laeuft', 10 * MIN)
|
|
k('b_angefragt', -3 * TAG, { status: 'angefragt' })
|
|
k('b_erledigt', -3 * TAG, { status: 'erledigt' })
|
|
const liste = await B.zuErledigen(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_alt2'])
|
|
})
|
|
|
|
test('vergangeneAnfragen: angefragt mit Beginn vor jetzt', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
seedBuchung('b_alt', { status: 'angefragt', startsAt: mit(-MIN) })
|
|
seedBuchung('b_alt2', { status: 'angefragt', startsAt: mit(-5 * TAG), featureId: 'f_feature02' })
|
|
seedBuchung('b_genau', { status: 'angefragt', startsAt: mit(0) })
|
|
seedBuchung('b_spaeter', { status: 'angefragt', startsAt: mit(MIN) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', startsAt: mit(-TAG) })
|
|
const liste = await B.vergangeneAnfragen(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_alt2'])
|
|
})
|
|
|
|
test('loeschKandidaten: Bezugszeit je Status (verfallen heldUntil, erledigt Terminende, abgesagt letzte Aenderung)', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const grenze = new Date(JETZT.getTime() - 48 * STUNDE)
|
|
seedBuchung('b_verf_alt', { status: 'verfallen', heldUntil: mit(-49 * STUNDE) })
|
|
seedBuchung('b_verf_neu', { status: 'verfallen', heldUntil: mit(-47 * STUNDE) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', heldUntil: mit(-49 * STUNDE) })
|
|
assert.deepEqual((await B.loeschKandidaten('verfallen', grenze)).map((d) => d.$id), ['b_verf_alt'])
|
|
|
|
const dreissig = new Date(JETZT.getTime() - 30 * TAG)
|
|
seedBuchung('b_abg_alt', { status: 'abgesagt', updatedAt: mit(-31 * TAG) })
|
|
seedBuchung('b_abg_neu', { status: 'abgesagt', updatedAt: mit(-29 * TAG) })
|
|
seedBuchung('b_abl_alt', { status: 'abgelehnt', updatedAt: mit(-31 * TAG), featureId: 'f_feature02' })
|
|
assert.deepEqual((await B.loeschKandidaten('abgesagt', dreissig)).map((d) => d.$id), ['b_abg_alt'])
|
|
assert.deepEqual((await B.loeschKandidaten('abgelehnt', dreissig)).map((d) => d.$id), ['b_abl_alt'])
|
|
|
|
const neunzig = new Date(JETZT.getTime() - 90 * TAG)
|
|
seedBuchung('b_erl_alt', { status: 'erledigt', endsAt: mit(-91 * TAG) })
|
|
seedBuchung('b_erl_neu', { status: 'erledigt', endsAt: mit(-89 * TAG) })
|
|
seedBuchung('b_nie_alt', { status: 'nichterschienen', endsAt: mit(-100 * TAG) })
|
|
assert.deepEqual((await B.loeschKandidaten('erledigt', neunzig)).map((d) => d.$id), ['b_erl_alt'])
|
|
assert.deepEqual((await B.loeschKandidaten('nichterschienen', neunzig)).map((d) => d.$id), ['b_nie_alt'])
|
|
|
|
for (let i = 0; i < 30; i += 1) seedBuchung(`b_viele${String(i).padStart(2, '0')}`, { status: 'erledigt', endsAt: mit(-200 * TAG) })
|
|
assert.equal((await B.loeschKandidaten('erledigt', neunzig)).length, 31)
|
|
await assert.rejects(B.loeschKandidaten('bestaetigt', neunzig), TypeError)
|
|
await assert.rejects(B.loeschKandidaten('gehalten', neunzig), TypeError)
|
|
})
|
|
|
|
test('loeschKandidaten: hoechstens limit (Standard 100), aelteste Bezugszeit zuerst, nach Funktion filterbar, ohne Gastfelder', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const neunzig = new Date(JETZT.getTime() - 90 * TAG)
|
|
const nr = (i) => String(i).padStart(3, '0')
|
|
// 150 alte erledigt; je groesser i, desto spaeter das Terminende; in umgekehrter Reihenfolge angelegt, jede dritte in der Funktion 2
|
|
for (let i = 149; i >= 0; i -= 1) {
|
|
seedBuchung(`b_e${nr(i)}`, { status: 'erledigt', endsAt: mit(-91 * TAG + i * MIN), featureId: i % 3 === 0 ? 'f_feature02' : F })
|
|
}
|
|
seedBuchung('b_jung', { status: 'erledigt', endsAt: mit(-89 * TAG) })
|
|
const ids = (liste) => liste.map((doc) => doc.$id)
|
|
const alle = Array.from({ length: 150 }, (_, i) => `b_e${nr(i)}`)
|
|
|
|
aw.resetListCalls()
|
|
const standard = await B.loeschKandidaten('erledigt', neunzig)
|
|
assert.deepEqual(ids(standard), alle.slice(0, 100), 'Standard 100, aelteste zuerst')
|
|
assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { limit: 10 })), alle.slice(0, 10))
|
|
assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { limit: 1 })), [alle[0]])
|
|
const zwei = await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_feature02' })
|
|
assert.deepEqual(ids(zwei), alle.filter((_, i) => i % 3 === 0), 'nur die Funktion 2, aelteste zuerst')
|
|
assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_feature02', limit: 5 })), alle.filter((_, i) => i % 3 === 0).slice(0, 5))
|
|
assert.deepEqual(await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_gibt_es_nicht' }), [])
|
|
|
|
// keine Gastfelder: weder Name noch Adresse, nur Kennung, Funktion, Status und die drei Bezugszeiten
|
|
const erlaubt = new Set(['featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil'])
|
|
for (const doc of standard) {
|
|
assert.ok(doc.$id)
|
|
assert.equal(doc.status, 'erledigt')
|
|
assert.deepEqual(Object.keys(doc).filter((key) => !key.startsWith('$') && !erlaubt.has(key)), [], `${doc.$id}: nur erlaubte Felder`)
|
|
assert.equal('gastName' in doc, false)
|
|
assert.equal('gastEmail' in doc, false)
|
|
}
|
|
// die Abfrage sagt es selbst: select ohne Gastfelder, limit, Reihenfolge nach der Bezugszeit
|
|
const abfrage = aw.listCalls('termineBookings')[0].queries
|
|
assert.deepEqual(abfrage.find((q) => q.method === 'select').values, ['$id', 'featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil'])
|
|
assert.deepEqual(abfrage.find((q) => q.method === 'limit').values, [100])
|
|
assert.deepEqual(abfrage.find((q) => q.method === 'orderAsc'), { method: 'orderAsc', attribute: 'endsAt' })
|
|
assert.deepEqual(abfrage.find((q) => q.method === 'lessThan'), { method: 'lessThan', attribute: 'endsAt', values: [iso(neunzig)] })
|
|
|
|
// Reihenfolge nach der Bezugszeit des Status: verfallen heldUntil, abgesagt updatedAt (Einfuegereihenfolge ist egal)
|
|
seedBuchung('b_v2', { status: 'verfallen', heldUntil: mit(-60 * STUNDE) })
|
|
seedBuchung('b_v1', { status: 'verfallen', heldUntil: mit(-70 * STUNDE) })
|
|
seedBuchung('b_v3', { status: 'verfallen', heldUntil: mit(-50 * STUNDE) })
|
|
assert.deepEqual(ids(await B.loeschKandidaten('verfallen', new Date(JETZT.getTime() - 48 * STUNDE))), ['b_v1', 'b_v2', 'b_v3'])
|
|
seedBuchung('b_a2', { status: 'abgesagt', updatedAt: mit(-40 * TAG) })
|
|
seedBuchung('b_a1', { status: 'abgesagt', updatedAt: mit(-50 * TAG) })
|
|
assert.deepEqual(ids(await B.loeschKandidaten('abgesagt', new Date(JETZT.getTime() - 30 * TAG))), ['b_a1', 'b_a2'])
|
|
|
|
// wie bisher: nicht loeschbare Status und ungueltige limit
|
|
await assert.rejects(B.loeschKandidaten('bestaetigt', neunzig, { limit: 10 }), TypeError)
|
|
await assert.rejects(B.loeschKandidaten('gehalten', neunzig), TypeError)
|
|
for (const limit of [0, -1, 1.5, NaN, '10']) await assert.rejects(B.loeschKandidaten('erledigt', neunzig, { limit }), TypeError, `limit ${limit}`)
|
|
})
|
|
|
|
test('ersteBuchungen: hoechstens limit (Standard 100) Buchungen einer Funktion, jeder Status, nur die Kennung (nie Gastfelder)', async () => {
|
|
const nr = (i) => String(i).padStart(3, '0')
|
|
for (let i = 0; i < 130; i += 1) seedBuchung(`b_a${nr(i)}`, { status: ['bestaetigt', 'erledigt', 'abgesagt', 'gehalten'][i % 4] })
|
|
for (let i = 0; i < 7; i += 1) seedBuchung(`b_fremd${i}`, { featureId: 'f_feature02' })
|
|
const alleA = Array.from({ length: 130 }, (_, i) => `b_a${nr(i)}`)
|
|
|
|
aw.resetListCalls()
|
|
const standard = await B.ersteBuchungen(F)
|
|
assert.equal(standard.length, 100, 'Standard 100')
|
|
assert.ok(standard.every((doc) => alleA.includes(doc.$id)), 'nur Buchungen dieser Funktion')
|
|
assert.equal(new Set(standard.map((doc) => doc.$id)).size, 100)
|
|
assert.equal((await B.ersteBuchungen(F, 10)).length, 10)
|
|
assert.equal((await B.ersteBuchungen(F, 1)).length, 1)
|
|
assert.deepEqual((await B.ersteBuchungen('f_feature02')).map((doc) => doc.$id).sort(), Array.from({ length: 7 }, (_, i) => `b_fremd${i}`))
|
|
assert.deepEqual(await B.ersteBuchungen('f_gibt_es_nicht'), [])
|
|
|
|
// nur die Kennung: keine Gastfelder, und die Abfrage sagt es selbst (select, limit, Funktionsfilter)
|
|
for (const doc of standard) {
|
|
assert.ok(doc.$id)
|
|
assert.deepEqual(Object.keys(doc).filter((key) => !key.startsWith('$')), [], `${doc.$id}: keine Felder ausser den $-Feldern`)
|
|
}
|
|
const aufrufe = aw.listCalls('termineBookings')
|
|
const abfrage = aufrufe[0].queries
|
|
assert.deepEqual(abfrage.find((q) => q.method === 'select').values, ['$id'])
|
|
assert.deepEqual(abfrage.find((q) => q.method === 'limit').values, [100])
|
|
assert.deepEqual(abfrage.find((q) => q.method === 'equal'), { method: 'equal', attribute: 'featureId', values: [F] })
|
|
for (const { queries } of aufrufe) assert.ok(queries.find((q) => q.method === 'limit').values[0] <= 100)
|
|
|
|
// wer loescht, liest neu: Geloeschtes fehlt in der naechsten Seite
|
|
for (const doc of standard) await B.loesche(doc.$id)
|
|
const rest = await B.ersteBuchungen(F)
|
|
assert.deepEqual(rest.map((doc) => doc.$id).sort(), alleA.filter((id) => !standard.some((doc) => doc.$id === id)))
|
|
assert.equal(rest.length, 30)
|
|
|
|
// ungueltige Eingaben: nie eine Abfrage ohne Funktionsfilter oder ohne Grenze
|
|
aw.resetListCalls()
|
|
for (const featureId of [undefined, null, '', 5]) await assert.rejects(B.ersteBuchungen(featureId), TypeError, `featureId ${featureId}`)
|
|
for (const limit of [0, -1, 1.5, NaN, '10', null]) await assert.rejects(B.ersteBuchungen(F, limit), TypeError, `limit ${limit}`)
|
|
assert.equal(aw.listCalls('termineBookings').length, 0, 'bei ungueltigen Eingaben geht keine Abfrage raus')
|
|
})
|
|
|
|
test('zaehleAnfragen: offene Anfragen einer Funktion (auch ueber 25)', async () => {
|
|
for (let i = 0; i < 27; i += 1) seedBuchung(`b_anf${String(i).padStart(2, '0')}`, { status: 'angefragt' })
|
|
seedBuchung('b_best', { status: 'bestaetigt' })
|
|
seedBuchung('b_fremd', { status: 'angefragt', featureId: 'f_feature02' })
|
|
assert.equal(await B.zaehleAnfragen(F), 27)
|
|
assert.equal(await B.zaehleAnfragen('f_feature02'), 1)
|
|
assert.equal(await B.zaehleAnfragen('f_leer'), 0)
|
|
})
|