| … | … | @@ -0,0 +1,617 @@ |
| 1 | await import('bare-node-runtime/global') |
| 2 | import test from 'brittle' |
| 3 | import Corestore from 'corestore' |
| 4 | import Hyperbee from 'hyperbee' |
| 5 | import crypto from 'bare-crypto' |
| 6 | import os from 'bare-os' |
| 7 | import path from 'bare-path' |
| 8 | import http from 'bare-http1' |
| 9 | |
| 10 | import { GoogleCalendarClient, mergeBusyBlocks } from '../lib/gcal.js' |
| 11 | import { calculateSlots } from '../lib/routes/availability.js' |
| 12 | import { Router } from '../lib/router.js' |
| 13 | import { registerProviderRoutes } from '../lib/routes/providers.js' |
| 14 | import { registerPackageRoutes } from '../lib/routes/packages.js' |
| 15 | import { registerAvailabilityRoutes } from '../lib/routes/availability.js' |
| 16 | import { registerBookingRoutes } from '../lib/routes/bookings.js' |
| 17 | import { registerDashboardRoutes } from '../lib/routes/dashboard.js' |
| 18 | import { registerAuthRoutes } from '../lib/routes/auth.js' |
| 19 | import { registerWebRoutes, registerPublicRoutes } from '../lib/routes/web.js' |
| 20 | |
| 21 | // ---- Helpers ---- |
| 22 | |
| 23 | async 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 | |
| 32 | function timeToMs (timeStr) { |
| 33 | const [h, m] = timeStr.split(':').map(Number) |
| 34 | return (h * 60 + m) * 60000 |
| 35 | } |
| 36 | |
| 37 | async 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 | |
| 57 | test('mergeBusyBlocks — empty response', (t) => { |
| 58 | const result = mergeBusyBlocks({ calendars: {} }) |
| 59 | t.is(result.length, 0) |
| 60 | }) |
| 61 | |
| 62 | test('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 | |
| 78 | test('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 | |
| 99 | test('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 | |
| 113 | test('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 | |
| 140 | test('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 | |
| 186 | test('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 | |
| 220 | test('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 | |
| 252 | test('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 | |
| 274 | test('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 | |
| 285 | test('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 | |
| 310 | test('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 | |
| 357 | test('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 | |
| 378 | test('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 | |
| 412 | function 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 | |
| 429 | async 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 | |
| 496 | async 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 | |
| 508 | test('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 | |
| 561 | test('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 | |
| 574 | test('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 | }) |