From b2c2650af93365c9040ef75bfb8d0f5e9a662d6c Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 7 Jun 2026 09:23:16 +0200 Subject: [PATCH] test(scheduling): property-based slot tests + guarded Stalwart integration test --- services/platform-api/package.json | 1 + services/platform-api/pnpm-lock.yaml | 16 + .../slots/slot-computer.property.spec.ts | 171 ++++++++ .../scheduling-stalwart.integration.spec.ts | 370 ++++++++++++++++++ 4 files changed, 558 insertions(+) create mode 100644 services/platform-api/src/scheduling/slots/slot-computer.property.spec.ts create mode 100644 services/platform-api/src/scheduling/stalwart-calendar/scheduling-stalwart.integration.spec.ts diff --git a/services/platform-api/package.json b/services/platform-api/package.json index 85348ef..089d8be 100644 --- a/services/platform-api/package.json +++ b/services/platform-api/package.json @@ -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", diff --git a/services/platform-api/pnpm-lock.yaml b/services/platform-api/pnpm-lock.yaml index f3fd379..16561a1 100644 --- a/services/platform-api/pnpm-lock.yaml +++ b/services/platform-api/pnpm-lock.yaml @@ -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 diff --git a/services/platform-api/src/scheduling/slots/slot-computer.property.spec.ts b/services/platform-api/src/scheduling/slots/slot-computer.property.spec.ts new file mode 100644 index 0000000..3b77bf2 --- /dev/null +++ b/services/platform-api/src/scheduling/slots/slot-computer.property.spec.ts @@ -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 = 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 = 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 = 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 }, + ) + }) +}) diff --git a/services/platform-api/src/scheduling/stalwart-calendar/scheduling-stalwart.integration.spec.ts b/services/platform-api/src/scheduling/stalwart-calendar/scheduling-stalwart.integration.spec.ts new file mode 100644 index 0000000..6575994 --- /dev/null +++ b/services/platform-api/src/scheduling/stalwart-calendar/scheduling-stalwart.integration.spec.ts @@ -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 { + 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][] } +async function jmap( + auth: string, + methodCalls: unknown[], + using = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], +): Promise { + 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 +} +const R = (j: JmapResp, n: string): Record | 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 => { + 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 + } + 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 => { + 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 + } + 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) + }) +})