OverviewFilesCommitsIssuesDocsReleasesPeers

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

489 lines · javascript ref:
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import { DriveManager } from '../../lib/drive-manager.js'
import { NoteManager } from '../../lib/note-manager.js'

function tmpSetup () {
  const dir = path.join(os.tmpdir(), 'hn-nm-' + 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 {}
}

// ==================== NOTEBOOK TESTS ====================

test('create notebook — writes metadata.json', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  const nb = await nm.createNotebook('work', { name: 'Work Notes', description: 'Work stuff' })

  t.is(nb.slug, 'work')
  t.is(nb.name, 'Work Notes')
  t.is(nb.description, 'Work stuff')
  t.ok(nb.createdAt)
  t.ok(nb.updatedAt)
  t.is(nb.noteCount, 0)

  // Verify on disk
  const drive = await dm.getDrive('work')
  const raw = await drive.get('/metadata.json')
  const parsed = JSON.parse(raw.toString())
  t.is(parsed.slug, 'work')

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

test('create notebook — rejects invalid slug', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  try {
    await nm.createNotebook('My Notebook!', { name: 'Bad' })
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.name, 'ValidationError')
    t.is(err.field, 'slug')
  }

  try {
    await nm.createNotebook('-bad', { name: 'Bad' })
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.name, 'ValidationError')
  }

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

test('create notebook — rejects duplicate slug', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('work', { name: 'Work' })

  try {
    await nm.createNotebook('work', { name: 'Work Again' })
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.name, 'ConflictError')
  }

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

test('list notebooks — returns array of all notebooks', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('alpha', { name: 'Alpha' })
  await nm.createNotebook('beta', { name: 'Beta' })
  await nm.createNotebook('gamma', { name: 'Gamma' })

  const list = await nm.listNotebooks()
  t.is(list.length, 3)
  // Sorted by name
  t.is(list[0].slug, 'alpha')
  t.is(list[1].slug, 'beta')
  t.is(list[2].slug, 'gamma')

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

test('get notebook — returns metadata, null for missing', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })

  const found = await nm.getNotebook('test')
  t.ok(found)
  t.is(found.slug, 'test')

  const missing = await nm.getNotebook('nonexistent')
  t.is(missing, null)

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

test('update notebook — updates name and description', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  const created = await nm.createNotebook('test', { name: 'Original', description: 'Orig' })
  const createdAt = created.createdAt

  // Small delay to ensure updatedAt differs
  await new Promise(r => setTimeout(r, 10))

  const updated = await nm.updateNotebook('test', { name: 'Renamed', description: 'New desc' })
  t.is(updated.name, 'Renamed')
  t.is(updated.description, 'New desc')
  t.is(updated.slug, 'test')
  t.ok(updated.updatedAt >= createdAt)

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

test('delete notebook — removes metadata and notes', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('doomed', { name: 'Doomed' })
  await nm.createNote('doomed', { title: 'Note 1', content: 'Content 1' })
  await nm.createNote('doomed', { title: 'Note 2', content: 'Content 2' })

  const result = await nm.deleteNotebook('doomed')
  t.ok(result.deleted)

  const meta = await nm.getNotebook('doomed')
  t.is(meta, null)

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

test('rename notebook — moves notes to new slug', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('old-name', { name: 'Old Name' })
  const note = await nm.createNote('old-name', { title: 'My Note', content: 'Content' })

  const renamed = await nm.renameNotebook('old-name', 'new-name')
  t.is(renamed.slug, 'new-name')

  // Old is gone
  const oldMeta = await nm.getNotebook('old-name')
  t.is(oldMeta, null)

  // Note accessible at new location
  const movedNote = await nm.getNote('new-name', note.id)
  t.ok(movedNote)
  t.is(movedNote.title, 'My Note')

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

// ==================== NOTE TESTS ====================

test('create note — writes JSON with id, title, content', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  const note = await nm.createNote('test', { title: 'Hello World', content: '# Hello\n\nWorld', tags: ['greeting'] })

  t.ok(note.id)
  t.is(note.id.length, 16, 'id is 16 hex chars')
  t.is(note.title, 'Hello World')
  t.is(note.content, '# Hello\n\nWorld')
  t.alike(note.tags, ['greeting'])
  t.ok(note.createdAt)
  t.ok(note.updatedAt)

  // noteCount bumped
  const meta = await nm.getNotebook('test')
  t.is(meta.noteCount, 1)

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

test('create note — rejects missing title', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })

  try {
    await nm.createNote('test', { content: 'No title here' })
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.name, 'ValidationError')
    t.is(err.field, 'title')
  }

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

test('create note — rejects nonexistent notebook', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  try {
    await nm.createNote('ghost', { title: 'Orphan' })
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.name, 'NotFoundError')
  }

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

test('list notes — returns notes sorted by updatedAt desc', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })

  const n1 = await nm.createNote('test', { title: 'First' })
  await new Promise(r => setTimeout(r, 10))
  const n2 = await nm.createNote('test', { title: 'Second' })
  await new Promise(r => setTimeout(r, 10))
  const n3 = await nm.createNote('test', { title: 'Third' })

  const notes = await nm.listNotes('test')
  t.is(notes.length, 3)
  // Most recent first
  t.is(notes[0].title, 'Third')
  t.is(notes[1].title, 'Second')
  t.is(notes[2].title, 'First')

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

test('list notes — supports limit and offset', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  for (let i = 0; i < 5; i++) {
    await nm.createNote('test', { title: `Note ${i}` })
    await new Promise(r => setTimeout(r, 10))
  }

  const page1 = await nm.listNotes('test', { limit: 2 })
  t.is(page1.length, 2)

  const page2 = await nm.listNotes('test', { limit: 2, offset: 2 })
  t.is(page2.length, 2)

  const page3 = await nm.listNotes('test', { limit: 2, offset: 4 })
  t.is(page3.length, 1)

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

test('get note — returns full note, null for missing', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  const created = await nm.createNote('test', { title: 'Find Me', content: 'Here I am' })

  const found = await nm.getNote('test', created.id)
  t.ok(found)
  t.is(found.title, 'Find Me')
  t.is(found.content, 'Here I am')

  const missing = await nm.getNote('test', 'deadbeef00000000')
  t.is(missing, null)

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

test('update note — updates content and title', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  const created = await nm.createNote('test', { title: 'Original', content: 'Old content' })

  await new Promise(r => setTimeout(r, 10))

  const updated = await nm.updateNote('test', created.id, { title: 'Updated', content: 'New content', tags: ['edited'] })
  t.is(updated.title, 'Updated')
  t.is(updated.content, 'New content')
  t.alike(updated.tags, ['edited'])
  t.ok(updated.updatedAt > created.updatedAt)

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

test('delete note — removes note and decrements noteCount', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  const n1 = await nm.createNote('test', { title: 'Keep' })
  const n2 = await nm.createNote('test', { title: 'Delete Me' })

  let meta = await nm.getNotebook('test')
  t.is(meta.noteCount, 2)

  const result = await nm.deleteNote('test', n2.id)
  t.ok(result.deleted)

  const gone = await nm.getNote('test', n2.id)
  t.is(gone, null)

  meta = await nm.getNotebook('test')
  t.is(meta.noteCount, 1)

  // Other note still exists
  const kept = await nm.getNote('test', n1.id)
  t.ok(kept)

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

test('move note — moves between notebooks', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('source', { name: 'Source' })
  await nm.createNotebook('target', { name: 'Target' })

  const note = await nm.createNote('source', { title: 'Moving Note', content: 'Travel' })

  const moved = await nm.moveNote('source', note.id, 'target')
  t.is(moved.title, 'Moving Note')

  // Gone from source
  const fromSource = await nm.getNote('source', note.id)
  t.is(fromSource, null)

  // Present in target
  const fromTarget = await nm.getNote('target', note.id)
  t.ok(fromTarget)
  t.is(fromTarget.title, 'Moving Note')

  // Note counts updated
  const srcMeta = await nm.getNotebook('source')
  const tgtMeta = await nm.getNotebook('target')
  t.is(srcMeta.noteCount, 0)
  t.is(tgtMeta.noteCount, 1)

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

// ==================== SEARCH TESTS ====================

test('search — finds by title substring', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  await nm.createNote('test', { title: 'Meeting Notes', content: 'Agenda items' })
  await nm.createNote('test', { title: 'Shopping List', content: 'Milk, eggs' })

  const results = await nm.searchNotes('Meeting')
  t.is(results.length, 1)
  t.is(results[0].title, 'Meeting Notes')
  t.ok(results[0].snippet)

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

test('search — finds by content substring', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  await nm.createNote('test', { title: 'Recipe', content: 'Add butter and sugar to the bowl' })
  await nm.createNote('test', { title: 'Todo', content: 'Buy new shoes' })

  const results = await nm.searchNotes('butter')
  t.is(results.length, 1)
  t.is(results[0].title, 'Recipe')
  t.ok(results[0].snippet.includes('butter'))

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

test('search — case insensitive', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('test', { name: 'Test' })
  await nm.createNote('test', { title: 'JavaScript Guide', content: 'Learn JS' })

  const results = await nm.searchNotes('javascript')
  t.is(results.length, 1)

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

test('search — with notebook filter', async (t) => {
  const { store, registry, dm, dir } = tmpSetup()
  await registry.ready()
  const nm = new NoteManager(dm)

  await nm.createNotebook('work', { name: 'Work' })
  await nm.createNotebook('personal', { name: 'Personal' })
  await nm.createNote('work', { title: 'Work Meeting', content: 'Discuss project' })
  await nm.createNote('personal', { title: 'Personal Meeting', content: 'Meet friends' })

  // Search all notebooks
  const all = await nm.searchNotes('Meeting')
  t.is(all.length, 2)

  // Search specific notebook
  const workOnly = await nm.searchNotes('Meeting', { notebook: 'work' })
  t.is(workOnly.length, 1)
  t.is(workOnly[0].notebook, 'work')

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