OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / notebook-apply.test.js

292 lines · javascript ref:
import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import os from 'bare-os'
import path from 'bare-path'
import { randomBytes } from 'hypercore-crypto'
import * as Y from 'yjs'
import { notebookApply, encodeOp, decodeOp } from '../lib/multiwriter/notebook-apply.js'

function createStore () {
  const tmp = path.join(os.tmpdir(), 'hypernote-test-' + randomBytes(16).toString('hex'))
  return new Corestore(tmp)
}

async function createNotebook (store) {
  const base = new Autobase(store, {
    open: (store) => {
      const core = store.get('view', { valueEncoding: 'json' })
      return new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
    },
    apply: notebookApply,
    valueEncoding: 'binary'
  })
  await base.ready()
  return base
}

test('notebook-apply: create-note', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  await base.append(encodeOp({
    type: 'create-note',
    noteId: 'note-1',
    title: 'My First Note',
    author: 'user-1',
    tags: ['test', 'demo'],
    timestamp: Date.now()
  }))
  await base.update()

  const entry = await base.view.get('note:note-1')
  t.ok(entry, 'note exists in view')
  t.is(entry.value.title, 'My First Note')
  t.is(entry.value.author, 'user-1')
  t.alike(entry.value.tags, ['test', 'demo'])

  await base.close()
  await store.close()
})

test('notebook-apply: update-note-title', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  await base.append(encodeOp({
    type: 'create-note',
    noteId: 'note-1',
    title: 'Original',
    author: 'user-1',
    timestamp: Date.now()
  }))
  await base.update()

  await base.append(encodeOp({
    type: 'update-note-title',
    noteId: 'note-1',
    title: 'Updated Title',
    timestamp: Date.now()
  }))
  await base.update()

  const entry = await base.view.get('note:note-1')
  t.is(entry.value.title, 'Updated Title')

  await base.close()
  await store.close()
})

test('notebook-apply: delete-note', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  await base.append(encodeOp({
    type: 'create-note',
    noteId: 'note-1',
    title: 'To Delete',
    author: 'user-1',
    timestamp: Date.now()
  }))
  await base.update()

  const before = await base.view.get('note:note-1')
  t.ok(before, 'note exists before delete')

  await base.append(encodeOp({
    type: 'delete-note',
    noteId: 'note-1',
    timestamp: Date.now()
  }))
  await base.update()

  const after = await base.view.get('note:note-1')
  t.absent(after, 'note gone after delete')

  await base.close()
  await store.close()
})

test('notebook-apply: yjs-update roundtrip', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  // Create a Y.Doc and generate an update
  const doc = new Y.Doc()
  doc.getText('content').insert(0, 'Hello world')
  const update = Y.encodeStateAsUpdate(doc)
  const updateB64 = Buffer.from(update).toString('base64')

  await base.append(encodeOp({
    type: 'create-note',
    noteId: 'note-1',
    title: 'Yjs Note',
    author: 'user-1',
    timestamp: Date.now()
  }))

  await base.append(encodeOp({
    type: 'yjs-update',
    noteId: 'note-1',
    update: updateB64,
    author: 'user-1',
    timestamp: Date.now()
  }))
  await base.update()

  // Verify update stored at seq 0
  const entry = await base.view.get('yjs:note-1:0')
  t.ok(entry, 'yjs update stored')
  t.is(entry.value.author, 'user-1')

  // Reconstruct on another Y.Doc
  const doc2 = new Y.Doc()
  const updateBuf = Buffer.from(entry.value.update, 'base64')
  Y.applyUpdate(doc2, new Uint8Array(updateBuf))
  t.is(doc2.getText('content').toString(), 'Hello world', 'yjs content reconstructed')

  await base.close()
  await store.close()
})

test('notebook-apply: multiple yjs-updates get sequential keys', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  await base.append(encodeOp({
    type: 'create-note',
    noteId: 'note-1',
    title: 'Seq Note',
    author: 'user-1',
    timestamp: Date.now()
  }))

  for (let i = 0; i < 3; i++) {
    const doc = new Y.Doc()
    doc.getText('content').insert(0, `Update ${i}`)
    const update = Y.encodeStateAsUpdate(doc)

    await base.append(encodeOp({
      type: 'yjs-update',
      noteId: 'note-1',
      update: Buffer.from(update).toString('base64'),
      author: 'user-1',
      timestamp: Date.now()
    }))
    await base.update()
  }

  const e0 = await base.view.get('yjs:note-1:0')
  const e1 = await base.view.get('yjs:note-1:1')
  const e2 = await base.view.get('yjs:note-1:2')

  t.ok(e0, 'seq 0 exists')
  t.ok(e1, 'seq 1 exists')
  t.ok(e2, 'seq 2 exists')

  const counter = await base.view.get('yjs-counter:note-1')
  t.is(counter.value.seq, 3, 'counter at 3')

  await base.close()
  await store.close()
})

test('notebook-apply: add-media', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  await base.append(encodeOp({
    type: 'add-media',
    noteId: 'note-1',
    hash: 'abc123def456',
    mimeType: 'image/png',
    filename: 'screenshot.png',
    size: 12345,
    author: 'user-1',
    timestamp: Date.now()
  }))
  await base.update()

  const entry = await base.view.get('media:note-1:abc123def456')
  t.ok(entry, 'media entry exists')
  t.is(entry.value.mimeType, 'image/png')
  t.is(entry.value.filename, 'screenshot.png')
  t.is(entry.value.size, 12345)

  await base.close()
  await store.close()
})

test('notebook-apply: remove-media', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  await base.append(encodeOp({
    type: 'add-media',
    noteId: 'note-1',
    hash: 'abc123',
    mimeType: 'image/png',
    filename: 'pic.png',
    size: 100,
    author: 'user-1',
    timestamp: Date.now()
  }))
  await base.update()

  const before = await base.view.get('media:note-1:abc123')
  t.ok(before, 'media exists before remove')

  await base.append(encodeOp({
    type: 'remove-media',
    noteId: 'note-1',
    hash: 'abc123',
    timestamp: Date.now()
  }))
  await base.update()

  const after = await base.view.get('media:note-1:abc123')
  t.absent(after, 'media gone after remove')

  await base.close()
  await store.close()
})

test('notebook-apply: yjs-snapshot', async (t) => {
  const store = createStore()
  const base = await createNotebook(store)

  const doc = new Y.Doc()
  doc.getText('content').insert(0, 'Snapshot content')
  const state = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64')

  await base.append(encodeOp({
    type: 'yjs-snapshot',
    noteId: 'note-1',
    state,
    timestamp: Date.now()
  }))
  await base.update()

  const entry = await base.view.get('yjs-snapshot:note-1')
  t.ok(entry, 'snapshot stored')

  // Verify reconstruction
  const doc2 = new Y.Doc()
  Y.applyUpdate(doc2, new Uint8Array(Buffer.from(entry.value.state, 'base64')))
  t.is(doc2.getText('content').toString(), 'Snapshot content')

  await base.close()
  await store.close()
})

test('notebook-apply: encodeOp / decodeOp roundtrip', async (t) => {
  const op = { type: 'create-note', noteId: 'n1', title: 'Test', timestamp: 123 }
  const encoded = encodeOp(op)
  t.ok(Buffer.isBuffer(encoded))
  const decoded = decodeOp(encoded)
  t.is(decoded.type, 'create-note')
  t.is(decoded.noteId, 'n1')
  t.is(decoded.title, 'Test')
})