OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms / test / room.test.js

311 lines · javascript ref:
import test from 'brittle'
import Corestore from 'corestore'
import b4a from 'b4a'
import { replicateAndSync } from 'autobase-test-helpers'
import { Room, jsonSchema } from '../index.js'

// ── Test fixture: a tiny "todos" schema ──

const todosSchema = jsonSchema({
  async apply (batch, op, host) {
    if (op.type === 'add-todo') {
      await batch.put(`todo:${op.id}`, {
        id: op.id,
        text: op.text,
        done: false,
        author: op.author || null,
        createdAt: op.timestamp || Date.now()
      })
    } else if (op.type === 'check-todo') {
      const entry = await batch.get(`todo:${op.id}`)
      if (entry) {
        await batch.put(`todo:${op.id}`, { ...entry.value, done: true })
      }
    } else if (op.type === 'delete-todo') {
      await batch.del(`todo:${op.id}`)
    }
  },
  validate (op) {
    if (op.type === 'add-todo') {
      if (typeof op.id !== 'number') throw new Error('add-todo: id must be number')
      if (typeof op.text !== 'string') throw new Error('add-todo: text must be string')
    }
  }
})

async function makeRoom (t, opts = {}) {
  const dir = '/tmp/hyper-rooms-test-' + Math.random().toString(36).slice(2)
  const store = new Corestore(dir)
  await store.ready()
  const room = new Room(store, { schema: todosSchema, namespace: 'test', ...opts })
  await room.ready()
  t.teardown(async () => {
    try { await room.close() } catch {}
    try { await store.close() } catch {}
  })
  return { room, store, dir }
}

// ── Single-writer (basic) ──────────────────────────────────────

test('single-writer: append → view round-trip', async (t) => {
  const { room } = await makeRoom(t)

  await room.append({ type: 'add-todo', id: 1, text: 'buy milk' })
  await room.append({ type: 'add-todo', id: 2, text: 'walk dog' })
  await room.append({ type: 'check-todo', id: 1 })
  await room.base.update()

  const t1 = await room.view.get('todo:1')
  const t2 = await room.view.get('todo:2')

  t.is(t1.value.text, 'buy milk')
  t.is(t1.value.done, true, 'check-todo applied')
  t.is(t2.value.text, 'walk dog')
  t.is(t2.value.done, false)
})

test('append + delete', async (t) => {
  const { room } = await makeRoom(t)

  await room.append({ type: 'add-todo', id: 1, text: 'temporary' })
  await room.append({ type: 'delete-todo', id: 1 })
  await room.base.update()

  const t1 = await room.view.get('todo:1')
  t.absent(t1, 'deleted')
})

test('schema validation rejects bad ops', async (t) => {
  const { room } = await makeRoom(t)

  await t.exception(
    room.append({ type: 'add-todo', text: 'no id' }),
    /id must be number/
  )

  await t.exception(
    room.append({ type: 'add-todo', id: 1, text: 42 }),
    /text must be string/
  )

  await t.exception(
    room.append({ }), // no type
    /op\.type must be a string/
  )
})

test('append rejects writer ops directly', async (t) => {
  const { room } = await makeRoom(t)
  await t.exception(
    room.append({ type: 'add-writer', userId: 'x' }),
    /Use addWriter/
  )
})

test('persistence across reopen', async (t) => {
  const dir = '/tmp/hyper-rooms-persist-' + Math.random().toString(36).slice(2)

  const store1 = new Corestore(dir)
  await store1.ready()
  const room1 = new Room(store1, { schema: todosSchema, namespace: 'test' })
  await room1.ready()

  const key = room1.keyHex
  await room1.append({ type: 'add-todo', id: 99, text: 'persistent' })
  await room1.base.update()

  await room1.close()
  await store1.close()

  // Reopen
  const store2 = new Corestore(dir)
  await store2.ready()
  const room2 = new Room(store2, { schema: todosSchema, namespace: 'test' })
  await room2.ready()

  t.is(room2.keyHex, key, 'same room key after reopen')

  const entry = await room2.view.get('todo:99')
  t.ok(entry, 'todo persisted')
  t.is(entry.value.text, 'persistent')

  await room2.close()
  await store2.close()
})

// ── Writer management ─────────────────────────────────────────

test('addWriter / listWriters / removeWriter', async (t) => {
  const { room } = await makeRoom(t)

  // Use a fake writer key (32 bytes hex)
  const aliceKey = '11'.repeat(32)
  const bobKey = '22'.repeat(32)

  await room.addWriter('alice', { writerKey: aliceKey, role: 'owner' })
  await room.addWriter('bob', { writerKey: bobKey, role: 'writer' })
  await room.base.update()

  const writers = await room.listWriters()
  t.is(writers.length, 2)
  t.ok(writers.find(w => w.userId === 'alice' && w.role === 'owner'))
  t.ok(writers.find(w => w.userId === 'bob' && w.role === 'writer'))

  const alice = await room.getWriter('alice')
  t.is(alice.role, 'owner')
  t.is(alice.writerKey, aliceKey)

  await room.removeWriter('bob')
  await room.base.update()

  const after = await room.listWriters()
  t.is(after.length, 1)
  t.is(after[0].userId, 'alice')
})

test('addWriter rejects bad role', async (t) => {
  const { room } = await makeRoom(t)
  await t.exception(
    room.addWriter('x', { writerKey: '11'.repeat(32), role: 'admin' }),
    /role must be/
  )
})

test('addWriter requires writerKey', async (t) => {
  const { room } = await makeRoom(t)
  await t.exception(
    room.addWriter('x', { role: 'writer' }),
    /requires opts\.writerKey/
  )
})

// ── Multi-writer replication ──────────────────────────────────

test('two rooms can replicate via stream pair', async (t) => {
  // Room A creates the room, room B joins via the bootstrap key.
  // Uses the canonical autobase replication pattern: base.replicate() +
  // replicateAndSync() helper that polls until heads converge.
  const dirA = '/tmp/hyper-rooms-mwA-' + Math.random().toString(36).slice(2)
  const dirB = '/tmp/hyper-rooms-mwB-' + Math.random().toString(36).slice(2)

  const storeA = new Corestore(dirA)
  const storeB = new Corestore(dirB)
  await storeA.ready()
  await storeB.ready()

  // A creates the room
  const roomA = new Room(storeA, { schema: todosSchema, namespace: 'shared' })
  await roomA.ready()
  const bootstrapKey = roomA.keyHex

  // A appends a todo
  await roomA.append({ type: 'add-todo', id: 1, text: 'from A' })
  await roomA.base.update()

  // B joins the same room via the bootstrap key
  const roomB = new Room(storeB, {
    schema: todosSchema,
    namespace: 'shared',
    bootstrapKey
  })
  await roomB.ready()

  // Sync both bases using the canonical helper.
  // (This sets up replication streams and polls until heads converge.)
  await replicateAndSync([roomA.base, roomB.base])

  // B should now see A's todo
  const entry = await roomB.view.get('todo:1')
  t.ok(entry, 'B received A\'s todo via replication')
  t.is(entry.value.text, 'from A')

  t.teardown(async () => {
    try { await roomA.close() } catch {}
    try { await roomB.close() } catch {}
    try { await storeA.close() } catch {}
    try { await storeB.close() } catch {}
  })
})

test('two writers: B added by A, both append, both see all', async (t) => {
  // Full multi-writer test: A adds B as a writer, B appends, both see both.
  const dirA = '/tmp/hyper-rooms-2wA-' + Math.random().toString(36).slice(2)
  const dirB = '/tmp/hyper-rooms-2wB-' + Math.random().toString(36).slice(2)

  const storeA = new Corestore(dirA)
  const storeB = new Corestore(dirB)
  await storeA.ready()
  await storeB.ready()

  const roomA = new Room(storeA, { schema: todosSchema, namespace: 'shared' })
  await roomA.ready()
  const bootstrapKey = roomA.keyHex

  const roomB = new Room(storeB, {
    schema: todosSchema,
    namespace: 'shared',
    bootstrapKey
  })
  await roomB.ready()

  // First sync so B is connected and knows about the room structure
  await replicateAndSync([roomA.base, roomB.base])

  // A adds B as a writer (using B's local writer key)
  await roomA.addWriter('bob', { writerKey: roomB.localKeyHex, role: 'writer' })
  await replicateAndSync([roomA.base, roomB.base])

  // B should now be writable
  t.ok(roomB.writable, 'B is now writable after addWriter')

  // Both append
  await roomA.append({ type: 'add-todo', id: 1, text: 'from A' })
  await roomB.append({ type: 'add-todo', id: 2, text: 'from B' })

  // Sync and verify both see both
  await replicateAndSync([roomA.base, roomB.base])

  const entry1A = await roomA.view.get('todo:1')
  const entry2A = await roomA.view.get('todo:2')
  const entry1B = await roomB.view.get('todo:1')
  const entry2B = await roomB.view.get('todo:2')

  t.is(entry1A.value.text, 'from A', 'A sees own todo')
  t.is(entry2A.value.text, 'from B', 'A sees B\'s todo')
  t.is(entry1B.value.text, 'from A', 'B sees A\'s todo')
  t.is(entry2B.value.text, 'from B', 'B sees own todo')

  // Both should also see the writer entry
  const writers = await roomA.listWriters()
  t.ok(writers.find(w => w.userId === 'bob'), 'A has bob in writers')

  t.teardown(async () => {
    try { await roomA.close() } catch {}
    try { await roomB.close() } catch {}
    try { await storeA.close() } catch {}
    try { await storeB.close() } catch {}
  })
})

// ── jsonSchema helper ─────────────────────────────────────────

test('jsonSchema requires an apply function', (t) => {
  t.exception(() => jsonSchema({}), /requires an apply function/)
})

test('jsonSchema validateOp catches non-object ops', async (t) => {
  const schema = jsonSchema({ apply: async () => {} })
  t.exception(() => schema.validateOp(null), /op must be an object/)
  t.exception(() => schema.validateOp({}), /op\.type must be a string/)
})

test('Room rejects bad schema', (t) => {
  const store = new Corestore('/tmp/hyper-rooms-bad-' + Math.random().toString(36).slice(2))
  t.exception(
    () => new Room(store, { schema: { encodeOp: () => {} } }),
    /Schema missing required function/
  )
  t.exception(() => new Room(store, {}), /Room requires a schema/)
})