OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / unit / web.test.js

304 lines · javascript ref:
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import os from 'bare-os'
import http from 'bare-http1'
import { DriveManager } from '../../lib/drive-manager.js'
import { NoteManager } from '../../lib/note-manager.js'
import { Router } from '../../lib/routes/index.js'
import { registerNotebookRoutes } from '../../lib/routes/notebooks.js'
import { registerWebRoutes } from '../../lib/routes/web.js'

// ==================== HELPERS ====================

function tmpSetup () {
  const dir = path.join(os.tmpdir(), 'hn-web-' + Date.now() + '-' + Math.random().toString(36).slice(2))
  fs.mkdirSync(dir, { recursive: true })
  const store = new Corestore(dir)
  const registry = new Hyperbee(store.get({ name: 'registry' }), {
    keyEncoding: 'utf-8',
    valueEncoding: 'json'
  })
  const dm = new DriveManager(store, registry)
  return { store, registry, dm, dir }
}

function rmrf (dir) {
  try {
    for (const f of fs.readdirSync(dir)) {
      const fp = path.join(dir, f)
      const stat = fs.statSync(fp)
      if (stat.isDirectory()) rmrf(fp)
      else fs.unlinkSync(fp)
    }
    fs.rmdirSync(dir)
  } catch {}
}

/**
 * Create a minimal mock server + router with web routes registered
 */
function createMockServer () {
  const server = {
    pathPrefix: '',
    authManager: {
      authenticate: async () => ({ userId: 'test', permissions: ['admin'] })
    }
  }
  const router = new Router(server)
  registerWebRoutes(router, server)
  return { server, router }
}

/**
 * Mock HTTP request
 */
function mockReq (method, url) {
  return { method, url, params: {} }
}

/**
 * Mock HTTP response that captures output
 */
function mockRes () {
  const res = {
    statusCode: 200,
    headers: {},
    body: null,
    writableEnded: false,
    setHeader (name, value) { res.headers[name.toLowerCase()] = value },
    end (data) { res.body = data; res.writableEnded = true },
    write (data) { if (!res.body) res.body = ''; res.body += data }
  }
  return res
}

// ==================== WEB FILES EXIST ====================

test('web — index.html exists', (t) => {
  const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
  const indexPath = path.join(webRoot, 'index.html')
  t.ok(fs.existsSync(indexPath), 'index.html exists')

  const content = fs.readFileSync(indexPath, 'utf-8')
  t.ok(content.includes('<!DOCTYPE html>'), 'is valid HTML')
  t.ok(content.includes('Hypernote'), 'contains app name')
  t.ok(content.includes('app.css'), 'references stylesheet')
  t.ok(content.includes('api.js'), 'references API client')
  t.ok(content.includes('markdown.js'), 'references markdown renderer')
  t.ok(content.includes('app.js'), 'references app script')
})

test('web — app.css exists and has theme variables', (t) => {
  const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
  const cssPath = path.join(webRoot, 'css', 'app.css')
  t.ok(fs.existsSync(cssPath), 'app.css exists')

  const content = fs.readFileSync(cssPath, 'utf-8')
  t.ok(content.includes(':root'), 'has CSS variables root')
  t.ok(content.includes('--bg:'), 'has background variable')
  t.ok(content.includes('--accent:'), 'has accent color variable')
  t.ok(content.includes('.markdown-body'), 'has markdown body styles')
  t.ok(content.includes('@media'), 'has responsive breakpoints')
})

test('web — api.js exists and defines HnApi', (t) => {
  const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
  const apiPath = path.join(webRoot, 'js', 'api.js')
  t.ok(fs.existsSync(apiPath), 'api.js exists')

  const content = fs.readFileSync(apiPath, 'utf-8')
  t.ok(content.includes('window.HnApi'), 'defines HnApi global')
  t.ok(content.includes('listNotebooks'), 'has listNotebooks method')
  t.ok(content.includes('getNote'), 'has getNote method')
  t.ok(content.includes('createNote'), 'has createNote method')
  t.ok(content.includes('search'), 'has search method')
  t.ok(content.includes('getProfile'), 'has getProfile method')
})

test('web — markdown.js exists and defines renderMarkdown', (t) => {
  const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
  const mdPath = path.join(webRoot, 'js', 'markdown.js')
  t.ok(fs.existsSync(mdPath), 'markdown.js exists')

  const content = fs.readFileSync(mdPath, 'utf-8')
  t.ok(content.includes('window.renderMarkdown'), 'defines renderMarkdown global')
  t.ok(content.includes('wikilink'), 'handles wiki-links')
  t.ok(content.includes('<pre><code>'), 'handles code blocks')
  t.ok(content.includes('<strong>'), 'handles bold')
  t.ok(content.includes('<em>'), 'handles italic')
})

test('web — app.js exists and has SPA router', (t) => {
  const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
  const appPath = path.join(webRoot, 'js', 'app.js')
  t.ok(fs.existsSync(appPath), 'app.js exists')

  const content = fs.readFileSync(appPath, 'utf-8')
  t.ok(content.includes('hashchange'), 'listens to hash changes')
  t.ok(content.includes('parseRoute'), 'has route parser')
  t.ok(content.includes('showView'), 'has view management')
  t.ok(content.includes('loadNotebooks'), 'has notebook loading')
  t.ok(content.includes('saveNote'), 'has note saving')
  t.ok(content.includes('tuiEditor'), 'has WYSIWYG editor')
})

// ==================== STATIC FILE SERVING ====================

test('web routes — serves index.html at /', async (t) => {
  const { router } = createMockServer()
  const req = mockReq('GET', '/')
  const res = mockRes()

  const matched = await router.dispatch(req, res)
  t.ok(matched, 'route matched')
  t.is(res.statusCode, 200)
  t.ok(res.headers['content-type'].includes('text/html'), 'serves HTML')
  t.ok(res.body.toString().includes('<!DOCTYPE html>'), 'serves index.html content')
  t.ok(res.body.toString().includes('Hypernote'), 'contains app name')
})

test('web routes — serves CSS files', async (t) => {
  const { router } = createMockServer()
  const req = mockReq('GET', '/web/css/app.css')
  const res = mockRes()

  const matched = await router.dispatch(req, res)
  t.ok(matched, 'route matched')
  t.is(res.statusCode, 200)
  t.ok(res.headers['content-type'].includes('text/css'), 'correct MIME type')
  t.ok(res.body.toString().includes(':root'), 'serves actual CSS content')
})

test('web routes — serves JS files', async (t) => {
  const { router } = createMockServer()
  const req = mockReq('GET', '/web/js/api.js')
  const res = mockRes()

  const matched = await router.dispatch(req, res)
  t.ok(matched, 'route matched')
  t.is(res.statusCode, 200)
  t.ok(res.headers['content-type'].includes('javascript'), 'correct MIME type')
  t.ok(res.body.toString().includes('HnApi'), 'serves actual JS content')
})

test('web routes — 404 for missing files', async (t) => {
  const { router } = createMockServer()
  const req = mockReq('GET', '/web/nonexistent.js')
  const res = mockRes()

  const matched = await router.dispatch(req, res)
  t.ok(matched, 'route matched (handler ran)')
  t.is(res.statusCode, 404, 'returns 404')
})

test('web routes — blocks directory traversal', async (t) => {
  const { router } = createMockServer()
  const req = mockReq('GET', '/web/../hypernote-server.js')
  const res = mockRes()

  const matched = await router.dispatch(req, res)
  t.ok(matched, 'route matched')
  t.is(res.statusCode, 403, 'returns 403 Forbidden')
})

test('web routes — sets security headers', async (t) => {
  const { router } = createMockServer()
  const req = mockReq('GET', '/')
  const res = mockRes()

  await router.dispatch(req, res)
  t.ok(res.headers['x-content-type-options'], 'has nosniff header')
  t.ok(res.headers['x-frame-options'], 'has frame options header')
  t.ok(res.headers['referrer-policy'], 'has referrer policy')
})

test('web routes — sets cache headers for static assets', async (t) => {
  const { router } = createMockServer()
  const req = mockReq('GET', '/web/css/app.css')
  const res = mockRes()

  await router.dispatch(req, res)
  t.ok(res.headers['cache-control'], 'has cache-control header')
  t.ok(res.headers['cache-control'].includes('max-age'), 'sets max-age')
})

// ==================== API ENDPOINT COEXISTENCE ====================

test('web routes — API routes still work alongside web routes', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  const server = {
    pathPrefix: '',
    noteManager: nm,
    authManager: {
      authenticate: async () => ({ userId: 'test', permissions: ['admin'] }),
      authorizeRequest: async () => ({
        authorized: true,
        auth: { userId: 'test', permissions: ['admin'] },
        reason: null
      })
    }
  }

  const router = new Router(server)
  registerWebRoutes(router, server)
  registerNotebookRoutes(router, server)

  // Web route works
  const webReq = mockReq('GET', '/')
  const webRes = mockRes()
  await router.dispatch(webReq, webRes)
  t.is(webRes.statusCode, 200)
  t.ok(webRes.headers['content-type'].includes('text/html'))

  // API route still works
  const apiReq = mockReq('GET', '/api/notebooks')
  const apiRes = mockRes()
  await router.dispatch(apiReq, apiRes)
  t.is(apiRes.statusCode, 200)
  t.ok(apiRes.headers['content-type'].includes('application/json'))

  await store.close()
  rmrf(dir)
})

// ==================== HTML STRUCTURE ====================

test('web — index.html has required view elements', (t) => {
  const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
  const content = fs.readFileSync(path.join(webRoot, 'index.html'), 'utf-8')

  // Views
  t.ok(content.includes('id="view-home"'), 'has home view')
  t.ok(content.includes('id="view-notebook"'), 'has notebook view')
  t.ok(content.includes('id="view-note"'), 'has note view')
  t.ok(content.includes('id="view-editor"'), 'has editor view')
  t.ok(content.includes('id="view-search"'), 'has search view')

  // Sidebar
  t.ok(content.includes('id="notebook-list"'), 'has notebook list')
  // Editor
  t.ok(content.includes('id="editor-container"'), 'has editor container')

  // Auth
  t.ok(content.includes('id="auth-setup"'), 'has auth setup')
  t.ok(content.includes('id="auth-token-input"'), 'has token input')
})

test('web — CSS has essential component styles', (t) => {
  const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
  const content = fs.readFileSync(path.join(webRoot, 'css', 'app.css'), 'utf-8')

  t.ok(content.includes('#sidebar'), 'has sidebar styles')
  t.ok(content.includes('#topbar'), 'has topbar styles')
  t.ok(content.includes('#editor-container'), 'has editor container styles')
  t.ok(content.includes('.note-card'), 'has note card styles')
  t.ok(content.includes('.modal'), 'has modal styles')
  t.ok(content.includes('.auth-card'), 'has auth card styles')
})