OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / test / server.test.js

706 lines · javascript ref:
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()
  }
})