test(scheduling): property-based slot tests + guarded Stalwart integration test

This commit is contained in:
Ronni Baslund
2026-06-07 09:23:16 +02:00
parent 8bbb7881a4
commit b2c2650af9
4 changed files with 558 additions and 0 deletions
+1
View File
@@ -37,6 +37,7 @@
"@types/jest": "^29.5.12",
"@types/luxon": "^3.4.2",
"@types/node": "^20.0.0",
"fast-check": "^4.8.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.5.0",
+16
View File
@@ -75,6 +75,9 @@ importers:
'@types/node':
specifier: ^20.0.0
version: 20.19.41
fast-check:
specifier: ^4.8.0
version: 4.8.0
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
@@ -1319,6 +1322,10 @@ packages:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
fast-check@4.8.0:
resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==}
engines: {node: '>=12.17.0'}
fast-content-type-parse@1.1.0:
resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
@@ -2181,6 +2188,9 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
pure-rand@8.4.0:
resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
@@ -4355,6 +4365,10 @@ snapshots:
iconv-lite: 0.4.24
tmp: 0.0.33
fast-check@4.8.0:
dependencies:
pure-rand: 8.4.0
fast-content-type-parse@1.1.0: {}
fast-decode-uri-component@1.0.1: {}
@@ -5382,6 +5396,8 @@ snapshots:
pure-rand@6.1.0: {}
pure-rand@8.4.0: {}
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -0,0 +1,171 @@
import fc from 'fast-check'
import { DateTime } from 'luxon'
import type { DateOverride, WeeklyRule } from '../../schemas/availability-schedule.schema.js'
import { computeSlots, SlotComputeInput } from './slot-computer.js'
// Property-based invariants for the pure slot computer. Rather than asserting a
// hand-picked output, these generate many randomised configurations and assert
// the laws every output MUST obey, regardless of input:
// 1. no returned slot, padded by its buffers, overlaps any busy interval
// 2. every slot start lands on the slotIntervalMinutes grid inside an
// availability window (in the schedule zone)
// 3. nothing starts before now + minimumNotice, nor on/after the booking
// horizon (now + maximumDaysInFuture) or the requested window end
// Each run uses several IANA zones (incl. ones that observe DST) so the offset
// math is exercised, not just a fixed UTC offset.
const ZONES = [
'Europe/Copenhagen',
'America/New_York',
'Asia/Tokyo',
'Australia/Sydney',
'UTC',
]
// A single interval in minutes-from-midnight, non-empty and within a day.
const intervalArb = fc
.tuple(fc.integer({ min: 0, max: 1380 }), fc.integer({ min: 15, max: 1440 }))
.map(([a, len]) => {
const startMinute = a
const endMinute = Math.min(1440, a + len)
return { startMinute, endMinute }
})
.filter((i) => i.endMinute > i.startMinute)
const weeklyRulesArb: fc.Arbitrary<WeeklyRule[]> = fc.array(
fc.record({
dayOfWeek: fc.integer({ min: 0, max: 6 }),
intervals: fc.array(intervalArb, { minLength: 0, maxLength: 3 }),
}),
{ minLength: 0, maxLength: 7 },
)
const dateOverridesArb: fc.Arbitrary<DateOverride[]> = fc.array(
fc.record({
// Dates within the generated planning window (June 2026).
date: fc
.integer({ min: 1, max: 28 })
.map((d) => `2026-06-${String(d).padStart(2, '0')}`),
isUnavailable: fc.boolean(),
intervals: fc.array(intervalArb, { minLength: 0, maxLength: 3 }),
}),
{ minLength: 0, maxLength: 4 },
)
// A busy interval, anchored near the planning window, lasting up to 8h.
const busyArb = fc
.tuple(fc.integer({ min: 0, max: 20 }), fc.integer({ min: 0, max: 1440 }), fc.integer({ min: 15, max: 480 }))
.map(([dayOffset, minuteOfDay, lenMin]) => {
const start = new Date(Date.UTC(2026, 5, 1, 0, 0, 0) + dayOffset * 86_400_000 + minuteOfDay * 60_000)
const end = new Date(start.getTime() + lenMin * 60_000)
return { startUtc: start, endUtc: end }
})
const inputArb: fc.Arbitrary<SlotComputeInput> = fc
.record({
durationMinutes: fc.constantFrom(15, 30, 45, 60, 90),
slotIntervalMinutes: fc.constantFrom(15, 30, 60),
bufferBeforeMinutes: fc.constantFrom(0, 5, 10, 15, 30),
bufferAfterMinutes: fc.constantFrom(0, 5, 10, 15, 30),
minimumNoticeMinutes: fc.constantFrom(0, 60, 120, 720),
maximumDaysInFuture: fc.integer({ min: 1, max: 21 }),
scheduleTimezone: fc.constantFrom(...ZONES),
weeklyRules: weeklyRulesArb,
dateOverrides: dateOverridesArb,
busy: fc.array(busyArb, { minLength: 0, maxLength: 6 }),
})
.map((cfg) => ({
...cfg,
now: new Date('2026-06-01T00:00:00Z'),
fromUtc: new Date('2026-06-01T00:00:00Z'),
toUtc: new Date('2026-06-22T00:00:00Z'),
}))
// Reconstruct, for a given slot, the local availability intervals that applied
// to its date (override wins over weekly rule), mirroring the computer's logic.
function intervalsForSlot(input: SlotComputeInput, startUtc: Date): { startMinute: number; endMinute: number }[] {
const local = DateTime.fromJSDate(startUtc, { zone: input.scheduleTimezone })
const isoDate = local.toFormat('yyyy-MM-dd')
// Mirror the computer's `new Map(...)` semantics exactly: on duplicate dates,
// the LAST override wins (the Map keeps the final entry for a key).
const byDate = new Map(input.dateOverrides.map((o) => [o.date, o]))
const override = byDate.get(isoDate)
if (override) return override.isUnavailable ? [] : override.intervals ?? []
const jsDow = local.weekday % 7
return input.weeklyRules.filter((r) => r.dayOfWeek === jsDow).flatMap((r) => r.intervals ?? [])
}
describe('computeSlots — properties', () => {
it('never returns a slot whose buffered window overlaps a busy interval', () => {
fc.assert(
fc.property(inputArb, (input) => {
const slots = computeSlots(input)
for (const s of slots) {
const padStart = s.startUtc.getTime() - input.bufferBeforeMinutes * 60_000
const padEnd = s.endUtc.getTime() + input.bufferAfterMinutes * 60_000
for (const b of input.busy) {
// Overlap iff padStart < busyEnd && padEnd > busyStart (touching is OK).
expect(padStart < b.endUtc.getTime() && padEnd > b.startUtc.getTime()).toBe(false)
}
}
}),
{ numRuns: 300 },
)
})
it('aligns every slot start to the interval grid inside an availability window', () => {
fc.assert(
fc.property(inputArb, (input) => {
const slots = computeSlots(input)
for (const s of slots) {
const local = DateTime.fromJSDate(s.startUtc, { zone: input.scheduleTimezone })
const minuteOfDay = local.hour * 60 + local.minute
const intervals = intervalsForSlot(input, s.startUtc)
// The slot must sit fully inside some window, on the interval grid
// measured from that window's start, and last exactly durationMinutes.
const fitsSome = intervals.some((intv) => {
const aligned = (minuteOfDay - intv.startMinute) % input.slotIntervalMinutes === 0
const within = minuteOfDay >= intv.startMinute && minuteOfDay + input.durationMinutes <= intv.endMinute
return aligned && within
})
expect(local.second).toBe(0)
expect(local.millisecond).toBe(0)
expect(fitsSome).toBe(true)
expect(s.endUtc.getTime() - s.startUtc.getTime()).toBe(input.durationMinutes * 60_000)
}
}),
{ numRuns: 300 },
)
})
it('keeps every slot within [now+minNotice, horizon) and the requested window', () => {
fc.assert(
fc.property(inputArb, (input) => {
const slots = computeSlots(input)
const earliest = Math.max(
input.now.getTime() + input.minimumNoticeMinutes * 60_000,
input.fromUtc.getTime(),
)
const horizon = input.now.getTime() + input.maximumDaysInFuture * 86_400_000
const latest = Math.min(horizon, input.toUtc.getTime())
for (const s of slots) {
expect(s.startUtc.getTime()).toBeGreaterThanOrEqual(earliest)
expect(s.startUtc.getTime()).toBeLessThan(latest)
}
}),
{ numRuns: 300 },
)
})
it('returns strictly sorted, de-duplicated slot starts', () => {
fc.assert(
fc.property(inputArb, (input) => {
const starts = computeSlots(input).map((s) => s.startUtc.getTime())
for (let i = 1; i < starts.length; i++) {
expect(starts[i]).toBeGreaterThan(starts[i - 1])
}
}),
{ numRuns: 200 },
)
})
})
@@ -0,0 +1,370 @@
import { jest } from '@jest/globals'
import mongoose from 'mongoose'
import { DateTime } from 'luxon'
import { createCipheriv, randomBytes, randomUUID } from 'node:crypto'
// Guarded live integration test for dezky Scheduling against the dockerized
// Stalwart + the running platform-api. It ONLY runs when the JMAP session and
// the API are reachable; otherwise the whole suite is skipped so `pnpm test`
// stays green for anyone without the full stack up. When it does run it seeds a
// throwaway host (domain + mailbox via Stalwart admin JMAP, an app-password
// AES-encrypted with SCHEDULING_CREDENTIAL_KEY, and Host/credential/
// availability/event-type docs in Mongo — mirroring the service), then drives
// the real public API and asserts the booking actually lands on the calendar
// and that concurrent same-slot bookings resolve to exactly one winner.
const API = process.env.SCHED_IT_API ?? 'http://localhost:3001'
const STAL = process.env.SCHED_IT_STALWART ?? 'http://stalwart:8080'
const MONGODB_URI = process.env.MONGODB_URI ?? ''
const ADMIN_PW = process.env.STALWART_ADMIN_PASSWORD ?? ''
const CRED_KEY = process.env.SCHEDULING_CREDENTIAL_KEY ?? ''
const TENANT_SLUG = 'baslund-test'
// Throwaway names — never the seeded demo host "anne".
const DOMAIN = 'sched-it.dezky.local'
const RUN = randomUUID().slice(0, 8)
const HOST_LP = `ithost-${RUN}`
const GUEST_LP = `itguest-${RUN}`
const HOST_EMAIL = `${HOST_LP}@${DOMAIN}`
const GUEST_EMAIL = `${GUEST_LP}@${DOMAIN}`
const HOST_PW = 'H-' + randomUUID()
const GUEST_PW = 'G-' + randomUUID()
const HOST_SLUG = `it-${RUN}`
const ET_SLUG = 'consult'
const TZ = 'Europe/Copenhagen'
const adminAuth = () => 'Basic ' + Buffer.from('admin:' + ADMIN_PW).toString('base64')
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64')
// Probe reachability up-front. Top-level await is fine under jest's ESM VM.
// Any HTTP response (even 401) counts as reachable; only a network/DNS failure
// (e.g. running outside the docker network) marks the stack absent.
async function reachable(url: string, init?: RequestInit): Promise<boolean> {
try {
await fetch(url, init)
return true
} catch {
return false
}
}
const stackUp =
!!MONGODB_URI &&
!!ADMIN_PW &&
!!CRED_KEY &&
(await reachable(`${STAL}/.well-known/jmap`)) &&
(await reachable(`${API}/api/v1/public/__probe__/__probe__/__probe__`))
if (!stackUp) {
// eslint-disable-next-line no-console
console.warn(
'[scheduling integration] Stalwart/API/env not reachable — skipping live integration suite.',
)
}
const describeLive = stackUp ? describe : describe.skip
type JmapResp = { methodResponses?: [string, Record<string, unknown>, string][] }
async function jmap(
auth: string,
methodCalls: unknown[],
using = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
): Promise<JmapResp> {
const r = await fetch(`${STAL}/jmap`, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
})
return r.json() as Promise<JmapResp>
}
const R = (j: JmapResp, n: string): Record<string, unknown> | null =>
(j.methodResponses || []).find((x) => x[0] === n)?.[1] ?? null
function seal(plain: string): { encryptedSecret: string; iv: string; authTag: string } {
const key = Buffer.from(CRED_KEY, 'hex')
const iv = randomBytes(12)
const c = createCipheriv('aes-256-gcm', key, iv)
const enc = Buffer.concat([c.update(plain, 'utf8'), c.final()])
return {
encryptedSecret: enc.toString('base64'),
iv: iv.toString('base64'),
authTag: c.getAuthTag().toString('base64'),
}
}
describeLive('scheduling ↔ Stalwart (live integration)', () => {
jest.setTimeout(60_000)
let conn: typeof mongoose
let hostId: mongoose.Types.ObjectId
const base = `${API}/api/v1/public/${TENANT_SLUG}/${HOST_SLUG}/${ET_SLUG}`
beforeAll(async () => {
conn = await mongoose.connect(MONGODB_URI)
const db = conn.connection.db!
const oid = () => new mongoose.Types.ObjectId()
const tenant = await db.collection('tenants').findOne({ slug: TENANT_SLUG })
if (!tenant) throw new Error(`tenant ${TENANT_SLUG} not found`)
const tenantId = tenant._id
// ── Stalwart: domain + host & guest mailboxes ──
let j = await jmap(adminAuth(), [['x:Domain/query', { filter: { name: DOMAIN } }, 'q']])
let domainId = (R(j, 'x:Domain/query') as { ids?: string[] } | null)?.ids?.[0]
if (!domainId) {
j = await jmap(adminAuth(), [['x:Domain/set', { create: { d: { name: DOMAIN } } }, 's']])
domainId = ((R(j, 'x:Domain/set') as { created?: { d?: { id?: string } } } | null)?.created?.d?.id) ?? undefined
}
const ensureMbx = async (lp: string, pw: string, full: string): Promise<string> => {
const c = await jmap(adminAuth(), [
[
'x:Account/set',
{
create: {
u: {
'@type': 'User',
name: lp,
domainId,
description: full,
credentials: { '0': { '@type': 'Password', secret: pw } },
},
},
},
's',
],
])
const id = (R(c, 'x:Account/set') as { created?: { u?: { id?: string } } } | null)?.created?.u?.id
if (!id) throw new Error(`failed to create mailbox ${lp}: ${JSON.stringify(c)}`)
return id
}
const hostAcct = await ensureMbx(HOST_LP, HOST_PW, 'IT Host')
await ensureMbx(GUEST_LP, GUEST_PW, 'IT Guest')
// ── app password (on-behalf) + encrypt ──
j = await jmap(adminAuth(), [
[
'x:AppPassword/set',
{
accountId: hostAcct,
create: {
ap: { description: `dezky-scheduling-it:${RUN}`, permissions: { '@type': 'Inherit' }, allowedIps: {} },
},
},
's',
],
])
const ap = (R(j, 'x:AppPassword/set') as { created?: { ap?: { id?: string; secret?: string } } } | null)?.created?.ap
if (!ap?.secret) throw new Error('failed to mint app password')
const sealed = seal(ap.secret)
// ── host calendar id (as host) ──
const hostAuth = basic(HOST_EMAIL, HOST_PW)
const ses = (await (await fetch(`${STAL}/.well-known/jmap`, { headers: { Authorization: hostAuth } })).json()) as {
primaryAccounts: Record<string, string>
}
const calAcct = ses.primaryAccounts['urn:ietf:params:jmap:calendars']
const cg = (await (
await fetch(`${STAL}/jmap`, {
method: 'POST',
headers: { Authorization: hostAuth, 'Content-Type': 'application/json' },
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:calendars'],
methodCalls: [['Calendar/get', { accountId: calAcct }, 'c']],
}),
})
).json()) as { methodResponses: [string, { list?: { id?: string }[] }, string][] }
const calId = cg.methodResponses[0][1].list?.[0]?.id
if (!calId) throw new Error('host calendar not found')
// ── insert Host / credential / availability / event-type ──
const now = new Date()
hostId = oid()
const schedId = oid()
const credId = oid()
const etId = oid()
await db.collection('scheduling_hosts').insertOne({
_id: hostId,
tenantId,
authentikUserId: `seed-it-${RUN}`,
email: HOST_EMAIL,
displayName: 'IT Host',
slug: HOST_SLUG,
timezone: TZ,
stalwartAccountId: hostAcct,
defaultCalendarId: calId,
busyCalendarIds: [calId],
isActive: true,
createdAt: now,
updatedAt: now,
})
await db.collection('scheduling_stalwart_credentials').insertOne({
_id: credId,
tenantId,
hostId,
type: 'app_password',
...sealed,
appPasswordId: ap.id,
jmapSessionUrl: `${STAL}/.well-known/jmap`,
lastValidatedAt: now,
createdAt: now,
updatedAt: now,
})
await db.collection('scheduling_availability_schedules').insertOne({
_id: schedId,
tenantId,
hostId,
name: 'Working hours',
timezone: TZ,
weeklyRules: [1, 2, 3, 4, 5].map((dayOfWeek) => ({
dayOfWeek,
intervals: [{ startMinute: 540, endMinute: 1020 }],
})),
dateOverrides: [],
createdAt: now,
updatedAt: now,
})
await db.collection('scheduling_event_types').insertOne({
_id: etId,
tenantId,
hostId,
slug: ET_SLUG,
title: 'IT consultation',
description: 'Throwaway integration-test event type.',
durationMinutes: 30,
slotIntervalMinutes: 30,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
minimumNoticeMinutes: 0,
maximumDaysInFuture: 60,
availabilityScheduleId: schedId,
locationType: 'jitsi',
isActive: true,
createdAt: now,
updatedAt: now,
})
})
afterAll(async () => {
// Tidy the throwaway Mongo docs + Stalwart accounts so reruns stay clean.
try {
if (conn?.connection?.db && hostId) {
const db = conn.connection.db
await db.collection('scheduling_bookings').deleteMany({ hostId })
await db.collection('scheduling_slot_locks').deleteMany({ hostId })
await db.collection('scheduling_event_types').deleteMany({ hostId })
await db.collection('scheduling_availability_schedules').deleteMany({ hostId })
await db.collection('scheduling_stalwart_credentials').deleteMany({ hostId })
await db.collection('scheduling_hosts').deleteOne({ _id: hostId })
}
const q = await jmap(adminAuth(), [
['x:Account/query', { filter: {} }, 'q'],
[
'x:Account/get',
{ '#ids': { resultOf: 'q', name: 'x:Account/query', path: '/ids' }, properties: ['emailAddress'] },
'g',
],
])
const list = (R(q, 'x:Account/get') as { list?: { id: string; emailAddress?: string }[] } | null)?.list ?? []
const toDestroy = list
.filter((a) => a.emailAddress === HOST_EMAIL || a.emailAddress === GUEST_EMAIL)
.map((a) => a.id)
if (toDestroy.length) await jmap(adminAuth(), [['x:Account/set', { destroy: toDestroy }, 'd']])
} finally {
await mongoose.disconnect()
}
})
// Resolve a target weekday window once and reuse it across assertions.
let from = ''
let to = ''
beforeAll(() => {
let day = DateTime.now().setZone(TZ).plus({ days: 7 }).startOf('day')
while (day.weekday > 5) day = day.plus({ days: 1 })
from = day.toUTC().toISO()!
to = day.plus({ days: 1 }).toUTC().toISO()!
})
const fetchSlots = async (): Promise<string[]> => {
const r = await fetch(
`${base}/slots?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&timezone=${TZ}`,
)
const j = (await r.json()) as { slots?: { startUtc: string }[] }
return (j.slots ?? []).map((s) => s.startUtc)
}
it('serves the seeded event type and offers slots on a working day', async () => {
const info = (await (await fetch(base)).json()) as { eventType?: { title?: string } }
expect(info?.eventType?.title).toBe('IT consultation')
const slots = await fetchSlots()
expect(slots.length).toBeGreaterThan(0)
})
it('writes a busy calendar event on booking and removes the slot from availability', async () => {
const slots = await fetchSlots()
expect(slots.length).toBeGreaterThan(0)
const target = slots[0]
const bk = await fetch(`${base}/bookings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startUtc: target,
attendeeName: 'IT Guest',
attendeeEmail: GUEST_EMAIL,
attendeeTimezone: TZ,
}),
})
expect([200, 201]).toContain(bk.status)
// The host's calendar free/busy must now report the booked slot as busy.
const hostAuth = basic(HOST_EMAIL, HOST_PW)
const ses = (await (await fetch(`${STAL}/.well-known/jmap`, { headers: { Authorization: hostAuth } })).json()) as {
primaryAccounts: Record<string, string>
}
const calAcct = ses.primaryAccounts['urn:ietf:params:jmap:calendars']
const av = (await (
await fetch(`${STAL}/jmap`, {
method: 'POST',
headers: { Authorization: hostAuth, 'Content-Type': 'application/json' },
body: JSON.stringify({
using: [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:principals',
'urn:ietf:params:jmap:principals:availability',
],
methodCalls: [['Principal/getAvailability', { accountId: calAcct, id: calAcct, utcStart: from, utcEnd: to }, 'a']],
}),
})
).json()) as { methodResponses: [string, { list?: { utcStart: string }[] }, string][] }
const busy = (av.methodResponses[0][1].list ?? []).map((b) => new Date(b.utcStart).getTime())
expect(busy).toContain(new Date(target).getTime())
const after = await fetchSlots()
expect(after).not.toContain(target)
})
it('resolves concurrent same-slot bookings to exactly one winner and one conflict', async () => {
const slots = await fetchSlots()
expect(slots.length).toBeGreaterThan(0)
const target = slots[slots.length - 1]
const statuses = await Promise.all(
[0, 1].map(() =>
fetch(`${base}/bookings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startUtc: target,
attendeeName: 'Racer',
attendeeEmail: GUEST_EMAIL,
attendeeTimezone: TZ,
}),
}).then((r) => r.status),
),
)
const wins = statuses.filter((s) => s === 200 || s === 201).length
const conflicts = statuses.filter((s) => s === 409).length
expect(wins).toBe(1)
expect(conflicts).toBe(1)
})
})