await import('bare-node-runtime/global')
import test from 'brittle'
import Corestore from 'corestore'
import RAM from 'random-access-memory'
import os from 'bare-os'
import path from 'bare-path'
import fs from 'bare-fs'
import Hyperbee from 'hyperbee'
import crypto from 'bare-crypto'
import http from 'bare-http1'
import { Router } from '../lib/router.js'
import { registerWebRoutes, registerPublicRoutes } from '../lib/routes/web.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'
// ---- Test Server Factory ----
async function createTestServer () {
const tmpDir = path.join(os.tmpdir(), 'hb-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()
const server = {
db,
store,
pathPrefix: '',
appDir: new URL('..', import.meta.url).pathname.replace(/\/$/, ''),
authManager: createMockAuthManager(db),
generateId (prefix) {
return prefix + '_' + crypto.randomBytes(12).toString('hex')
}
}
const router = new Router(server)
server.router = router
registerWebRoutes(router, server)
registerAuthRoutes(router, server)
registerProviderRoutes(router, server)
registerPackageRoutes(router, server)
registerAvailabilityRoutes(router, server)
registerBookingRoutes(router, server)
registerDashboardRoutes(router, server)
registerPublicRoutes(router, server)
// Start HTTP server on random port
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
const base = `http://127.0.0.1:${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, base, httpServer,
async close () {
httpServer.close()
await store.close()
},
async fetch (reqPath) {
return httpRequest('GET', reqPath)
},
async json (method, reqPath, body) {
const res = await httpRequest(method, reqPath, body)
return { status: res.status, data: res.json(), headers: res.headers }
}
}
}
function createMockAuthManager (db) {
// Minimal mock that auto-authenticates all requests as admin
return {
authenticate (req) {
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 (req, action, resource) {
const auth = this.authenticate(req)
return { authorized: true, auth, reason: null }
},
challengeManager: {
generate () {
return { challengeId: 'test-challenge', challenge: 'test', expiresAt: Date.now() + 300000 }
}
},
identityRegistry: {
setUsername () {}
}
}
}
// ---- Seed Helpers ----
async function seedProvider (t) {
const providerId = 'prov_test1'
const provider = {
id: providerId,
slug: 'test-coach',
displayName: 'Test Coach',
bio: 'A test coach.',
timezone: 'America/New_York',
specialties: ['Wellness'],
socialLinks: {},
photo: null,
accentColor: '#c4a265',
active: true,
userId: 'test-user-1',
createdAt: Date.now()
}
const batch = t.db.batch()
await batch.put(`provider:${providerId}`, provider)
await batch.put(`provider-by-slug:test-coach`, providerId)
await batch.flush()
return provider
}
async function seedPackage (t, providerId) {
const pkg = {
id: 'pkg_test1',
providerId,
name: 'Test Session',
type: 'single',
description: 'A test session.',
sessionDuration: 60,
sessionCount: 1,
price: 10000,
currency: 'USD',
active: true,
createdAt: Date.now()
}
const batch = t.db.batch()
await batch.put(`package:${providerId}:${pkg.id}`, pkg)
await batch.put(`package-detail:${pkg.id}`, pkg)
await batch.flush()
return pkg
}
async function seedDiscovery (t, providerId) {
const pkg = {
id: 'pkg_discovery',
providerId,
name: 'Free Discovery Call',
type: 'discovery',
description: 'Free intro.',
sessionDuration: 15,
sessionCount: 1,
price: 0,
currency: 'USD',
active: true,
createdAt: Date.now()
}
const batch = t.db.batch()
await batch.put(`package:${providerId}:${pkg.id}`, pkg)
await batch.put(`package-detail:${pkg.id}`, pkg)
await batch.flush()
return pkg
}
async function seedAvailability (t, providerId) {
const avail = {
providerId,
timezone: 'America/New_York',
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 },
cancellation: { windowHours: 24, refundPolicy: 'full' },
reschedule: { windowHours: 24, maxPerBooking: 2 },
noShow: { penalty: 'forfeit' }
}
await t.db.put(`availability:${providerId}`, avail)
return avail
}
// ---- Tests ----
test('health check', async (t) => {
const s = await createTestServer()
try {
const { status, data } = await s.json('GET', '/api/health')
t.is(status, 200)
t.is(data.status, 'ok')
t.is(data.version, '0.1.0')
} finally {
await s.close()
}
})
test('static files serve with correct MIME types', async (t) => {
const s = await createTestServer()
try {
const css = await s.fetch('/web/css/app.css')
t.is(css.status, 200)
t.ok(css.headers['content-type'].includes('text/css'))
const js = await s.fetch('/web/js/app.js')
t.is(js.status, 200)
t.ok(js.headers['content-type'].includes('application/javascript'))
const noble = await s.fetch('/web/vendor/noble-ed25519.js')
t.is(noble.status, 200)
t.ok(noble.headers['content-type'].includes('application/javascript'))
const nobleSha = await s.fetch('/web/vendor/noble-sha512.js')
t.is(nobleSha.status, 200)
const nobleCrypto = await s.fetch('/web/vendor/noble-crypto.js')
t.is(nobleCrypto.status, 200)
const missing = await s.fetch('/web/nope.js')
t.is(missing.status, 404)
} finally {
await s.close()
}
})
test('directory traversal blocked', async (t) => {
const s = await createTestServer()
try {
const res = await s.fetch('/web/../package.json')
t.is(res.status, 404)
} finally {
await s.close()
}
})
test('provider CRUD', async (t) => {
const s = await createTestServer()
try {
// Create
const { status, data } = await s.json('POST', '/api/providers', {
displayName: 'Jane Doe',
slug: 'jane-doe',
bio: 'A coach.',
timezone: 'America/Chicago'
})
t.is(status, 201)
t.is(data.displayName, 'Jane Doe')
t.is(data.slug, 'jane-doe')
t.ok(data.id.startsWith('prov_'))
// List
const list = await s.json('GET', '/api/providers')
t.is(list.status, 200)
t.is(list.data.length, 1)
// Get by slug
const get = await s.json('GET', '/api/providers/jane-doe')
t.is(get.status, 200)
t.is(get.data.displayName, 'Jane Doe')
// Update
const upd = await s.json('PUT', '/api/providers/jane-doe', {
bio: 'Updated bio.'
})
t.is(upd.status, 200)
t.is(upd.data.bio, 'Updated bio.')
// Duplicate slug
const dup = await s.json('POST', '/api/providers', {
displayName: 'Another',
slug: 'jane-doe'
})
t.is(dup.status, 409)
// Not found
const nf = await s.json('GET', '/api/providers/nope')
t.is(nf.status, 404)
} finally {
await s.close()
}
})
test('package CRUD', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
// Create package
const { status, data } = await s.json('POST', '/api/packages', {
providerId: provider.id,
name: '60min Session',
type: 'single',
sessionDuration: 60,
price: 12000
})
t.is(status, 201)
t.is(data.name, '60min Session')
t.is(data.price, 12000)
// List by provider
const list = await s.json('GET', '/api/providers/test-coach/packages')
t.is(list.status, 200)
t.is(list.data.length, 1)
// Get by ID
const get = await s.json('GET', `/api/packages/${data.id}`)
t.is(get.status, 200)
t.is(get.data.name, '60min Session')
// Update
const upd = await s.json('PUT', `/api/packages/${data.id}`, {
name: 'Updated Session',
price: 15000
})
t.is(upd.status, 200)
t.is(upd.data.name, 'Updated Session')
t.is(upd.data.price, 15000)
// Archive
const del = await s.json('DELETE', `/api/packages/${data.id}`)
t.is(del.status, 200)
// Archived package not in list
const list2 = await s.json('GET', '/api/providers/test-coach/packages')
t.is(list2.data.length, 0)
} finally {
await s.close()
}
})
test('availability and slot calculation', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
await seedAvailability(s, provider.id)
await seedPackage(s, provider.id)
// Get availability
const avail = await s.json('GET', '/api/providers/test-coach/availability')
t.is(avail.status, 200)
t.is(avail.data.bufferMinutes, 15)
t.ok(avail.data.weekly.monday.length > 0)
t.is(avail.data.weekly.saturday.length, 0)
// Get slots for a Monday (should have slots)
// 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]
const slots = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=60`)
t.is(slots.status, 200)
t.ok(slots.data.slots.length > 0, 'Monday should have slots')
// Each slot should have correct duration (60 min = 3600000ms)
for (const slot of slots.data.slots) {
t.is(slot.end - slot.start, 3600000)
t.ok(slot.startTime)
t.ok(slot.endTime)
}
// Slots should have 15min buffer between them
for (let i = 1; i < slots.data.slots.length; i++) {
const gap = slots.data.slots[i].start - slots.data.slots[i - 1].end
t.ok(gap >= 15 * 60000, 'buffer should be at least 15 min')
}
// Saturday should have no slots
const daysUntilSat = (6 - now.getDay() + 7) % 7 || 7
const saturday = new Date(now)
saturday.setDate(saturday.getDate() + daysUntilSat)
const satStr = saturday.toISOString().split('T')[0]
const satSlots = await s.json('GET', `/api/providers/test-coach/slots?date=${satStr}&duration=60`)
t.is(satSlots.data.slots.length, 0, 'Saturday should have no slots')
// Week view
const weekSlots = await s.json('GET', `/api/providers/test-coach/slots?weekOf=${mondayStr}&duration=60`)
t.is(weekSlots.status, 200)
t.ok(weekSlots.data.days)
t.is(Object.keys(weekSlots.data.days).length, 7)
} finally {
await s.close()
}
})
test('booking create, cancel, reschedule', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
await seedAvailability(s, provider.id)
const discovery = await seedDiscovery(s, provider.id)
// 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]
// Get slots
const slots = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
t.ok(slots.data.slots.length > 0)
const firstSlot = slots.data.slots[0]
// Create booking (free discovery call = auto-confirmed)
const booking = await s.json('POST', '/api/bookings', {
providerId: provider.id,
packageId: discovery.id,
date: mondayStr,
startTime: firstSlot.startTime
})
t.is(booking.status, 201)
t.is(booking.data.status, 'confirmed')
t.is(booking.data.date, mondayStr)
t.is(booking.data.startTimeLocal, firstSlot.startTime)
t.ok(booking.data.id.startsWith('bk_'))
// Get booking
const get = await s.json('GET', `/api/bookings/${booking.data.id}`)
t.is(get.status, 200)
t.is(get.data.id, booking.data.id)
// Slot should now be taken
const slotsAfter = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
const takenSlot = slotsAfter.data.slots.find(s => s.startTime === firstSlot.startTime)
t.absent(takenSlot, 'booked slot should not appear in available slots')
// Double-booking should fail
const double = await s.json('POST', '/api/bookings', {
providerId: provider.id,
packageId: discovery.id,
date: mondayStr,
startTime: firstSlot.startTime
})
t.is(double.status, 409)
// Reschedule to second slot
const secondSlot = slotsAfter.data.slots[0]
const resc = await s.json('PUT', `/api/bookings/${booking.data.id}/reschedule`, {
date: mondayStr,
startTime: secondSlot.startTime
})
t.is(resc.status, 200)
t.is(resc.data.startTimeLocal, secondSlot.startTime)
t.is(resc.data.rescheduleCount, 1)
// Cancel
const cancel = await s.json('PUT', `/api/bookings/${booking.data.id}/cancel`)
t.is(cancel.status, 200)
t.is(cancel.data.status, 'cancelled')
// Original slot should be available again
const slotsAfterCancel = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
const freedSlot = slotsAfterCancel.data.slots.find(s => s.startTime === secondSlot.startTime)
t.ok(freedSlot, 'cancelled slot should be available again')
} finally {
await s.close()
}
})
test('provider landing page renders HTML', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
await seedPackage(s, provider.id)
const res = await s.fetch('/test-coach')
t.is(res.status, 200)
t.ok(res.headers['content-type'].includes('text/html'))
const html = res.body
t.ok(html.includes('Test Coach'))
t.ok(html.includes('Test Session'))
t.ok(html.includes('identity.js'), 'should include identity.js')
t.ok(html.includes('app.js'), 'should include app.js')
t.ok(!html.includes('noble-ed25519.js'), 'should NOT include noble script tags (loaded dynamically)')
t.ok(html.includes('/test-coach/book/pkg_test1'), 'should link to booking page')
} finally {
await s.close()
}
})
test('booking page renders HTML', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
const pkg = await seedPackage(s, provider.id)
const res = await s.fetch(`/test-coach/book/${pkg.id}`)
t.is(res.status, 200)
t.ok(res.headers['content-type'].includes('text/html'))
const html = res.body
t.ok(html.includes('Test Session'))
t.ok(html.includes('identity.js'), 'should include identity.js')
t.ok(!html.includes('noble-ed25519.js'), 'should NOT include noble script tags')
t.ok(html.includes('initBookingPage'), 'should call initBookingPage')
} finally {
await s.close()
}
})
test('dashboard page renders HTML', async (t) => {
const s = await createTestServer()
try {
const res = await s.fetch('/dashboard')
t.is(res.status, 200)
const html = res.body
t.ok(html.includes('dashboard-sidebar'))
t.ok(html.includes('Calendar'))
t.ok(html.includes('Clients'))
t.ok(html.includes('Packages'))
t.ok(html.includes('dashboard.js'))
} finally {
await s.close()
}
})
test('unknown slug returns 404', async (t) => {
const s = await createTestServer()
try {
const res = await s.fetch('/nonexistent-provider')
t.is(res.status, 404)
} finally {
await s.close()
}
})
test('auth challenge endpoint', async (t) => {
const s = await createTestServer()
try {
const { status, data } = await s.json('GET', '/api/auth/challenge')
t.is(status, 200)
t.ok(data.challengeId)
t.ok(data.challenge)
} finally {
await s.close()
}
})
test('dashboard API — today and week', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
await seedAvailability(s, provider.id)
const today = await s.json('GET', '/api/dashboard/today')
t.is(today.status, 200)
t.ok(today.data.date)
t.ok(Array.isArray(today.data.bookings))
t.ok(today.data.summary)
const week = await s.json('GET', '/api/dashboard/week')
t.is(week.status, 200)
t.ok(week.data.days)
t.is(Object.keys(week.data.days).length, 7)
} finally {
await s.close()
}
})
test('dashboard API — clients list', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
const clients = await s.json('GET', '/api/dashboard/clients')
t.is(clients.status, 200)
t.ok(Array.isArray(clients.data))
} finally {
await s.close()
}
})
test('dashboard API — analytics', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
const analytics = await s.json('GET', '/api/dashboard/analytics')
t.is(analytics.status, 200)
t.ok(analytics.data.sessions)
t.is(typeof analytics.data.clients, 'number')
} finally {
await s.close()
}
})
test('profile API', async (t) => {
const s = await createTestServer()
try {
// Get (empty)
const get = await s.json('GET', '/api/profile')
t.is(get.status, 200)
// Update
const upd = await s.json('PUT', '/api/profile', {
displayName: 'Jane',
email: 'jane@test.com',
phone: '+15551234567'
})
t.is(upd.status, 200)
t.is(upd.data.displayName, 'Jane')
t.is(upd.data.email, 'jane@test.com')
// Verify persisted
const get2 = await s.json('GET', '/api/profile')
t.is(get2.data.displayName, 'Jane')
} finally {
await s.close()
}
})
test('booking complete and notes', async (t) => {
const s = await createTestServer()
try {
const provider = await seedProvider(s)
await seedAvailability(s, provider.id)
const discovery = await seedDiscovery(s, provider.id)
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 slots = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
const booking = await s.json('POST', '/api/bookings', {
providerId: provider.id,
packageId: discovery.id,
date: mondayStr,
startTime: slots.data.slots[0].startTime
})
// Add notes
const notes = await s.json('PUT', `/api/bookings/${booking.data.id}/notes`, {
notes: 'Great session, discussed goals.'
})
t.is(notes.status, 200)
t.is(notes.data.notes, 'Great session, discussed goals.')
// Complete
const complete = await s.json('PUT', `/api/bookings/${booking.data.id}/complete`)
t.is(complete.status, 200)
t.is(complete.data.status, 'completed')
} finally {
await s.close()
}
})