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()
}
})