test(scheduling): property-based slot tests + guarded Stalwart integration test
This commit is contained in:
@@ -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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
+370
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user