OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / node-rooms.test.js

130 lines · javascript ref:
// A node holds rooms, the way it holds feeds.
//
// ⛔ One Corestore namespace PER ROOM. Two Autobees sharing one derive the same local writer and
// DEADLOCK rather than erroring (node.js header, measured 2026-09-06) — and a node is expected to
// hold many rooms at once, so this is the case that would hit it.
//
// ⚠ A room key is a CAPABILITY. Holding it is what lets you replicate the room at all, so the list
// of rooms this node is in is local state, not something derived from anyone else's log.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { FavelaNode } from '../lib/node.js'
import { saveCredential } from '../lib/device.js'
import { identity } from './helpers.js'
import { ADMIN, PEER } from '../lib/room.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-nr-'))
const stub = () => ({ joined: [], async join (b) { this.joined.push(b) }, async leave () {}, async destroy () {} })

async function node (storage = tmp()) {
  const id = await identity()
  const cred = await id.device()
  saveCredential(storage, cred)
  const transport = stub()
  const n = new FavelaNode(storage, { transport })
  await n.ready()
  return { n, cred, root: id.rootHex, storage, transport }
}

test('★ creating a room founds it and the creator is its admin', async (t) => {
  const { n, root } = await node()
  t.teardown(() => n.close())

  const { key } = await n.createRoom()
  t.is(key.length, 64)
  const room = n.room(key)
  t.is(await room.roleOf(root), ADMIN, 'the creator, by identity')
  t.alike((await n.rooms()).map((r) => r.key), [key])
})

test('rooms survive a restart, and their logs come back', async (t) => {
  const { n, storage, cred } = await node()
  const { key } = await n.createRoom()
  await n.room(key).setConfig('title', 'Berlin', cred)
  await n.room(key).update()
  await n.close()

  const again = new FavelaNode(storage, { transport: stub() })
  await again.ready()
  t.teardown(() => again.close())
  t.alike((await again.rooms()).map((r) => r.key), [key], 'reopened from disk')
  t.is((await again.room(key).config()).title, 'Berlin')
})

test('⛔ every room is ANNOUNCED, or it cannot replicate', async (t) => {
  const { n, transport } = await node()
  t.teardown(() => n.close())
  const { key } = await n.createRoom()
  // The same defect the namespace had: an autobee nobody announces is a private log wearing the
  // name of a shared one.
  t.ok(transport.joined.some((b) => b && b.key && b.key.toString('hex') === key))
})

test('⛔⛔ a room REOPENED at boot is announced too — the case the daemon actually has', async (t) => {
  const { n, storage } = await node()
  const { key } = await n.createRoom()
  await n.close()

  // ⚠ This is where the previous test lied. It injects a transport at CONSTRUCTION, and the real
  // daemon cannot: `ready()` builds the overlay and the transport needs the overlay, so at boot
  // `this.transport` is null and every join inside ready() is a no-op. The room came back from
  // disk, was never announced, and replicated for nobody — while the test passed.
  const again = new FavelaNode(storage, { transport: null })
  await again.ready()
  const transport = stub()
  again.transport = transport
  await again.announceAll()
  t.teardown(() => again.close())

  t.ok(transport.joined.some((b) => b && b.key && b.key.toString('hex') === key),
    'announceAll covers what ready() opened and could not announce')
  t.ok(transport.joined.some((b) => b === again.names), 'the namespace as well')
  t.ok(transport.joined.some((b) => b === again.peer.feed), 'and the feed')
})

test('⛔ two rooms open at once do not deadlock', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  const a = await n.createRoom()
  const b = await n.createRoom()
  t.not(a.key, b.key)
  // Two Autobees on ONE Corestore namespace derive the same local writer and hang — not error,
  // hang. A node holding several rooms is exactly where that would land.
  t.is((await n.rooms()).length, 2)
  t.ok(await n.room(a.key).founder())
  t.ok(await n.room(b.key).founder())
})

test('joining by key opens a room this node did not create', async (t) => {
  const host = await node()
  const guest = await node()
  t.teardown(async () => { await host.n.close(); await guest.n.close() })
  const { key } = await host.n.createRoom()

  await guest.n.joinRoom(key)
  t.alike((await guest.n.rooms()).map((r) => r.key), [key])
  // ⚠ Joined is not the same as admitted. Holding the key lets you replicate and read; being a
  // member is a decision somebody inside makes.
  t.is(await guest.n.room(key).roleOf(guest.root), null, 'in the room, not of it')
})

test('⛔ a malformed room key is refused', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  await t.exception(n.joinRoom('nope'), /32 bytes of hex|room key/)
})

test('leaving closes the room and forgets it, without destroying it', async (t) => {
  const { n, transport } = await node()
  t.teardown(() => n.close())
  const { key } = await n.createRoom()
  await n.leaveRoom(key)
  t.alike(await n.rooms(), [], 'gone from this node')
  // ⚠ Leaving stops replicating. It does not delete a room anybody else is in, and it cannot —
  // the same shape as unfollowing and delisting.
  t.ok(transport.joined.length >= 1)
})