OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / test / gcal.test.js

618 lines · javascript ref:
await import('bare-node-runtime/global')
import test from 'brittle'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import crypto from 'bare-crypto'
import os from 'bare-os'
import path from 'bare-path'
import http from 'bare-http1'

import { GoogleCalendarClient, mergeBusyBlocks } from '../lib/gcal.js'
import { calculateSlots } from '../lib/routes/availability.js'
import { Router } from '../lib/router.js'
import { registerProviderRoutes } from '../lib/routes/providers.js'
import { registerPackageRoutes } from '../lib/routes/packages.js'
import { registerAvailabilityRoutes } from '../lib/routes/availability.js'
import { registerBookingRoutes } from '../lib/routes/bookings.js'
import { registerDashboardRoutes } from '../lib/routes/dashboard.js'
import { registerAuthRoutes } from '../lib/routes/auth.js'
import { registerWebRoutes, registerPublicRoutes } from '../lib/routes/web.js'

// ---- Helpers ----

async function createTestDb () {
  const tmpDir = path.join(os.tmpdir(), 'hb-gcal-test-' + crypto.randomBytes(8).toString('hex'))
  const store = new Corestore(tmpDir)
  const core = store.get({ name: 'registry' })
  const db = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await db.ready()
  return { db, store, close: () => store.close() }
}

function timeToMs (timeStr) {
  const [h, m] = timeStr.split(':').map(Number)
  return (h * 60 + m) * 60000
}

async function seedAvailability (db, providerId) {
  await db.put(`availability:${providerId}`, {
    providerId,
    timezone: 'UTC',
    weekly: {
      monday: [{ start: '09:00', end: '17:00' }],
      tuesday: [{ start: '09:00', end: '17:00' }],
      wednesday: [{ start: '09:00', end: '17:00' }],
      thursday: [{ start: '09:00', end: '17:00' }],
      friday: [{ start: '09:00', end: '17:00' }],
      saturday: [],
      sunday: []
    },
    bufferMinutes: 15,
    advanceBooking: { minHours: 0, maxDays: 60 }
  })
}

// ---- Unit Tests: mergeBusyBlocks ----

test('mergeBusyBlocks — empty response', (t) => {
  const result = mergeBusyBlocks({ calendars: {} })
  t.is(result.length, 0)
})

test('mergeBusyBlocks — single calendar, no overlap', (t) => {
  const result = mergeBusyBlocks({
    calendars: {
      primary: {
        busy: [
          { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' },
          { start: '2026-04-01T14:00:00Z', end: '2026-04-01T15:00:00Z' }
        ]
      }
    }
  })
  t.is(result.length, 2)
  t.is(result[0].end - result[0].start, 3600000) // 1 hour
  t.is(result[1].end - result[1].start, 3600000)
})

test('mergeBusyBlocks — overlapping blocks merged', (t) => {
  const result = mergeBusyBlocks({
    calendars: {
      primary: {
        busy: [
          { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:30:00Z' }
        ]
      },
      work: {
        busy: [
          { start: '2026-04-01T11:00:00Z', end: '2026-04-01T12:00:00Z' }
        ]
      }
    }
  })
  t.is(result.length, 1, 'overlapping blocks should merge into one')
  const block = result[0]
  t.is(block.start, new Date('2026-04-01T10:00:00Z').getTime())
  t.is(block.end, new Date('2026-04-01T12:00:00Z').getTime())
})

test('mergeBusyBlocks — adjacent blocks stay separate', (t) => {
  const result = mergeBusyBlocks({
    calendars: {
      primary: {
        busy: [
          { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' },
          { start: '2026-04-01T11:01:00Z', end: '2026-04-01T12:00:00Z' }
        ]
      }
    }
  })
  t.is(result.length, 2, 'non-overlapping blocks should stay separate')
})

test('mergeBusyBlocks — multiple calendars merged', (t) => {
  const result = mergeBusyBlocks({
    calendars: {
      cal1: {
        busy: [
          { start: '2026-04-01T09:00:00Z', end: '2026-04-01T10:00:00Z' }
        ]
      },
      cal2: {
        busy: [
          { start: '2026-04-01T09:30:00Z', end: '2026-04-01T10:30:00Z' }
        ]
      },
      cal3: {
        busy: [
          { start: '2026-04-01T14:00:00Z', end: '2026-04-01T15:00:00Z' }
        ]
      }
    }
  })
  t.is(result.length, 2, 'cal1+cal2 overlap → merge, cal3 stays separate')
  t.is(result[0].start, new Date('2026-04-01T09:00:00Z').getTime())
  t.is(result[0].end, new Date('2026-04-01T10:30:00Z').getTime())
})

// ---- Unit Tests: GoogleCalendarClient ----

test('gcal client — getBusyBlocksForDate returns busy blocks', async (t) => {
  const { db, close } = await createTestDb()
  try {
    // Seed connected provider
    await db.put('gcal:provider:prov_1', {
      connected: true,
      accessToken: 'fake-token',
      refreshToken: 'fake-refresh',
      tokenExpiry: Date.now() + 3600000,
      calendarIds: ['primary']
    })

    let fetchCalls = 0
    const mockHttp = async (url, opts) => {
      fetchCalls++
      return {
        ok: true,
        json: async () => ({
          calendars: {
            primary: {
              busy: [
                { start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' },
                { start: '2026-04-01T14:00:00Z', end: '2026-04-01T15:30:00Z' }
              ]
            }
          }
        })
      }
    }

    const gcal = new GoogleCalendarClient({ httpClient: mockHttp, cacheTTL: 60000 })
    const busy = await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])

    t.is(busy.length, 2)
    t.is(fetchCalls, 1)
    // First block: 10:00-11:00 = 36000000 - 39600000 ms of day
    t.is(busy[0].start, timeToMs('10:00'))
    t.is(busy[0].end, timeToMs('11:00'))
    // Second block: 14:00-15:30
    t.is(busy[1].start, timeToMs('14:00'))
    t.is(busy[1].end, timeToMs('15:30'))
  } finally {
    await close()
  }
})

test('gcal client — caches results (second call skips fetch)', async (t) => {
  const { db, close } = await createTestDb()
  try {
    await db.put('gcal:provider:prov_1', {
      connected: true,
      accessToken: 'fake-token',
      refreshToken: 'fake-refresh',
      tokenExpiry: Date.now() + 3600000,
      calendarIds: ['primary']
    })

    let fetchCalls = 0
    const mockHttp = async () => {
      fetchCalls++
      return {
        ok: true,
        json: async () => ({
          calendars: { primary: { busy: [{ start: '2026-04-01T10:00:00Z', end: '2026-04-01T11:00:00Z' }] } }
        })
      }
    }

    const gcal = new GoogleCalendarClient({ httpClient: mockHttp, cacheTTL: 60000 })

    await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
    t.is(fetchCalls, 1, 'first call fetches')

    await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
    t.is(fetchCalls, 1, 'second call uses cache')
  } finally {
    await close()
  }
})

test('gcal client — cache expires after TTL', async (t) => {
  const { db, close } = await createTestDb()
  try {
    await db.put('gcal:provider:prov_1', {
      connected: true,
      accessToken: 'fake-token',
      refreshToken: 'fake-refresh',
      tokenExpiry: Date.now() + 3600000,
      calendarIds: ['primary']
    })

    let fetchCalls = 0
    const mockHttp = async () => {
      fetchCalls++
      return {
        ok: true,
        json: async () => ({
          calendars: { primary: { busy: [] } }
        })
      }
    }

    const gcal = new GoogleCalendarClient({ httpClient: mockHttp, cacheTTL: 0 }) // immediate expiry

    await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
    await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
    t.is(fetchCalls, 2, 'expired cache causes re-fetch')
  } finally {
    await close()
  }
})

test('gcal client — graceful fallback on API error', async (t) => {
  const { db, close } = await createTestDb()
  try {
    await db.put('gcal:provider:prov_1', {
      connected: true,
      accessToken: 'fake-token',
      refreshToken: 'fake-refresh',
      tokenExpiry: Date.now() + 3600000,
      calendarIds: ['primary']
    })

    const mockHttp = async () => { throw new Error('Network error') }
    const gcal = new GoogleCalendarClient({ httpClient: mockHttp })

    const busy = await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
    t.is(busy.length, 0, 'should return empty array on error')
    // Should not throw
  } finally {
    await close()
  }
})

test('gcal client — returns empty when not connected', async (t) => {
  const { db, close } = await createTestDb()
  try {
    const gcal = new GoogleCalendarClient({ httpClient: async () => { throw new Error('should not call') } })
    const busy = await gcal.getBusyBlocksForDate(db, 'prov_1', '2026-04-01', ['primary'])
    t.is(busy.length, 0, 'no gcal entry = empty array')
  } finally {
    await close()
  }
})

test('gcal client — disconnect removes tokens and cache', async (t) => {
  const { db, close } = await createTestDb()
  try {
    await db.put('gcal:provider:prov_1', { connected: true, accessToken: 'x' })
    await db.put('gcal:cache:prov_1:2026-04-01', { busy: [], fetchedAt: Date.now() })
    await db.put('gcal:cache:prov_1:2026-04-02', { busy: [], fetchedAt: Date.now() })

    const gcal = new GoogleCalendarClient({})
    await gcal.disconnect(db, 'prov_1')

    const auth = await db.get('gcal:provider:prov_1')
    t.absent(auth, 'auth entry should be deleted')

    const cache1 = await db.get('gcal:cache:prov_1:2026-04-01')
    t.absent(cache1, 'cache entry should be deleted')

    const cache2 = await db.get('gcal:cache:prov_1:2026-04-02')
    t.absent(cache2, 'cache entry should be deleted')
  } finally {
    await close()
  }
})

// ---- Unit Tests: calculateSlots with externalBusy ----

test('calculateSlots — externalBusy blocks slots', async (t) => {
  const { db, close } = await createTestDb()
  try {
    const providerId = 'prov_test'
    await seedAvailability(db, providerId)

    // Find next Monday
    const now = new Date()
    const daysUntilMonday = (8 - now.getDay()) % 7 || 7
    const monday = new Date(now)
    monday.setDate(monday.getDate() + daysUntilMonday)
    const mondayStr = monday.toISOString().split('T')[0]

    // Without external busy
    const slotsNormal = await calculateSlots(db, providerId, mondayStr, 60)
    t.ok(slotsNormal.length > 0, 'should have slots without external busy')

    // With external busy blocking 10:00-12:00
    const externalBusy = [
      { start: timeToMs('10:00'), end: timeToMs('12:00') }
    ]
    const slotsFiltered = await calculateSlots(db, providerId, mondayStr, 60, externalBusy)

    t.ok(slotsFiltered.length < slotsNormal.length, 'should have fewer slots with external busy')

    // Verify specific slots are blocked
    const blocked1000 = slotsFiltered.find(s => s.startTime === '10:00')
    t.absent(blocked1000, '10:00 slot should be blocked')

    const blocked1115 = slotsFiltered.find(s => s.startTime === '11:15')
    t.absent(blocked1115, '11:15 slot should be blocked (overlaps with 10:00-12:00)')

    // Verify slots outside busy range are present
    const available0900 = slotsFiltered.find(s => s.startTime === '09:00')
    t.ok(available0900, '09:00 slot should be available')

    // After busy block 10:00-12:00, next slot depends on buffer (15min)
    // Slots generated: 09:00, 10:15, 11:30, 12:45, 14:00, 15:15, 16:00
    // 10:15 blocked (overlaps 10:00-12:00), 11:30 blocked
    // 12:45 is the first slot fully after 12:00
    const available1245 = slotsFiltered.find(s => s.startTime === '12:45')
    t.ok(available1245, '12:45 slot should be available (after busy block ends)')
  } finally {
    await close()
  }
})

test('calculateSlots — empty externalBusy has no effect', async (t) => {
  const { db, close } = await createTestDb()
  try {
    const providerId = 'prov_test'
    await seedAvailability(db, providerId)

    const now = new Date()
    const daysUntilMonday = (8 - now.getDay()) % 7 || 7
    const monday = new Date(now)
    monday.setDate(monday.getDate() + daysUntilMonday)
    const mondayStr = monday.toISOString().split('T')[0]

    const slotsDefault = await calculateSlots(db, providerId, mondayStr, 60)
    const slotsExplicitEmpty = await calculateSlots(db, providerId, mondayStr, 60, [])

    t.is(slotsDefault.length, slotsExplicitEmpty.length, 'empty externalBusy = same result')
  } finally {
    await close()
  }
})

test('calculateSlots — multiple external busy blocks', async (t) => {
  const { db, close } = await createTestDb()
  try {
    const providerId = 'prov_test'
    await seedAvailability(db, providerId)

    const now = new Date()
    const daysUntilMonday = (8 - now.getDay()) % 7 || 7
    const monday = new Date(now)
    monday.setDate(monday.getDate() + daysUntilMonday)
    const mondayStr = monday.toISOString().split('T')[0]

    const externalBusy = [
      { start: timeToMs('09:00'), end: timeToMs('10:00') },
      { start: timeToMs('14:00'), end: timeToMs('16:00') }
    ]

    const slots = await calculateSlots(db, providerId, mondayStr, 60, externalBusy)

    const blocked0900 = slots.find(s => s.startTime === '09:00')
    t.absent(blocked0900, '09:00 blocked')

    const blocked1415 = slots.find(s => s.startTime === '14:15')
    t.absent(blocked1415, '14:15 blocked (inside 14:00-16:00)')

    const available1015 = slots.find(s => s.startTime === '10:15')
    t.ok(available1015, '10:15 available')
  } finally {
    await close()
  }
})

// ---- Integration Tests: Slot API with Google Calendar ----

function createMockAuthManager () {
  return {
    authenticate () {
      return {
        userId: 'test-user-1', identityId: 'test-identity-1', method: 'test',
        username: 'testuser', permissions: { global: ['read', 'write', 'admin'] },
        user: { userId: 'test-user-1', username: 'testuser' }, verified: true
      }
    },
    authorizeRequest () {
      return { authorized: true, auth: this.authenticate(), reason: null }
    },
    challengeManager: { generate () { return { challengeId: 'x', challenge: 'x', expiresAt: Date.now() + 300000 } } },
    identityRegistry: { setUsername () {} }
  }
}

async function createTestServerWithGcal (mockHttpClient) {
  const tmpDir = path.join(os.tmpdir(), 'hb-gcal-int-' + crypto.randomBytes(8).toString('hex'))
  const store = new Corestore(tmpDir)
  const core = store.get({ name: 'registry' })
  const db = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await db.ready()

  const gcal = mockHttpClient
    ? new GoogleCalendarClient({ httpClient: mockHttpClient, cacheTTL: 60000, clientId: 'test', clientSecret: 'test' })
    : null

  const server = {
    db, store, pathPrefix: '', gcal,
    appDir: new URL('..', import.meta.url).pathname.replace(/\/$/, ''),
    authManager: createMockAuthManager(),
    generateId (prefix) { return prefix + '_' + crypto.randomBytes(12).toString('hex') }
  }

  const router = new Router(server)
  registerWebRoutes(router, server)
  registerAuthRoutes(router, server)
  registerProviderRoutes(router, server)
  registerPackageRoutes(router, server)
  registerAvailabilityRoutes(router, server)
  registerBookingRoutes(router, server)
  registerDashboardRoutes(router, server)
  registerPublicRoutes(router, server)

  const httpServer = http.createServer(async (req, res) => {
    res.setHeader('Access-Control-Allow-Origin', '*')
    if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }
    const matched = await router.dispatch(req, res)
    if (!matched) { res.statusCode = 404; res.end('{"error":"Not found"}') }
  })

  await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve))
  const port = httpServer.address().port

  function httpRequest (method, reqPath, body) {
    return new Promise((resolve, reject) => {
      const opts = { hostname: '127.0.0.1', port, path: reqPath, method, headers: {} }
      if (body) {
        const bodyStr = JSON.stringify(body)
        opts.headers['Content-Type'] = 'application/json'
        opts.headers['Content-Length'] = Buffer.byteLength(bodyStr)
      }
      const req = http.request(opts, (res) => {
        let data = ''
        res.on('data', chunk => { data += chunk })
        res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data, json () { return JSON.parse(data) } }))
      })
      req.on('error', reject)
      if (body) req.write(JSON.stringify(body))
      req.end()
    })
  }

  return {
    server, db,
    async close () { httpServer.close(); await store.close() },
    async json (method, path, body) {
      const res = await httpRequest(method, path, body)
      return { status: res.status, data: res.json() }
    }
  }
}

async function seedTestProvider (db) {
  const provider = {
    id: 'prov_gcal_test', slug: 'gcal-coach', displayName: 'GCal Coach',
    bio: '', timezone: 'UTC', specialties: [], socialLinks: {}, photo: null,
    accentColor: '#c4a265', active: true, userId: 'test-user-1', createdAt: Date.now()
  }
  await db.put(`provider:${provider.id}`, provider)
  await db.put('provider-by-slug:gcal-coach', provider.id)
  await seedAvailability(db, provider.id)
  return provider
}

test('integration — slots API excludes Google Calendar busy times', async (t) => {
  const mockHttp = async (url) => {
    if (url.includes('freeBusy')) {
      return {
        ok: true,
        json: async () => ({
          calendars: {
            primary: {
              busy: [
                { start: '2026-04-06T10:00:00Z', end: '2026-04-06T12:00:00Z' }
              ]
            }
          }
        })
      }
    }
    return { ok: true, json: async () => ({}) }
  }

  const s = await createTestServerWithGcal(mockHttp)
  try {
    const provider = await seedTestProvider(s.db)

    // Connect Google Calendar for this provider
    await s.db.put(`gcal:provider:${provider.id}`, {
      connected: true,
      accessToken: 'fake-token',
      refreshToken: 'fake-refresh',
      tokenExpiry: Date.now() + 3600000,
      calendarIds: ['primary']
    })

    // Get slots for Apr 6 (Monday)
    const result = await s.json('GET', '/api/providers/gcal-coach/slots?date=2026-04-06&duration=60')
    t.is(result.status, 200)

    const slots = result.data.slots
    const blocked1000 = slots.find(s => s.startTime === '10:00')
    t.absent(blocked1000, '10:00 should be blocked by Google Calendar')

    const blocked1115 = slots.find(s => s.startTime === '11:15')
    t.absent(blocked1115, '11:15 should be blocked (overlaps with 10:00-12:00)')

    const available0900 = slots.find(s => s.startTime === '09:00')
    t.ok(available0900, '09:00 should be available')

    const available1245 = slots.find(s => s.startTime === '12:45')
    t.ok(available1245, '12:45 should be available (after busy block ends)')
  } finally {
    await s.close()
  }
})

test('integration — slots API works without gcal configured', async (t) => {
  const s = await createTestServerWithGcal(null) // no gcal
  try {
    await seedTestProvider(s.db)

    const result = await s.json('GET', '/api/providers/gcal-coach/slots?date=2026-04-06&duration=60')
    t.is(result.status, 200)
    t.ok(result.data.slots.length > 0, 'slots should work without gcal')
  } finally {
    await s.close()
  }
})

test('integration — booking rejects slot blocked by Google Calendar', async (t) => {
  const mockHttp = async (url) => {
    if (url.includes('freeBusy')) {
      return {
        ok: true,
        json: async () => ({
          calendars: {
            primary: {
              busy: [{ start: '2026-04-06T10:00:00Z', end: '2026-04-06T12:00:00Z' }]
            }
          }
        })
      }
    }
    return { ok: true, json: async () => ({}) }
  }

  const s = await createTestServerWithGcal(mockHttp)
  try {
    const provider = await seedTestProvider(s.db)

    // Add a discovery package
    const pkg = {
      id: 'pkg_gcal_disc', providerId: provider.id, name: 'Discovery', type: 'discovery',
      sessionDuration: 60, sessionCount: 1, price: 0, active: true, createdAt: Date.now()
    }
    await s.db.put(`package:${provider.id}:${pkg.id}`, pkg)
    await s.db.put(`package-detail:${pkg.id}`, pkg)

    // Connect Google Calendar
    await s.db.put(`gcal:provider:${provider.id}`, {
      connected: true, accessToken: 'fake-token', refreshToken: 'fake-refresh',
      tokenExpiry: Date.now() + 3600000, calendarIds: ['primary']
    })

    // Try to book at 10:00 (blocked by Google)
    const booking = await s.json('POST', '/api/bookings', {
      providerId: provider.id, packageId: pkg.id, date: '2026-04-06', startTime: '10:00'
    })
    t.is(booking.status, 409, 'should reject booking during Google Calendar busy time')
  } finally {
    await s.close()
  }
})