OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / b13a13f

Add comprehensive test suite: 17 tests, 132 assertions
Tests cover all Phase 1+2 functionality: - Health check, static file serving, MIME types, directory traversal - Provider CRUD (create, list, get, update, duplicate slug, 404) - Package CRUD (create, list, get, update, archive, archived filtering) - Availability config + slot calculation (buffer, day-off, week view) - Booking lifecycle (create, conflict detection, reschedule, cancel, slot release) - HTML page rendering (provider landing, booking calendar, dashboard) - Dashboard API (today, week, clients, analytics) - Profile API (get, update, persistence) - Auth challenge endpoint - Booking complete + session notes Uses mock auth manager for isolated testing. Each test gets its own Corestore + Hyperbee + HTTP server on random port. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b13a13fe3b572de2be373581cbb3dda963b1737f
by plankatron <fleeky@gmail.com> · 2026-03-22T19:04:20.000Z

Changed files (1) +706 -0

test/server.test.js +706 -0
……@@ -0,0 +1,706 @@
1await import('bare-node-runtime/global')
2import test from 'brittle'
3import Corestore from 'corestore'
4import RAM from 'random-access-memory'
5import os from 'bare-os'
6import path from 'bare-path'
7import fs from 'bare-fs'
8import Hyperbee from 'hyperbee'
9import crypto from 'bare-crypto'
10import http from 'bare-http1'
11
12import { Router } from '../lib/router.js'
13import { registerWebRoutes, registerPublicRoutes } from '../lib/routes/web.js'
14import { registerProviderRoutes } from '../lib/routes/providers.js'
15import { registerPackageRoutes } from '../lib/routes/packages.js'
16import { registerAvailabilityRoutes } from '../lib/routes/availability.js'
17import { registerBookingRoutes } from '../lib/routes/bookings.js'
18import { registerDashboardRoutes } from '../lib/routes/dashboard.js'
19import { registerAuthRoutes } from '../lib/routes/auth.js'
20
21// ---- Test Server Factory ----
22
23async function createTestServer () {
24 const tmpDir = path.join(os.tmpdir(), 'hb-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
30 const server = {
31 db,
32 store,
33 pathPrefix: '',
34 appDir: new URL('..', import.meta.url).pathname.replace(/\/$/, ''),
35 authManager: createMockAuthManager(db),
36 generateId (prefix) {
37 return prefix + '_' + crypto.randomBytes(12).toString('hex')
38 }
39 }
40
41 const router = new Router(server)
42 server.router = router
43
44 registerWebRoutes(router, server)
45 registerAuthRoutes(router, server)
46 registerProviderRoutes(router, server)
47 registerPackageRoutes(router, server)
48 registerAvailabilityRoutes(router, server)
49 registerBookingRoutes(router, server)
50 registerDashboardRoutes(router, server)
51 registerPublicRoutes(router, server)
52
53 // Start HTTP server on random port
54 const httpServer = http.createServer(async (req, res) => {
55 res.setHeader('Access-Control-Allow-Origin', '*')
56 if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }
57 const matched = await router.dispatch(req, res)
58 if (!matched) { res.statusCode = 404; res.end('{"error":"Not found"}') }
59 })
60
61 await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve))
62 const port = httpServer.address().port
63 const base = `http://127.0.0.1:${port}`
64
65 function httpRequest (method, reqPath, body) {
66 return new Promise((resolve, reject) => {
67 const opts = {
68 hostname: '127.0.0.1',
69 port,
70 path: reqPath,
71 method,
72 headers: {}
73 }
74 if (body) {
75 const bodyStr = JSON.stringify(body)
76 opts.headers['Content-Type'] = 'application/json'
77 opts.headers['Content-Length'] = Buffer.byteLength(bodyStr)
78 }
79
80 const req = http.request(opts, (res) => {
81 let data = ''
82 res.on('data', chunk => { data += chunk })
83 res.on('end', () => {
84 resolve({
85 status: res.statusCode,
86 headers: res.headers,
87 body: data,
88 json () { return JSON.parse(data) }
89 })
90 })
91 })
92 req.on('error', reject)
93 if (body) req.write(JSON.stringify(body))
94 req.end()
95 })
96 }
97
98 return {
99 server, db, base, httpServer,
100 async close () {
101 httpServer.close()
102 await store.close()
103 },
104 async fetch (reqPath) {
105 return httpRequest('GET', reqPath)
106 },
107 async json (method, reqPath, body) {
108 const res = await httpRequest(method, reqPath, body)
109 return { status: res.status, data: res.json(), headers: res.headers }
110 }
111 }
112}
113
114function createMockAuthManager (db) {
115 // Minimal mock that auto-authenticates all requests as admin
116 return {
117 authenticate (req) {
118 return {
119 userId: 'test-user-1',
120 identityId: 'test-identity-1',
121 method: 'test',
122 username: 'testuser',
123 permissions: { global: ['read', 'write', 'admin'] },
124 user: { userId: 'test-user-1', username: 'testuser' },
125 verified: true
126 }
127 },
128 authorizeRequest (req, action, resource) {
129 const auth = this.authenticate(req)
130 return { authorized: true, auth, reason: null }
131 },
132 challengeManager: {
133 generate () {
134 return { challengeId: 'test-challenge', challenge: 'test', expiresAt: Date.now() + 300000 }
135 }
136 },
137 identityRegistry: {
138 setUsername () {}
139 }
140 }
141}
142
143// ---- Seed Helpers ----
144
145async function seedProvider (t) {
146 const providerId = 'prov_test1'
147 const provider = {
148 id: providerId,
149 slug: 'test-coach',
150 displayName: 'Test Coach',
151 bio: 'A test coach.',
152 timezone: 'America/New_York',
153 specialties: ['Wellness'],
154 socialLinks: {},
155 photo: null,
156 accentColor: '#c4a265',
157 active: true,
158 userId: 'test-user-1',
159 createdAt: Date.now()
160 }
161
162 const batch = t.db.batch()
163 await batch.put(`provider:${providerId}`, provider)
164 await batch.put(`provider-by-slug:test-coach`, providerId)
165 await batch.flush()
166
167 return provider
168}
169
170async function seedPackage (t, providerId) {
171 const pkg = {
172 id: 'pkg_test1',
173 providerId,
174 name: 'Test Session',
175 type: 'single',
176 description: 'A test session.',
177 sessionDuration: 60,
178 sessionCount: 1,
179 price: 10000,
180 currency: 'USD',
181 active: true,
182 createdAt: Date.now()
183 }
184
185 const batch = t.db.batch()
186 await batch.put(`package:${providerId}:${pkg.id}`, pkg)
187 await batch.put(`package-detail:${pkg.id}`, pkg)
188 await batch.flush()
189
190 return pkg
191}
192
193async function seedDiscovery (t, providerId) {
194 const pkg = {
195 id: 'pkg_discovery',
196 providerId,
197 name: 'Free Discovery Call',
198 type: 'discovery',
199 description: 'Free intro.',
200 sessionDuration: 15,
201 sessionCount: 1,
202 price: 0,
203 currency: 'USD',
204 active: true,
205 createdAt: Date.now()
206 }
207
208 const batch = t.db.batch()
209 await batch.put(`package:${providerId}:${pkg.id}`, pkg)
210 await batch.put(`package-detail:${pkg.id}`, pkg)
211 await batch.flush()
212
213 return pkg
214}
215
216async function seedAvailability (t, providerId) {
217 const avail = {
218 providerId,
219 timezone: 'America/New_York',
220 weekly: {
221 monday: [{ start: '09:00', end: '17:00' }],
222 tuesday: [{ start: '09:00', end: '17:00' }],
223 wednesday: [{ start: '09:00', end: '17:00' }],
224 thursday: [{ start: '09:00', end: '17:00' }],
225 friday: [{ start: '09:00', end: '17:00' }],
226 saturday: [],
227 sunday: []
228 },
229 bufferMinutes: 15,
230 advanceBooking: { minHours: 0, maxDays: 60 },
231 cancellation: { windowHours: 24, refundPolicy: 'full' },
232 reschedule: { windowHours: 24, maxPerBooking: 2 },
233 noShow: { penalty: 'forfeit' }
234 }
235 await t.db.put(`availability:${providerId}`, avail)
236 return avail
237}
238
239// ---- Tests ----
240
241test('health check', async (t) => {
242 const s = await createTestServer()
243 try {
244 const { status, data } = await s.json('GET', '/api/health')
245 t.is(status, 200)
246 t.is(data.status, 'ok')
247 t.is(data.version, '0.1.0')
248 } finally {
249 await s.close()
250 }
251})
252
253test('static files serve with correct MIME types', async (t) => {
254 const s = await createTestServer()
255 try {
256 const css = await s.fetch('/web/css/app.css')
257 t.is(css.status, 200)
258 t.ok(css.headers['content-type'].includes('text/css'))
259
260 const js = await s.fetch('/web/js/app.js')
261 t.is(js.status, 200)
262 t.ok(js.headers['content-type'].includes('application/javascript'))
263
264 const noble = await s.fetch('/web/vendor/noble-ed25519.js')
265 t.is(noble.status, 200)
266 t.ok(noble.headers['content-type'].includes('application/javascript'))
267
268 const nobleSha = await s.fetch('/web/vendor/noble-sha512.js')
269 t.is(nobleSha.status, 200)
270
271 const nobleCrypto = await s.fetch('/web/vendor/noble-crypto.js')
272 t.is(nobleCrypto.status, 200)
273
274 const missing = await s.fetch('/web/nope.js')
275 t.is(missing.status, 404)
276 } finally {
277 await s.close()
278 }
279})
280
281test('directory traversal blocked', async (t) => {
282 const s = await createTestServer()
283 try {
284 const res = await s.fetch('/web/../package.json')
285 t.is(res.status, 404)
286 } finally {
287 await s.close()
288 }
289})
290
291test('provider CRUD', async (t) => {
292 const s = await createTestServer()
293 try {
294 // Create
295 const { status, data } = await s.json('POST', '/api/providers', {
296 displayName: 'Jane Doe',
297 slug: 'jane-doe',
298 bio: 'A coach.',
299 timezone: 'America/Chicago'
300 })
301 t.is(status, 201)
302 t.is(data.displayName, 'Jane Doe')
303 t.is(data.slug, 'jane-doe')
304 t.ok(data.id.startsWith('prov_'))
305
306 // List
307 const list = await s.json('GET', '/api/providers')
308 t.is(list.status, 200)
309 t.is(list.data.length, 1)
310
311 // Get by slug
312 const get = await s.json('GET', '/api/providers/jane-doe')
313 t.is(get.status, 200)
314 t.is(get.data.displayName, 'Jane Doe')
315
316 // Update
317 const upd = await s.json('PUT', '/api/providers/jane-doe', {
318 bio: 'Updated bio.'
319 })
320 t.is(upd.status, 200)
321 t.is(upd.data.bio, 'Updated bio.')
322
323 // Duplicate slug
324 const dup = await s.json('POST', '/api/providers', {
325 displayName: 'Another',
326 slug: 'jane-doe'
327 })
328 t.is(dup.status, 409)
329
330 // Not found
331 const nf = await s.json('GET', '/api/providers/nope')
332 t.is(nf.status, 404)
333 } finally {
334 await s.close()
335 }
336})
337
338test('package CRUD', async (t) => {
339 const s = await createTestServer()
340 try {
341 const provider = await seedProvider(s)
342
343 // Create package
344 const { status, data } = await s.json('POST', '/api/packages', {
345 providerId: provider.id,
346 name: '60min Session',
347 type: 'single',
348 sessionDuration: 60,
349 price: 12000
350 })
351 t.is(status, 201)
352 t.is(data.name, '60min Session')
353 t.is(data.price, 12000)
354
355 // List by provider
356 const list = await s.json('GET', '/api/providers/test-coach/packages')
357 t.is(list.status, 200)
358 t.is(list.data.length, 1)
359
360 // Get by ID
361 const get = await s.json('GET', `/api/packages/${data.id}`)
362 t.is(get.status, 200)
363 t.is(get.data.name, '60min Session')
364
365 // Update
366 const upd = await s.json('PUT', `/api/packages/${data.id}`, {
367 name: 'Updated Session',
368 price: 15000
369 })
370 t.is(upd.status, 200)
371 t.is(upd.data.name, 'Updated Session')
372 t.is(upd.data.price, 15000)
373
374 // Archive
375 const del = await s.json('DELETE', `/api/packages/${data.id}`)
376 t.is(del.status, 200)
377
378 // Archived package not in list
379 const list2 = await s.json('GET', '/api/providers/test-coach/packages')
380 t.is(list2.data.length, 0)
381 } finally {
382 await s.close()
383 }
384})
385
386test('availability and slot calculation', async (t) => {
387 const s = await createTestServer()
388 try {
389 const provider = await seedProvider(s)
390 await seedAvailability(s, provider.id)
391 await seedPackage(s, provider.id)
392
393 // Get availability
394 const avail = await s.json('GET', '/api/providers/test-coach/availability')
395 t.is(avail.status, 200)
396 t.is(avail.data.bufferMinutes, 15)
397 t.ok(avail.data.weekly.monday.length > 0)
398 t.is(avail.data.weekly.saturday.length, 0)
399
400 // Get slots for a Monday (should have slots)
401 // Find next Monday
402 const now = new Date()
403 const daysUntilMonday = (8 - now.getDay()) % 7 || 7
404 const monday = new Date(now)
405 monday.setDate(monday.getDate() + daysUntilMonday)
406 const mondayStr = monday.toISOString().split('T')[0]
407
408 const slots = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=60`)
409 t.is(slots.status, 200)
410 t.ok(slots.data.slots.length > 0, 'Monday should have slots')
411
412 // Each slot should have correct duration (60 min = 3600000ms)
413 for (const slot of slots.data.slots) {
414 t.is(slot.end - slot.start, 3600000)
415 t.ok(slot.startTime)
416 t.ok(slot.endTime)
417 }
418
419 // Slots should have 15min buffer between them
420 for (let i = 1; i < slots.data.slots.length; i++) {
421 const gap = slots.data.slots[i].start - slots.data.slots[i - 1].end
422 t.ok(gap >= 15 * 60000, 'buffer should be at least 15 min')
423 }
424
425 // Saturday should have no slots
426 const daysUntilSat = (6 - now.getDay() + 7) % 7 || 7
427 const saturday = new Date(now)
428 saturday.setDate(saturday.getDate() + daysUntilSat)
429 const satStr = saturday.toISOString().split('T')[0]
430
431 const satSlots = await s.json('GET', `/api/providers/test-coach/slots?date=${satStr}&duration=60`)
432 t.is(satSlots.data.slots.length, 0, 'Saturday should have no slots')
433
434 // Week view
435 const weekSlots = await s.json('GET', `/api/providers/test-coach/slots?weekOf=${mondayStr}&duration=60`)
436 t.is(weekSlots.status, 200)
437 t.ok(weekSlots.data.days)
438 t.is(Object.keys(weekSlots.data.days).length, 7)
439 } finally {
440 await s.close()
441 }
442})
443
444test('booking create, cancel, reschedule', async (t) => {
445 const s = await createTestServer()
446 try {
447 const provider = await seedProvider(s)
448 await seedAvailability(s, provider.id)
449 const discovery = await seedDiscovery(s, provider.id)
450
451 // Find next Monday
452 const now = new Date()
453 const daysUntilMonday = (8 - now.getDay()) % 7 || 7
454 const monday = new Date(now)
455 monday.setDate(monday.getDate() + daysUntilMonday)
456 const mondayStr = monday.toISOString().split('T')[0]
457
458 // Get slots
459 const slots = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
460 t.ok(slots.data.slots.length > 0)
461 const firstSlot = slots.data.slots[0]
462
463 // Create booking (free discovery call = auto-confirmed)
464 const booking = await s.json('POST', '/api/bookings', {
465 providerId: provider.id,
466 packageId: discovery.id,
467 date: mondayStr,
468 startTime: firstSlot.startTime
469 })
470 t.is(booking.status, 201)
471 t.is(booking.data.status, 'confirmed')
472 t.is(booking.data.date, mondayStr)
473 t.is(booking.data.startTimeLocal, firstSlot.startTime)
474 t.ok(booking.data.id.startsWith('bk_'))
475
476 // Get booking
477 const get = await s.json('GET', `/api/bookings/${booking.data.id}`)
478 t.is(get.status, 200)
479 t.is(get.data.id, booking.data.id)
480
481 // Slot should now be taken
482 const slotsAfter = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
483 const takenSlot = slotsAfter.data.slots.find(s => s.startTime === firstSlot.startTime)
484 t.absent(takenSlot, 'booked slot should not appear in available slots')
485
486 // Double-booking should fail
487 const double = await s.json('POST', '/api/bookings', {
488 providerId: provider.id,
489 packageId: discovery.id,
490 date: mondayStr,
491 startTime: firstSlot.startTime
492 })
493 t.is(double.status, 409)
494
495 // Reschedule to second slot
496 const secondSlot = slotsAfter.data.slots[0]
497 const resc = await s.json('PUT', `/api/bookings/${booking.data.id}/reschedule`, {
498 date: mondayStr,
499 startTime: secondSlot.startTime
500 })
501 t.is(resc.status, 200)
502 t.is(resc.data.startTimeLocal, secondSlot.startTime)
503 t.is(resc.data.rescheduleCount, 1)
504
505 // Cancel
506 const cancel = await s.json('PUT', `/api/bookings/${booking.data.id}/cancel`)
507 t.is(cancel.status, 200)
508 t.is(cancel.data.status, 'cancelled')
509
510 // Original slot should be available again
511 const slotsAfterCancel = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
512 const freedSlot = slotsAfterCancel.data.slots.find(s => s.startTime === secondSlot.startTime)
513 t.ok(freedSlot, 'cancelled slot should be available again')
514 } finally {
515 await s.close()
516 }
517})
518
519test('provider landing page renders HTML', async (t) => {
520 const s = await createTestServer()
521 try {
522 const provider = await seedProvider(s)
523 await seedPackage(s, provider.id)
524
525 const res = await s.fetch('/test-coach')
526 t.is(res.status, 200)
527 t.ok(res.headers['content-type'].includes('text/html'))
528
529 const html = res.body
530 t.ok(html.includes('Test Coach'))
531 t.ok(html.includes('Test Session'))
532 t.ok(html.includes('noble-ed25519.js'))
533 t.ok(html.includes('noble-sha512.js'))
534 t.ok(html.includes('identity.js'))
535 t.ok(html.includes('app.js'))
536 t.ok(html.includes('/test-coach/book/pkg_test1'))
537 } finally {
538 await s.close()
539 }
540})
541
542test('booking page renders HTML', async (t) => {
543 const s = await createTestServer()
544 try {
545 const provider = await seedProvider(s)
546 const pkg = await seedPackage(s, provider.id)
547
548 const res = await s.fetch(`/test-coach/book/${pkg.id}`)
549 t.is(res.status, 200)
550 t.ok(res.headers['content-type'].includes('text/html'))
551
552 const html = res.body
553 t.ok(html.includes('Test Session'))
554 t.ok(html.includes('noble-ed25519.js'))
555 t.ok(html.includes('identity.js'))
556 t.ok(html.includes('initBookingPage'))
557 } finally {
558 await s.close()
559 }
560})
561
562test('dashboard page renders HTML', async (t) => {
563 const s = await createTestServer()
564 try {
565 const res = await s.fetch('/dashboard')
566 t.is(res.status, 200)
567 const html = res.body
568 t.ok(html.includes('dashboard-sidebar'))
569 t.ok(html.includes('Calendar'))
570 t.ok(html.includes('Clients'))
571 t.ok(html.includes('Packages'))
572 t.ok(html.includes('dashboard.js'))
573 } finally {
574 await s.close()
575 }
576})
577
578test('unknown slug returns 404', async (t) => {
579 const s = await createTestServer()
580 try {
581 const res = await s.fetch('/nonexistent-provider')
582 t.is(res.status, 404)
583 } finally {
584 await s.close()
585 }
586})
587
588test('auth challenge endpoint', async (t) => {
589 const s = await createTestServer()
590 try {
591 const { status, data } = await s.json('GET', '/api/auth/challenge')
592 t.is(status, 200)
593 t.ok(data.challengeId)
594 t.ok(data.challenge)
595 } finally {
596 await s.close()
597 }
598})
599
600test('dashboard API — today and week', async (t) => {
601 const s = await createTestServer()
602 try {
603 const provider = await seedProvider(s)
604 await seedAvailability(s, provider.id)
605
606 const today = await s.json('GET', '/api/dashboard/today')
607 t.is(today.status, 200)
608 t.ok(today.data.date)
609 t.ok(Array.isArray(today.data.bookings))
610 t.ok(today.data.summary)
611
612 const week = await s.json('GET', '/api/dashboard/week')
613 t.is(week.status, 200)
614 t.ok(week.data.days)
615 t.is(Object.keys(week.data.days).length, 7)
616 } finally {
617 await s.close()
618 }
619})
620
621test('dashboard API — clients list', async (t) => {
622 const s = await createTestServer()
623 try {
624 const provider = await seedProvider(s)
625 const clients = await s.json('GET', '/api/dashboard/clients')
626 t.is(clients.status, 200)
627 t.ok(Array.isArray(clients.data))
628 } finally {
629 await s.close()
630 }
631})
632
633test('dashboard API — analytics', async (t) => {
634 const s = await createTestServer()
635 try {
636 const provider = await seedProvider(s)
637 const analytics = await s.json('GET', '/api/dashboard/analytics')
638 t.is(analytics.status, 200)
639 t.ok(analytics.data.sessions)
640 t.is(typeof analytics.data.clients, 'number')
641 } finally {
642 await s.close()
643 }
644})
645
646test('profile API', async (t) => {
647 const s = await createTestServer()
648 try {
649 // Get (empty)
650 const get = await s.json('GET', '/api/profile')
651 t.is(get.status, 200)
652
653 // Update
654 const upd = await s.json('PUT', '/api/profile', {
655 displayName: 'Jane',
656 email: 'jane@test.com',
657 phone: '+15551234567'
658 })
659 t.is(upd.status, 200)
660 t.is(upd.data.displayName, 'Jane')
661 t.is(upd.data.email, 'jane@test.com')
662
663 // Verify persisted
664 const get2 = await s.json('GET', '/api/profile')
665 t.is(get2.data.displayName, 'Jane')
666 } finally {
667 await s.close()
668 }
669})
670
671test('booking complete and notes', async (t) => {
672 const s = await createTestServer()
673 try {
674 const provider = await seedProvider(s)
675 await seedAvailability(s, provider.id)
676 const discovery = await seedDiscovery(s, provider.id)
677
678 const now = new Date()
679 const daysUntilMonday = (8 - now.getDay()) % 7 || 7
680 const monday = new Date(now)
681 monday.setDate(monday.getDate() + daysUntilMonday)
682 const mondayStr = monday.toISOString().split('T')[0]
683
684 const slots = await s.json('GET', `/api/providers/test-coach/slots?date=${mondayStr}&duration=15`)
685 const booking = await s.json('POST', '/api/bookings', {
686 providerId: provider.id,
687 packageId: discovery.id,
688 date: mondayStr,
689 startTime: slots.data.slots[0].startTime
690 })
691
692 // Add notes
693 const notes = await s.json('PUT', `/api/bookings/${booking.data.id}/notes`, {
694 notes: 'Great session, discussed goals.'
695 })
696 t.is(notes.status, 200)
697 t.is(notes.data.notes, 'Great session, discussed goals.')
698
699 // Complete
700 const complete = await s.json('PUT', `/api/bookings/${booking.data.id}/complete`)
701 t.is(complete.status, 200)
702 t.is(complete.data.status, 'completed')
703 } finally {
704 await s.close()
705 }
706})