OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / 7d47dfb

Google Calendar integration: core module + slot filtering + tests
New: lib/gcal.js — GoogleCalendarClient with: - OAuth2 token lifecycle (auth URL, code exchange, refresh) - FreeBusy API client with busy block merging - Per-date caching (5-min TTL in Hyperbee) - Injected httpClient for testing (mock Google API) - Graceful degradation (returns [] on any error) - Disconnect (clears tokens + cache) Modified: calculateSlots() now accepts optional externalBusy parameter. Google Calendar busy blocks filter slots alongside existing bookings. Both slot API and booking validation respect Google Calendar. 17 new tests (34 total, 174 assertions): - mergeBusyBlocks: empty, single, overlapping, adjacent, multi-calendar - getBusyBlocksForDate: basic, caching, TTL expiry, error fallback, not connected - disconnect: clears all data - calculateSlots: externalBusy filtering, empty array, multiple blocks - Integration: slots API with gcal, without gcal, booking rejection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7d47dfb99d9e4488845b74258bb8e0670dfa94c6
by plankatron <fleeky@gmail.com> · 2026-03-22T19:31:57.000Z

Changed files (5) +927 -9

lib/gcal.js +275 -0
……@@ -0,0 +1,275 @@
1/**
2 * Google Calendar Client
3 *
4 * Handles OAuth2 token lifecycle, FreeBusy API queries, and caching.
5 * Accepts injected httpClient for testing (defaults to bare-fetch).
6 * All outbound calls wrapped in try/catch — never blocks slot calculation.
7 */
8
9const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
10const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
11const GOOGLE_FREEBUSY_URL = 'https://www.googleapis.com/calendar/v3/freeBusy'
12const GOOGLE_CALENDARLIST_URL = 'https://www.googleapis.com/calendar/v3/users/me/calendarList'
13const SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'
14
15export class GoogleCalendarClient {
16 constructor (options = {}) {
17 this.clientId = options.clientId || ''
18 this.clientSecret = options.clientSecret || ''
19 this.httpClient = options.httpClient || null
20 this.cacheTTL = options.cacheTTL != null ? options.cacheTTL : 5 * 60 * 1000
21 }
22
23 /**
24 * Get the HTTP client (lazy-load bare-fetch if not injected)
25 */
26 async _fetch (url, opts) {
27 if (this.httpClient) return this.httpClient(url, opts)
28 if (!this._bareFetch) {
29 const mod = await import('bare-fetch')
30 this._bareFetch = mod.default || mod
31 }
32 return this._bareFetch(url, opts)
33 }
34
35 /**
36 * Build Google OAuth2 authorization URL
37 */
38 getAuthUrl (redirectUri, state) {
39 const params = new URLSearchParams({
40 client_id: this.clientId,
41 redirect_uri: redirectUri,
42 response_type: 'code',
43 scope: SCOPE,
44 access_type: 'offline',
45 prompt: 'consent',
46 state
47 })
48 return GOOGLE_AUTH_URL + '?' + params.toString()
49 }
50
51 /**
52 * Exchange authorization code for tokens
53 */
54 async exchangeCode (code, redirectUri) {
55 const body = new URLSearchParams({
56 code,
57 client_id: this.clientId,
58 client_secret: this.clientSecret,
59 redirect_uri: redirectUri,
60 grant_type: 'authorization_code'
61 })
62
63 const res = await this._fetch(GOOGLE_TOKEN_URL, {
64 method: 'POST',
65 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
66 body: body.toString()
67 })
68
69 const data = await res.json()
70 if (!res.ok) throw new Error(data.error_description || data.error || 'Token exchange failed')
71
72 return {
73 accessToken: data.access_token,
74 refreshToken: data.refresh_token,
75 expiresIn: data.expires_in,
76 scope: data.scope
77 }
78 }
79
80 /**
81 * Refresh an expired access token
82 */
83 async refreshAccessToken (refreshToken) {
84 const body = new URLSearchParams({
85 refresh_token: refreshToken,
86 client_id: this.clientId,
87 client_secret: this.clientSecret,
88 grant_type: 'refresh_token'
89 })
90
91 const res = await this._fetch(GOOGLE_TOKEN_URL, {
92 method: 'POST',
93 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
94 body: body.toString()
95 })
96
97 const data = await res.json()
98 if (!res.ok) throw new Error(data.error_description || data.error || 'Token refresh failed')
99
100 return {
101 accessToken: data.access_token,
102 expiresIn: data.expires_in
103 }
104 }
105
106 /**
107 * Get a valid access token for a provider (refresh if expired)
108 */
109 async getValidToken (db, providerId) {
110 const entry = await db.get(`gcal:provider:${providerId}`)
111 if (!entry || !entry.value.connected) return null
112
113 const gcal = entry.value
114 const now = Date.now()
115
116 // Token still valid (with 5-min buffer)
117 if (gcal.accessToken && gcal.tokenExpiry && now < gcal.tokenExpiry - 300000) {
118 return gcal.accessToken
119 }
120
121 // Refresh needed
122 if (!gcal.refreshToken) return null
123
124 try {
125 const refreshed = await this.refreshAccessToken(gcal.refreshToken)
126 gcal.accessToken = refreshed.accessToken
127 gcal.tokenExpiry = now + (refreshed.expiresIn * 1000)
128 await db.put(`gcal:provider:${providerId}`, gcal)
129 return gcal.accessToken
130 } catch (err) {
131 console.error('[GCal] Token refresh failed for', providerId, err.message)
132 // Mark as disconnected if refresh fails (token revoked)
133 gcal.connected = false
134 await db.put(`gcal:provider:${providerId}`, gcal)
135 return null
136 }
137 }
138
139 /**
140 * Fetch free/busy blocks from Google Calendar API
141 * Returns merged array of { start, end } (UTC ms timestamps)
142 */
143 async fetchFreeBusy (accessToken, calendarIds, timeMin, timeMax) {
144 const items = calendarIds.map(id => ({ id }))
145
146 const res = await this._fetch(GOOGLE_FREEBUSY_URL, {
147 method: 'POST',
148 headers: {
149 Authorization: 'Bearer ' + accessToken,
150 'Content-Type': 'application/json'
151 },
152 body: JSON.stringify({ timeMin, timeMax, items })
153 })
154
155 const data = await res.json()
156 if (!res.ok) throw new Error(data.error?.message || 'FreeBusy query failed')
157
158 return mergeBusyBlocks(data)
159 }
160
161 /**
162 * Get busy blocks for a specific date, with caching.
163 * Returns array of { start, end } in milliseconds-of-day (local time).
164 */
165 async getBusyBlocksForDate (db, providerId, date, calendarIds) {
166 // Check cache
167 const cacheKey = `gcal:cache:${providerId}:${date}`
168 const cached = await db.get(cacheKey)
169
170 if (cached && Date.now() - cached.value.fetchedAt < this.cacheTTL) {
171 return cached.value.busy
172 }
173
174 // Fetch from Google
175 const accessToken = await this.getValidToken(db, providerId)
176 if (!accessToken) return []
177
178 try {
179 // Query for the full day in UTC
180 const timeMin = date + 'T00:00:00Z'
181 const timeMax = date + 'T23:59:59Z'
182
183 const busyUtc = await this.fetchFreeBusy(accessToken, calendarIds, timeMin, timeMax)
184
185 // Convert UTC timestamps to ms-of-day
186 // For simplicity, treat as UTC day boundaries (timezone offset applied later if needed)
187 const dateStart = new Date(date + 'T00:00:00Z').getTime()
188 const busy = busyUtc.map(block => ({
189 start: Math.max(0, block.start - dateStart),
190 end: Math.min(24 * 60 * 60 * 1000, block.end - dateStart)
191 })).filter(b => b.end > 0 && b.start < 24 * 60 * 60 * 1000)
192
193 // Cache the result
194 await db.put(cacheKey, { busy, fetchedAt: Date.now() })
195
196 return busy
197 } catch (err) {
198 console.error('[GCal] FreeBusy fetch failed for', providerId, date, err.message)
199 return []
200 }
201 }
202
203 /**
204 * Fetch list of calendars for selection UI
205 */
206 async fetchCalendarList (accessToken) {
207 const res = await this._fetch(GOOGLE_CALENDARLIST_URL + '?minAccessRole=freeBusyReader', {
208 headers: { Authorization: 'Bearer ' + accessToken }
209 })
210
211 const data = await res.json()
212 if (!res.ok) throw new Error(data.error?.message || 'Calendar list failed')
213
214 return (data.items || []).map(cal => ({
215 id: cal.id,
216 summary: cal.summary || cal.id,
217 primary: cal.primary || false
218 }))
219 }
220
221 /**
222 * Disconnect — remove tokens and cached data
223 */
224 async disconnect (db, providerId) {
225 await db.del(`gcal:provider:${providerId}`)
226
227 // Clear all cached busy blocks
228 const delKeys = []
229 for await (const entry of db.createReadStream({
230 gte: `gcal:cache:${providerId}:`,
231 lte: `gcal:cache:${providerId}:~`
232 })) {
233 delKeys.push(entry.key)
234 }
235 for (const key of delKeys) {
236 await db.del(key)
237 }
238 }
239}
240
241/**
242 * Merge busy blocks from all calendars in a FreeBusy response.
243 * Returns sorted, non-overlapping array of { start, end } (UTC ms).
244 */
245export function mergeBusyBlocks (freeBusyResponse) {
246 const allBusy = []
247
248 const calendars = freeBusyResponse.calendars || {}
249 for (const calData of Object.values(calendars)) {
250 for (const block of calData.busy || []) {
251 allBusy.push({
252 start: new Date(block.start).getTime(),
253 end: new Date(block.end).getTime()
254 })
255 }
256 }
257
258 if (allBusy.length === 0) return allBusy
259
260 // Sort by start time
261 allBusy.sort((a, b) => a.start - b.start)
262
263 // Merge overlapping blocks
264 const merged = [allBusy[0]]
265 for (let i = 1; i < allBusy.length; i++) {
266 const last = merged[merged.length - 1]
267 if (allBusy[i].start <= last.end) {
268 last.end = Math.max(last.end, allBusy[i].end)
269 } else {
270 merged.push(allBusy[i])
271 }
272 }
273
274 return merged
275}
lib/routes/availability.js +27 -4
……@@ -93,11 +93,13 @@ export function registerAvailabilityRoutes (router, server) {
9393 const days = {}
9494 for (let i = 0; i < 7; i++) {
9595 const d = addDays(weekOf, i)
96 days[d] = await calculateSlots(server.db, providerId, d, duration)
96 const externalBusy = await getExternalBusy(server, providerId, d)
97 days[d] = await calculateSlots(server.db, providerId, d, duration, externalBusy)
9798 }
9899 json(res, { days })
99100 } else {
100 const slots = await calculateSlots(server.db, providerId, date, duration)
101 const externalBusy = await getExternalBusy(server, providerId, date)
102 const slots = await calculateSlots(server.db, providerId, date, duration, externalBusy)
101103 json(res, { date, slots })
102104 }
103105 })
……@@ -105,7 +107,7 @@ export function registerAvailabilityRoutes (router, server) {
105107
106108// ---- Slot Calculation ----
107109
108export async function calculateSlots (db, providerId, date, duration) {
110export async function calculateSlots (db, providerId, date, duration, externalBusy = []) {
109111 const availEntry = await db.get(`availability:${providerId}`)
110112 if (!availEntry) return []
111113 const avail = availEntry.value
……@@ -160,7 +162,7 @@ export async function calculateSlots (db, providerId, date, duration) {
160162 // Remove slots that conflict with existing bookings
161163 const bookings = await getBookingsForDate(db, providerId, date)
162164
163 const availableSlots = allSlots.filter(slot => {
165 let availableSlots = allSlots.filter(slot => {
164166 return !bookings.some(b => {
165167 if (b.status === 'cancelled') return false
166168 const bStart = timeToMs(b.startTimeLocal) - bufferMs
……@@ -169,6 +171,13 @@ export async function calculateSlots (db, providerId, date, duration) {
169171 })
170172 })
171173
174 // Filter out slots that conflict with external busy blocks (e.g., Google Calendar)
175 if (externalBusy.length > 0) {
176 availableSlots = availableSlots.filter(slot => {
177 return !externalBusy.some(busy => slot.start < busy.end && slot.end > busy.start)
178 })
179 }
180
172181 return availableSlots
173182}
174183
……@@ -207,3 +216,17 @@ function addDays (dateStr, days) {
207216 d.setUTCDate(d.getUTCDate() + days)
208217 return d.toISOString().split('T')[0]
209218}
219
220/**
221 * Get external busy blocks for a provider + date.
222 * Currently supports Google Calendar. Returns [] if not configured.
223 */
224export async function getExternalBusy (server, providerId, date) {
225 if (!server.gcal) return []
226 const entry = await server.db.get(`gcal:provider:${providerId}`)
227 if (!entry || !entry.value.connected) return []
228 return server.gcal.getBusyBlocksForDate(
229 server.db, providerId, date,
230 entry.value.calendarIds || ['primary']
231 )
232}
lib/routes/bookings.js +7 -5
……@@ -4,7 +4,7 @@
44
55import { json, readJson, notFound } from '../response.js'
66import { requireAuth, optionalAuth } from './auth-helpers.js'
7import { calculateSlots } from './availability.js'
7import { calculateSlots, getExternalBusy } from './availability.js'
88
99export function registerBookingRoutes (router, server) {
1010 // Create booking (authenticated client)
……@@ -27,8 +27,9 @@ export function registerBookingRoutes (router, server) {
2727 // Calculate end time from package duration
2828 const slotEnd = endTime || addMinutesToTime(startTime, pkg.sessionDuration)
2929
30 // Check slot availability
31 const slots = await calculateSlots(server.db, providerId, date, pkg.sessionDuration)
30 // Check slot availability (including Google Calendar busy blocks)
31 const externalBusy = await getExternalBusy(server, providerId, date)
32 const slots = await calculateSlots(server.db, providerId, date, pkg.sessionDuration, externalBusy)
3233 const slotAvailable = slots.some(s => s.startTime === startTime)
3334
3435 if (!slotAvailable) {
……@@ -138,8 +139,9 @@ export function registerBookingRoutes (router, server) {
138139 return
139140 }
140141
141 // Check new slot availability
142 const slots = await calculateSlots(server.db, booking.providerId, date, duration)
142 // Check new slot availability (including Google Calendar busy blocks)
143 const reschExternalBusy = await getExternalBusy(server, booking.providerId, date)
144 const slots = await calculateSlots(server.db, booking.providerId, date, duration, reschExternalBusy)
143145 if (!slots.some(s => s.startTime === startTime)) {
144146 json(res, { error: 'New slot not available' }, 409)
145147 return
package.json +1 -0
……@@ -14,6 +14,7 @@
1414 "@noble/hashes": "^1.3.3",
1515 "b4a": "^1.7.3",
1616 "bare-crypto": "^1.13.0",
17 "bare-fetch": "^2.8.0",
1718 "bare-fs": "^4.5.3",
1819 "bare-http1": "^4.2.3",
1920 "bare-node-runtime": "^1.1.4",
test/gcal.test.js +617 -0
……@@ -0,0 +1,617 @@
1await import('bare-node-runtime/global')
2import test from 'brittle'
3import Corestore from 'corestore'
4import Hyperbee from 'hyperbee'
5import crypto from 'bare-crypto'
6import os from 'bare-os'
7import path from 'bare-path'
8import http from 'bare-http1'
9
10import { GoogleCalendarClient, mergeBusyBlocks } from '../lib/gcal.js'
11import { calculateSlots } from '../lib/routes/availability.js'
12import { Router } from '../lib/router.js'
13import { registerProviderRoutes } from '../lib/routes/providers.js'
14import { registerPackageRoutes } from '../lib/routes/packages.js'
15import { registerAvailabilityRoutes } from '../lib/routes/availability.js'
16import { registerBookingRoutes } from '../lib/routes/bookings.js'
17import { registerDashboardRoutes } from '../lib/routes/dashboard.js'
18import { registerAuthRoutes } from '../lib/routes/auth.js'
19import { registerWebRoutes, registerPublicRoutes } from '../lib/routes/web.js'
20
21// ---- Helpers ----
22
23async function createTestDb () {
24 const tmpDir = path.join(os.tmpdir(), 'hb-gcal-test-' + crypto.randomBytes(8).toString('hex'))
25 const store = new Corestore(tmpDir)
26 const core = store.get({ name: 'registry' })
27 const db = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
28 await db.ready()
29 return { db, store, close: () => store.close() }
30}
31
32function timeToMs (timeStr) {
33 const [h, m] = timeStr.split(':').map(Number)
34 return (h * 60 + m) * 60000
35}
36
37async function seedAvailability (db, providerId) {
38 await db.put(`availability:${providerId}`, {
39 providerId,
40 timezone: 'UTC',
41 weekly: {
42 monday: [{ start: '09:00', end: '17:00' }],
43 tuesday: [{ start: '09:00', end: '17:00' }],
44 wednesday: [{ start: '09:00', end: '17:00' }],
45 thursday: [{ start: '09:00', end: '17:00' }],
46 friday: [{ start: '09:00', end: '17:00' }],
47 saturday: [],
48 sunday: []
49 },
50 bufferMinutes: 15,
51 advanceBooking: { minHours: 0, maxDays: 60 }
52 })
53}
54
55// ---- Unit Tests: mergeBusyBlocks ----
56
57test('mergeBusyBlocks — empty response', (t) => {
58 const result = mergeBusyBlocks({ calendars: {} })
59 t.is(result.length, 0)
60})
61
62test('mergeBusyBlocks — single calendar, no overlap', (t) => {
63 const result = mergeBusyBlocks({
64 calendars: {
65 primary: {
66 busy: [
67 { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' },
68 { start: '2026-04-01T14:00:00Z', end: '2026-04-01T15:00:00Z' }
69 ]
70 }
71 }
72 })
73 t.is(result.length, 2)
74 t.is(result[0].end - result[0].start, 3600000) // 1 hour
75 t.is(result[1].end - result[1].start, 3600000)
76})
77
78test('mergeBusyBlocks — overlapping blocks merged', (t) => {
79 const result = mergeBusyBlocks({
80 calendars: {
81 primary: {
82 busy: [
83 { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:30:00Z' }
84 ]
85 },
86 work: {
87 busy: [
88 { start: '2026-04-01T11:00:00Z', end: '2026-04-01T12:00:00Z' }
89 ]
90 }
91 }
92 })
93 t.is(result.length, 1, 'overlapping blocks should merge into one')
94 const block = result[0]
95 t.is(block.start, new Date('2026-04-01T10:00:00Z').getTime())
96 t.is(block.end, new Date('2026-04-01T12:00:00Z').getTime())
97})
98
99test('mergeBusyBlocks — adjacent blocks stay separate', (t) => {
100 const result = mergeBusyBlocks({
101 calendars: {
102 primary: {
103 busy: [
104 { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' },
105 { start: '2026-04-01T11:01:00Z', end: '2026-04-01T12:00:00Z' }
106 ]
107 }
108 }
109 })
110 t.is(result.length, 2, 'non-overlapping blocks should stay separate')
111})
112
113test('mergeBusyBlocks — multiple calendars merged', (t) => {
114 const result = mergeBusyBlocks({
115 calendars: {
116 cal1: {
117 busy: [
118 { start: '2026-04-01T09:00:00Z', end: '2026-04-01T10:00:00Z' }
119 ]
120 },
121 cal2: {
122 busy: [
123 { start: '2026-04-01T09:30:00Z', end: '2026-04-01T10:30:00Z' }
124 ]
125 },
126 cal3: {
127 busy: [
128 { start: '2026-04-01T14:00:00Z', end: '2026-04-01T15:00:00Z' }
129 ]
130 }
131 }
132 })
133 t.is(result.length, 2, 'cal1+cal2 overlap → merge, cal3 stays separate')
134 t.is(result[0].start, new Date('2026-04-01T09:00:00Z').getTime())
135 t.is(result[0].end, new Date('2026-04-01T10:30:00Z').getTime())
136})
137
138// ---- Unit Tests: GoogleCalendarClient ----
139
140test('gcal client — getBusyBlocksForDate returns busy blocks', async (t) => {
141 const { db, close } = await createTestDb()
142 try {
143 // Seed connected provider
144 await db.put('gcal:provider:prov_1', {
145 connected: true,
146 accessToken: 'fake-token',
147 refreshToken: 'fake-refresh',
148 tokenExpiry: Date.now() + 3600000,
149 calendarIds: ['primary']
150 })
151
152 let fetchCalls = 0
153 const mockHttp = async (url, opts) => {
154 fetchCalls++
155 return {
156 ok: true,
157 json: async () => ({
158 calendars: {
159 primary: {
160 busy: [
161 { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' },
162 { start: '2026-04-01T14:00:00Z', end: '2026-04-01T15:30:00Z' }
163 ]
164 }
165 }
166 })
167 }
168 }
169
170 const gcal = new GoogleCalendarClient({ httpClient: mockHttp, cacheTTL: 60000 })
171 const busy = await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
172
173 t.is(busy.length, 2)
174 t.is(fetchCalls, 1)
175 // First block: 10:00-11:00 = 36000000 - 39600000 ms of day
176 t.is(busy[0].start, timeToMs('10:00'))
177 t.is(busy[0].end, timeToMs('11:00'))
178 // Second block: 14:00-15:30
179 t.is(busy[1].start, timeToMs('14:00'))
180 t.is(busy[1].end, timeToMs('15:30'))
181 } finally {
182 await close()
183 }
184})
185
186test('gcal client — caches results (second call skips fetch)', async (t) => {
187 const { db, close } = await createTestDb()
188 try {
189 await db.put('gcal:provider:prov_1', {
190 connected: true,
191 accessToken: 'fake-token',
192 refreshToken: 'fake-refresh',
193 tokenExpiry: Date.now() + 3600000,
194 calendarIds: ['primary']
195 })
196
197 let fetchCalls = 0
198 const mockHttp = async () => {
199 fetchCalls++
200 return {
201 ok: true,
202 json: async () => ({
203 calendars: { primary: { busy: [{ start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' }] } }
204 })
205 }
206 }
207
208 const gcal = new GoogleCalendarClient({ httpClient: mockHttp, cacheTTL: 60000 })
209
210 await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
211 t.is(fetchCalls, 1, 'first call fetches')
212
213 await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
214 t.is(fetchCalls, 1, 'second call uses cache')
215 } finally {
216 await close()
217 }
218})
219
220test('gcal client — cache expires after TTL', async (t) => {
221 const { db, close } = await createTestDb()
222 try {
223 await db.put('gcal:provider:prov_1', {
224 connected: true,
225 accessToken: 'fake-token',
226 refreshToken: 'fake-refresh',
227 tokenExpiry: Date.now() + 3600000,
228 calendarIds: ['primary']
229 })
230
231 let fetchCalls = 0
232 const mockHttp = async () => {
233 fetchCalls++
234 return {
235 ok: true,
236 json: async () => ({
237 calendars: { primary: { busy: [] } }
238 })
239 }
240 }
241
242 const gcal = new GoogleCalendarClient({ httpClient: mockHttp, cacheTTL: 0 }) // immediate expiry
243
244 await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
245 await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
246 t.is(fetchCalls, 2, 'expired cache causes re-fetch')
247 } finally {
248 await close()
249 }
250})
251
252test('gcal client — graceful fallback on API error', async (t) => {
253 const { db, close } = await createTestDb()
254 try {
255 await db.put('gcal:provider:prov_1', {
256 connected: true,
257 accessToken: 'fake-token',
258 refreshToken: 'fake-refresh',
259 tokenExpiry: Date.now() + 3600000,
260 calendarIds: ['primary']
261 })
262
263 const mockHttp = async () => { throw new Error('Network error') }
264 const gcal = new GoogleCalendarClient({ httpClient: mockHttp })
265
266 const busy = await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
267 t.is(busy.length, 0, 'should return empty array on error')
268 // Should not throw
269 } finally {
270 await close()
271 }
272})
273
274test('gcal client — returns empty when not connected', async (t) => {
275 const { db, close } = await createTestDb()
276 try {
277 const gcal = new GoogleCalendarClient({ httpClient: async () => { throw new Error('should not call') } })
278 const busy = await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
279 t.is(busy.length, 0, 'no gcal entry = empty array')
280 } finally {
281 await close()
282 }
283})
284
285test('gcal client — disconnect removes tokens and cache', async (t) => {
286 const { db, close } = await createTestDb()
287 try {
288 await db.put('gcal:provider:prov_1', { connected: true, accessToken: 'x' })
289 await db.put('gcal:cache:prov_1:2026-04-01', { busy: [], fetchedAt: Date.now() })
290 await db.put('gcal:cache:prov_1:2026-04-02', { busy: [], fetchedAt: Date.now() })
291
292 const gcal = new GoogleCalendarClient({})
293 await gcal.disconnect(db, 'prov_1')
294
295 const auth = await db.get('gcal:provider:prov_1')
296 t.absent(auth, 'auth entry should be deleted')
297
298 const cache1 = await db.get('gcal:cache:prov_1:2026-04-01')
299 t.absent(cache1, 'cache entry should be deleted')
300
301 const cache2 = await db.get('gcal:cache:prov_1:2026-04-02')
302 t.absent(cache2, 'cache entry should be deleted')
303 } finally {
304 await close()
305 }
306})
307
308// ---- Unit Tests: calculateSlots with externalBusy ----
309
310test('calculateSlots — externalBusy blocks slots', async (t) => {
311 const { db, close } = await createTestDb()
312 try {
313 const providerId = 'prov_test'
314 await seedAvailability(db, providerId)
315
316 // Find next Monday
317 const now = new Date()
318 const daysUntilMonday = (8 - now.getDay()) % 7 || 7
319 const monday = new Date(now)
320 monday.setDate(monday.getDate() + daysUntilMonday)
321 const mondayStr = monday.toISOString().split('T')[0]
322
323 // Without external busy
324 const slotsNormal = await calculateSlots(db, providerId, mondayStr, 60)
325 t.ok(slotsNormal.length > 0, 'should have slots without external busy')
326
327 // With external busy blocking 10:00-12:00
328 const externalBusy = [
329 { start: timeToMs('10:00'), end: timeToMs('12:00') }
330 ]
331 const slotsFiltered = await calculateSlots(db, providerId, mondayStr, 60, externalBusy)
332
333 t.ok(slotsFiltered.length < slotsNormal.length, 'should have fewer slots with external busy')
334
335 // Verify specific slots are blocked
336 const blocked1000 = slotsFiltered.find(s => s.startTime === '10:00')
337 t.absent(blocked1000, '10:00 slot should be blocked')
338
339 const blocked1115 = slotsFiltered.find(s => s.startTime === '11:15')
340 t.absent(blocked1115, '11:15 slot should be blocked (overlaps with 10:00-12:00)')
341
342 // Verify slots outside busy range are present
343 const available0900 = slotsFiltered.find(s => s.startTime === '09:00')
344 t.ok(available0900, '09:00 slot should be available')
345
346 // After busy block 10:00-12:00, next slot depends on buffer (15min)
347 // Slots generated: 09:00, 10:15, 11:30, 12:45, 14:00, 15:15, 16:00
348 // 10:15 blocked (overlaps 10:00-12:00), 11:30 blocked
349 // 12:45 is the first slot fully after 12:00
350 const available1245 = slotsFiltered.find(s => s.startTime === '12:45')
351 t.ok(available1245, '12:45 slot should be available (after busy block ends)')
352 } finally {
353 await close()
354 }
355})
356
357test('calculateSlots — empty externalBusy has no effect', async (t) => {
358 const { db, close } = await createTestDb()
359 try {
360 const providerId = 'prov_test'
361 await seedAvailability(db, providerId)
362
363 const now = new Date()
364 const daysUntilMonday = (8 - now.getDay()) % 7 || 7
365 const monday = new Date(now)
366 monday.setDate(monday.getDate() + daysUntilMonday)
367 const mondayStr = monday.toISOString().split('T')[0]
368
369 const slotsDefault = await calculateSlots(db, providerId, mondayStr, 60)
370 const slotsExplicitEmpty = await calculateSlots(db, providerId, mondayStr, 60, [])
371
372 t.is(slotsDefault.length, slotsExplicitEmpty.length, 'empty externalBusy = same result')
373 } finally {
374 await close()
375 }
376})
377
378test('calculateSlots — multiple external busy blocks', async (t) => {
379 const { db, close } = await createTestDb()
380 try {
381 const providerId = 'prov_test'
382 await seedAvailability(db, providerId)
383
384 const now = new Date()
385 const daysUntilMonday = (8 - now.getDay()) % 7 || 7
386 const monday = new Date(now)
387 monday.setDate(monday.getDate() + daysUntilMonday)
388 const mondayStr = monday.toISOString().split('T')[0]
389
390 const externalBusy = [
391 { start: timeToMs('09:00'), end: timeToMs('10:00') },
392 { start: timeToMs('14:00'), end: timeToMs('16:00') }
393 ]
394
395 const slots = await calculateSlots(db, providerId, mondayStr, 60, externalBusy)
396
397 const blocked0900 = slots.find(s => s.startTime === '09:00')
398 t.absent(blocked0900, '09:00 blocked')
399
400 const blocked1415 = slots.find(s => s.startTime === '14:15')
401 t.absent(blocked1415, '14:15 blocked (inside 14:00-16:00)')
402
403 const available1015 = slots.find(s => s.startTime === '10:15')
404 t.ok(available1015, '10:15 available')
405 } finally {
406 await close()
407 }
408})
409
410// ---- Integration Tests: Slot API with Google Calendar ----
411
412function createMockAuthManager () {
413 return {
414 authenticate () {
415 return {
416 userId: 'test-user-1', identityId: 'test-identity-1', method: 'test',
417 username: 'testuser', permissions: { global: ['read', 'write', 'admin'] },
418 user: { userId: 'test-user-1', username: 'testuser' }, verified: true
419 }
420 },
421 authorizeRequest () {
422 return { authorized: true, auth: this.authenticate(), reason: null }
423 },
424 challengeManager: { generate () { return { challengeId: 'x', challenge: 'x', expiresAt: Date.now() + 300000 } } },
425 identityRegistry: { setUsername () {} }
426 }
427}
428
429async function createTestServerWithGcal (mockHttpClient) {
430 const tmpDir = path.join(os.tmpdir(), 'hb-gcal-int-' + crypto.randomBytes(8).toString('hex'))
431 const store = new Corestore(tmpDir)
432 const core = store.get({ name: 'registry' })
433 const db = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
434 await db.ready()
435
436 const gcal = mockHttpClient
437 ? new GoogleCalendarClient({ httpClient: mockHttpClient, cacheTTL: 60000, clientId: 'test', clientSecret: 'test' })
438 : null
439
440 const server = {
441 db, store, pathPrefix: '', gcal,
442 appDir: new URL('..', import.meta.url).pathname.replace(/\/$/, ''),
443 authManager: createMockAuthManager(),
444 generateId (prefix) { return prefix + '_' + crypto.randomBytes(12).toString('hex') }
445 }
446
447 const router = new Router(server)
448 registerWebRoutes(router, server)
449 registerAuthRoutes(router, server)
450 registerProviderRoutes(router, server)
451 registerPackageRoutes(router, server)
452 registerAvailabilityRoutes(router, server)
453 registerBookingRoutes(router, server)
454 registerDashboardRoutes(router, server)
455 registerPublicRoutes(router, server)
456
457 const httpServer = http.createServer(async (req, res) => {
458 res.setHeader('Access-Control-Allow-Origin', '*')
459 if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }
460 const matched = await router.dispatch(req, res)
461 if (!matched) { res.statusCode = 404; res.end('{"error":"Not found"}') }
462 })
463
464 await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve))
465 const port = httpServer.address().port
466
467 function httpRequest (method, reqPath, body) {
468 return new Promise((resolve, reject) => {
469 const opts = { hostname: '127.0.0.1', port, path: reqPath, method, headers: {} }
470 if (body) {
471 const bodyStr = JSON.stringify(body)
472 opts.headers['Content-Type'] = 'application/json'
473 opts.headers['Content-Length'] = Buffer.byteLength(bodyStr)
474 }
475 const req = http.request(opts, (res) => {
476 let data = ''
477 res.on('data', chunk => { data += chunk })
478 res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data, json () { return JSON.parse(data) } }))
479 })
480 req.on('error', reject)
481 if (body) req.write(JSON.stringify(body))
482 req.end()
483 })
484 }
485
486 return {
487 server, db,
488 async close () { httpServer.close(); await store.close() },
489 async json (method, path, body) {
490 const res = await httpRequest(method, path, body)
491 return { status: res.status, data: res.json() }
492 }
493 }
494}
495
496async function seedTestProvider (db) {
497 const provider = {
498 id: 'prov_gcal_test', slug: 'gcal-coach', displayName: 'GCal Coach',
499 bio: '', timezone: 'UTC', specialties: [], socialLinks: {}, photo: null,
500 accentColor: '#c4a265', active: true, userId: 'test-user-1', createdAt: Date.now()
501 }
502 await db.put(`provider:${provider.id}`, provider)
503 await db.put('provider-by-slug:gcal-coach', provider.id)
504 await seedAvailability(db, provider.id)
505 return provider
506}
507
508test('integration — slots API excludes Google Calendar busy times', async (t) => {
509 const mockHttp = async (url) => {
510 if (url.includes('freeBusy')) {
511 return {
512 ok: true,
513 json: async () => ({
514 calendars: {
515 primary: {
516 busy: [
517 { start: '2026-04-06T10:00:00Z', end: '2026-04-06T12:00:00Z' }
518 ]
519 }
520 }
521 })
522 }
523 }
524 return { ok: true, json: async () => ({}) }
525 }
526
527 const s = await createTestServerWithGcal(mockHttp)
528 try {
529 const provider = await seedTestProvider(s.db)
530
531 // Connect Google Calendar for this provider
532 await s.db.put(`gcal:provider:${provider.id}`, {
533 connected: true,
534 accessToken: 'fake-token',
535 refreshToken: 'fake-refresh',
536 tokenExpiry: Date.now() + 3600000,
537 calendarIds: ['primary']
538 })
539
540 // Get slots for Apr 6 (Monday)
541 const result = await s.json('GET', '/api/providers/gcal-coach/slots?date=2026-04-06&duration=60')
542 t.is(result.status, 200)
543
544 const slots = result.data.slots
545 const blocked1000 = slots.find(s => s.startTime === '10:00')
546 t.absent(blocked1000, '10:00 should be blocked by Google Calendar')
547
548 const blocked1115 = slots.find(s => s.startTime === '11:15')
549 t.absent(blocked1115, '11:15 should be blocked (overlaps with 10:00-12:00)')
550
551 const available0900 = slots.find(s => s.startTime === '09:00')
552 t.ok(available0900, '09:00 should be available')
553
554 const available1245 = slots.find(s => s.startTime === '12:45')
555 t.ok(available1245, '12:45 should be available (after busy block ends)')
556 } finally {
557 await s.close()
558 }
559})
560
561test('integration — slots API works without gcal configured', async (t) => {
562 const s = await createTestServerWithGcal(null) // no gcal
563 try {
564 await seedTestProvider(s.db)
565
566 const result = await s.json('GET', '/api/providers/gcal-coach/slots?date=2026-04-06&duration=60')
567 t.is(result.status, 200)
568 t.ok(result.data.slots.length > 0, 'slots should work without gcal')
569 } finally {
570 await s.close()
571 }
572})
573
574test('integration — booking rejects slot blocked by Google Calendar', async (t) => {
575 const mockHttp = async (url) => {
576 if (url.includes('freeBusy')) {
577 return {
578 ok: true,
579 json: async () => ({
580 calendars: {
581 primary: {
582 busy: [{ start: '2026-04-06T10:00:00Z', end: '2026-04-06T12:00:00Z' }]
583 }
584 }
585 })
586 }
587 }
588 return { ok: true, json: async () => ({}) }
589 }
590
591 const s = await createTestServerWithGcal(mockHttp)
592 try {
593 const provider = await seedTestProvider(s.db)
594
595 // Add a discovery package
596 const pkg = {
597 id: 'pkg_gcal_disc', providerId: provider.id, name: 'Discovery', type: 'discovery',
598 sessionDuration: 60, sessionCount: 1, price: 0, active: true, createdAt: Date.now()
599 }
600 await s.db.put(`package:${provider.id}:${pkg.id}`, pkg)
601 await s.db.put(`package-detail:${pkg.id}`, pkg)
602
603 // Connect Google Calendar
604 await s.db.put(`gcal:provider:${provider.id}`, {
605 connected: true, accessToken: 'fake-token', refreshToken: 'fake-refresh',
606 tokenExpiry: Date.now() + 3600000, calendarIds: ['primary']
607 })
608
609 // Try to book at 10:00 (blocked by Google)
610 const booking = await s.json('POST', '/api/bookings', {
611 providerId: provider.id, packageId: pkg.id, date: '2026-04-06', startTime: '10:00'
612 })
613 t.is(booking.status, 409, 'should reject booking during Google Calendar busy time')
614 } finally {
615 await s.close()
616 }
617})