OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / metadata-base.test.mjs

663 lines · javascript ref:
/**
 * metadata-base.test.mjs — Tests for the MetadataBase Autobase smart contract
 *
 * Run: bare test/metadata-base.test.mjs
 *
 * Tests the core Autobase module in isolation (no keet-core dependency).
 * Uses tmp directories for storage, cleaned up on completion.
 */

import { MetadataBase, mergeFields } from '../lib/metadata-base.js'
import { RoomMeta, parseHsMessage, createHsMessage } from '../lib/room-meta.js'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'

const tmpBase = path.join(os.tmpdir(), 'metadata-test-' + Date.now())
let testCount = 0
let passCount = 0
let failCount = 0

function assert (condition, msg) {
  testCount++
  if (condition) {
    passCount++
    console.log(`  PASS: ${msg}`)
  } else {
    failCount++
    console.error(`  FAIL: ${msg}`)
  }
}

function assertEqual (actual, expected, msg) {
  testCount++
  const pass = JSON.stringify(actual) === JSON.stringify(expected)
  if (pass) {
    passCount++
    console.log(`  PASS: ${msg}`)
  } else {
    failCount++
    console.error(`  FAIL: ${msg}`)
    console.error(`    expected: ${JSON.stringify(expected)}`)
    console.error(`    actual:   ${JSON.stringify(actual)}`)
  }
}

function tmpDir (name) {
  const dir = path.join(tmpBase, name)
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

function rmDir (dir) {
  try {
    fs.rmSync(dir, { recursive: true, force: true })
  } catch {
    // Best effort cleanup
  }
}

// ── Test: mergeFields helper ─────────────────────────────────

console.log('\n--- mergeFields ---')

{
  const result = mergeFields(null, { topics: ['rust'], discoverable: true }, 100)
  assertEqual(result.topics, ['rust'], 'merge into null: topics set')
  assertEqual(result.discoverable, true, 'merge into null: discoverable set')
  assertEqual(result._fieldTs.topics, 100, 'merge into null: field timestamp recorded')
}

{
  const existing = {
    topics: ['rust'],
    discoverable: true,
    _fieldTs: { topics: 100, discoverable: 100 }
  }
  const result = mergeFields(existing, { topics: ['go', 'p2p'] }, 200)
  assertEqual(result.topics, ['go', 'p2p'], 'newer timestamp wins: topics updated')
  assertEqual(result.discoverable, true, 'unchanged field preserved')
  assertEqual(result._fieldTs.topics, 200, 'field timestamp updated')
}

{
  const existing = {
    topics: ['rust'],
    discoverable: true,
    _fieldTs: { topics: 200, discoverable: 100 }
  }
  const result = mergeFields(existing, { topics: ['old'] }, 150)
  assertEqual(result.topics, ['rust'], 'older timestamp loses: topics unchanged')
  assertEqual(result._fieldTs.topics, 200, 'field timestamp unchanged')
}

{
  const existing = {
    topics: ['rust'],
    type: 'chat',
    _fieldTs: { topics: 100, type: 100 }
  }
  const result = mergeFields(existing, { type: null }, 200)
  assertEqual(result.type, undefined, 'null value deletes field')
  assertEqual(result.topics, ['rust'], 'other fields preserved')
}

// ── Test: MetadataBase lifecycle ─────────────────────────────

console.log('\n--- MetadataBase lifecycle ---')

{
  const dir = tmpDir('lifecycle')
  const mb = new MetadataBase(dir, { network: false })

  assert(!mb._ready, 'not ready before ready()')

  await mb.ready()
  assert(mb._ready, 'ready after ready()')
  assert(mb.key !== null, 'has bootstrap key')
  assert(mb.localKey !== null, 'has local writer key')
  assert(mb.writable, 'is writable (creator is always writer)')

  // Key persisted to disk
  const savedKey = fs.readFileSync(path.join(dir, 'metadata-key'), 'utf8').trim()
  assertEqual(savedKey, mb.key.toString('hex'), 'key persisted to disk')

  await mb.close()
  assert(!mb._ready, 'not ready after close()')

  rmDir(dir)
}

// ── Test: Key persistence across restarts ────────────────────

console.log('\n--- Key persistence ---')

{
  const dir = tmpDir('persistence')

  // First run: create new
  const mb1 = new MetadataBase(dir, { network: false })
  await mb1.ready()
  const key1 = mb1.key.toString('hex')
  await mb1.close()

  // Second run: should load same key
  const mb2 = new MetadataBase(dir, { network: false })
  await mb2.ready()
  const key2 = mb2.key.toString('hex')
  assertEqual(key2, key1, 'same key after restart')
  await mb2.close()

  rmDir(dir)
}

// ── Test: Basic get/set ──────────────────────────────────────

console.log('\n--- Basic get/set ---')

{
  const dir = tmpDir('basic')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()

  // Set room metadata
  await mb.set('room', 'abc123', { topics: ['rust', 'p2p'], discoverable: true })

  // Get it back
  const result = await mb.get('room', 'abc123')
  assertEqual(result.topics, ['rust', 'p2p'], 'get returns topics')
  assertEqual(result.discoverable, true, 'get returns discoverable')
  assert(result._ts > 0, 'has timestamp')
  assert(result._by !== undefined, 'has writer attribution')

  // Get nonexistent
  const missing = await mb.get('room', 'nonexistent')
  assertEqual(missing, null, 'nonexistent returns null')

  await mb.close()
  rmDir(dir)
}

// ── Test: Field-level merge ──────────────────────────────────

console.log('\n--- Field-level merge ---')

{
  const dir = tmpDir('merge')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()

  // Set initial metadata
  await mb.set('room', 'abc', { topics: ['rust'], type: 'chat' })

  // Update only discoverable (topics should be preserved)
  await mb.set('room', 'abc', { discoverable: true })

  const result = await mb.get('room', 'abc')
  assertEqual(result.topics, ['rust'], 'original field preserved')
  assertEqual(result.type, 'chat', 'original field preserved')
  assertEqual(result.discoverable, true, 'new field added')

  // Delete a field with null
  await mb.set('room', 'abc', { type: null })

  const result2 = await mb.get('room', 'abc')
  assertEqual(result2.type, undefined, 'null deletes field')
  assertEqual(result2.topics, ['rust'], 'other fields preserved')

  await mb.close()
  rmDir(dir)
}

// ── Test: Namespace extensibility ────────────────────────────

console.log('\n--- Namespace extensibility ---')

{
  const dir = tmpDir('namespaces')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()

  // Room metadata (known namespace)
  await mb.set('room', 'room1', { topics: ['gaming'] })

  // Bridge config (known namespace)
  await mb.append({
    ns: 'bridge',
    roomId: 'room1',
    platform: 'irc',
    config: { network: 'libera', channel: '#gaming' }
  })

  // Governance (known namespace)
  await mb.append({
    ns: 'gov',
    roomId: 'room1',
    rule: 'voting',
    params: { threshold: 0.66, quorum: 3 }
  })

  // Unknown namespace (forward compatibility — stored in default case)
  await mb.set('reputation', 'user1', { score: 4.2, reviews: 10 })

  // All are queryable
  const room = await mb.get('room', 'room1')
  assertEqual(room.topics, ['gaming'], 'room namespace works')

  const bridge = await mb.get('bridge', 'room1:irc')
  assertEqual(bridge.network, 'libera', 'bridge namespace works')

  const gov = await mb.get('gov', 'room1:voting')
  assertEqual(gov.threshold, 0.66, 'gov namespace works')

  const rep = await mb.get('reputation', 'user1')
  assertEqual(rep.score, 4.2, 'unknown namespace stored via default case')
  assertEqual(rep._ns, 'reputation', 'unknown namespace marked with _ns')

  await mb.close()
  rmDir(dir)
}

// ── Test: getAll ─────────────────────────────────────────────

console.log('\n--- getAll ---')

{
  const dir = tmpDir('getall')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()

  await mb.set('room', 'r1', { topics: ['a'] })
  await mb.set('room', 'r2', { topics: ['b'] })
  await mb.set('room', 'r3', { topics: ['c'] })

  const all = await mb.getAll('room')
  assert(Object.keys(all).length === 3, 'getAll returns 3 entries')
  assertEqual(all.r1.topics, ['a'], 'r1 correct')
  assertEqual(all.r2.topics, ['b'], 'r2 correct')
  assertEqual(all.r3.topics, ['c'], 'r3 correct')

  // Different namespace not included
  await mb.set('bridge', 'b1', { platform: 'irc' })
  const rooms = await mb.getAll('room')
  assert(Object.keys(rooms).length === 3, 'getAll scoped to namespace')

  await mb.close()
  rmDir(dir)
}

// ── Test: Topic search (secondary index) ─────────────────────

console.log('\n--- Topic search ---')

{
  const dir = tmpDir('search')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()

  await mb.set('room', 'r1', { topics: ['rust', 'p2p'] })
  await mb.set('room', 'r2', { topics: ['rust', 'gaming'] })
  await mb.set('room', 'r3', { topics: ['python', 'ml'] })

  const rustRooms = await mb.searchByTopic('rust')
  assertEqual(rustRooms.length, 2, 'found 2 rust rooms')
  const rustIds = rustRooms.map(r => r.roomId).sort()
  assertEqual(rustIds, ['r1', 'r2'], 'correct rooms for rust topic')

  const pythonRooms = await mb.searchByTopic('python')
  assertEqual(pythonRooms.length, 1, 'found 1 python room')
  assertEqual(pythonRooms[0].roomId, 'r3', 'correct room for python')

  const noRooms = await mb.searchByTopic('java')
  assertEqual(noRooms.length, 0, 'no rooms for unknown topic')

  // Update topics — old index entries should be cleaned
  await mb.set('room', 'r1', { topics: ['go'] })
  const rustAfter = await mb.searchByTopic('rust')
  assertEqual(rustAfter.length, 1, 'index updated: only 1 rust room after change')
  assertEqual(rustAfter[0].roomId, 'r2', 'correct remaining rust room')

  const goRooms = await mb.searchByTopic('go')
  assertEqual(goRooms.length, 1, 'new topic indexed')
  assertEqual(goRooms[0].roomId, 'r1', 'correct room for new topic')

  await mb.close()
  rmDir(dir)
}

// ── Test: Delete operation ───────────────────────────────────

console.log('\n--- Delete ---')

{
  const dir = tmpDir('delete')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()

  await mb.set('room', 'r1', { topics: ['rust'] })
  const before = await mb.get('room', 'r1')
  assert(before !== null, 'entry exists before delete')

  await mb.del('room:r1')
  const after = await mb.get('room', 'r1')
  assertEqual(after, null, 'entry gone after delete')

  await mb.close()
  rmDir(dir)
}

// ── Test: List prefix query ──────────────────────────────────

console.log('\n--- List prefix ---')

{
  const dir = tmpDir('list')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()

  await mb.append({ ns: 'bridge', roomId: 'r1', platform: 'irc', config: { ch: '#a' } })
  await mb.append({ ns: 'bridge', roomId: 'r1', platform: 'discord', config: { ch: 'b' } })
  await mb.append({ ns: 'bridge', roomId: 'r2', platform: 'irc', config: { ch: '#c' } })

  const r1Bridges = await mb.list('bridge:r1:')
  assertEqual(r1Bridges.length, 2, 'list returns 2 bridges for r1')

  const allBridges = await mb.list('bridge:')
  assertEqual(allBridges.length, 3, 'list returns all 3 bridges')

  await mb.close()
  rmDir(dir)
}

// ── Test: Data persists across restart ───────────────────────

console.log('\n--- Data persistence ---')

{
  const dir = tmpDir('persist-data')

  // Write data
  const mb1 = new MetadataBase(dir, { network: false })
  await mb1.ready()
  await mb1.set('room', 'persist-test', { topics: ['durable'], discoverable: true })
  await mb1.close()

  // Read after restart
  const mb2 = new MetadataBase(dir, { network: false })
  await mb2.ready()
  const result = await mb2.get('room', 'persist-test')
  assertEqual(result.topics, ['durable'], 'data survives restart')
  assertEqual(result.discoverable, true, 'all fields survive restart')
  await mb2.close()

  rmDir(dir)
}

// ── Test: RoomMeta wrapper ───────────────────────────────────

console.log('\n--- RoomMeta wrapper ---')

{
  const dir = tmpDir('roommeta')
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()
  const rm = new RoomMeta(mb)

  // Set via RoomMeta
  await rm.set('abc', { topics: ['rust'], discoverable: true })

  // Get via RoomMeta (should strip internal fields)
  const result = await rm.get('abc')
  assertEqual(result.topics, ['rust'], 'RoomMeta.get returns topics')
  assertEqual(result.discoverable, true, 'RoomMeta.get returns discoverable')
  assertEqual(result._by, undefined, 'internal _by stripped')
  assertEqual(result._ts, undefined, 'internal _ts stripped')
  assertEqual(result._fieldTs, undefined, 'internal _fieldTs stripped')

  // GetAll via RoomMeta
  await rm.set('def', { topics: ['go'], type: 'registry' })
  const all = await rm.getAll()
  assert(Object.keys(all).length === 2, 'getAll returns 2 rooms')
  assertEqual(all.abc.topics, ['rust'], 'getAll abc correct')
  assertEqual(all.def.type, 'registry', 'getAll def correct')
  assertEqual(all.def._by, undefined, 'getAll strips internals')

  // Search via RoomMeta
  const rustRooms = await rm.searchByTopic('rust')
  assertEqual(rustRooms.length, 1, 'searchByTopic works through RoomMeta')

  // Get nonexistent
  const empty = await rm.get('nonexistent')
  assertEqual(JSON.stringify(empty), '{}', 'nonexistent returns {}')

  await mb.close()
  rmDir(dir)
}

// ── Test: parseHsMessage / createHsMessage helpers ───────────

console.log('\n--- _hs message helpers ---')

{
  const msg = createHsMessage('room-meta', { roomId: 'abc', topics: ['rust'] })
  assert(msg.startsWith('{"_hs":'), 'createHsMessage starts with _hs prefix')

  const parsed = parseHsMessage(msg)
  assertEqual(parsed._hs, 'room-meta', 'parseHsMessage extracts type')
  assertEqual(parsed.roomId, 'abc', 'parseHsMessage extracts roomId')
  assertEqual(parsed.topics, ['rust'], 'parseHsMessage extracts topics')

  // Invalid inputs
  assertEqual(parseHsMessage(null), null, 'null returns null')
  assertEqual(parseHsMessage('hello world'), null, 'non-hs returns null')
  assertEqual(parseHsMessage('{"_hs":broken'), null, 'invalid JSON returns null')
}

// ── Test: Legacy migration ───────────────────────────────────

console.log('\n--- Legacy migration ---')

{
  const dir = tmpDir('migration')
  const home = tmpDir('migration-home')

  // Create legacy room-meta.json
  const configDir = path.join(home, '.config', 'keet-cli')
  fs.mkdirSync(configDir, { recursive: true })
  const legacyData = {
    room1: { topics: ['rust'], discoverable: true, _syncTs: 123 },
    room2: { topics: ['go'], type: 'registry' }
  }
  fs.writeFileSync(path.join(configDir, 'room-meta.json'), JSON.stringify(legacyData))

  // Create MetadataBase + RoomMeta with legacy path
  const mb = new MetadataBase(dir, { network: false })
  await mb.ready()
  const rm = new RoomMeta(mb, { storagePath: home })

  // Run migration
  const { migrated } = await rm.migrateLegacy(null)
  assertEqual(migrated, 2, 'migrated 2 entries')

  // Verify data is in Autobase
  const r1 = await rm.get('room1')
  assertEqual(r1.topics, ['rust'], 'migrated room1 topics')
  assertEqual(r1.discoverable, true, 'migrated room1 discoverable')
  assertEqual(r1._syncTs, undefined, 'legacy _syncTs stripped')

  const r2 = await rm.get('room2')
  assertEqual(r2.type, 'registry', 'migrated room2 type')

  // Legacy file should be renamed
  const backupExists = fs.existsSync(path.join(configDir, 'room-meta.json.migrated'))
  assert(backupExists, 'legacy file renamed to .migrated')

  // Second migration is a no-op
  const { migrated: m2 } = await rm.migrateLegacy(null)
  assertEqual(m2, 0, 'second migration is no-op')

  await mb.close()
  rmDir(dir)
  rmDir(home)
}

// ── Test: Independent identity (no mnemonic derivation) ──────

console.log('\n--- Independent identity ---')

{
  const dir1 = tmpDir('indep-1')
  const dir2 = tmpDir('indep-2')

  // Two MetadataBases with different storage paths get different keys
  const mb1 = new MetadataBase(dir1, { network: false })
  await mb1.ready()

  const mb2 = new MetadataBase(dir2, { network: false })
  await mb2.ready()

  assert(mb1.key.toString('hex') !== mb2.key.toString('hex'), 'different storage → different Autobase key')
  assert(mb1.localKey.toString('hex') !== mb2.localKey.toString('hex'), 'local writer keys are unique per instance')

  // Both are writable (creators are always writers)
  assert(mb1.writable, 'instance 1 is writable')
  assert(mb2.writable, 'instance 2 is writable')

  await mb1.close()
  await mb2.close()

  rmDir(dir1)
  rmDir(dir2)
}

// ── Test: Key recovery from Corestore ─────────────────────────

console.log('\n--- Key recovery from Corestore ---')

{
  const dir = tmpDir('recovery')

  // Create MetadataBase, write data, close
  const mb1 = new MetadataBase(dir, { network: false })
  await mb1.ready()
  const originalKey = mb1.key.toString('hex')
  await mb1.set('room', 'recovery-test', { topics: ['test'] })
  await mb1.close()

  // Delete the metadata-key file (simulating file loss)
  const keyFile = path.join(dir, 'metadata-key')
  assert(fs.existsSync(keyFile), 'metadata-key file exists before delete')
  fs.unlinkSync(keyFile)
  assert(!fs.existsSync(keyFile), 'metadata-key file deleted')

  // Re-open — Autobase key should be re-derived from Corestore
  // (null bootstrapKey + no key file → Autobase creates from existing store)
  const mb2 = new MetadataBase(dir, { network: false })
  await mb2.ready()
  const recoveredKey = mb2.key.toString('hex')

  // Key file should be re-created
  assert(fs.existsSync(keyFile), 'metadata-key file re-created after recovery')

  // Note: without the key file, Autobase may create a NEW key from the store.
  // The important thing is that it starts up successfully and the key is persisted.
  assert(typeof recoveredKey === 'string' && recoveredKey.length === 64, 'recovered key is valid 64-char hex')
  assert(mb2.writable, 'recovered instance is writable')

  await mb2.close()
  rmDir(dir)
}

// ── Test: joinKey (device pairing) ────────────────────────────

console.log('\n--- joinKey (device pairing) ---')

{
  const dirA = tmpDir('pair-a')
  const dirB = tmpDir('pair-b')

  // Device A creates its MetadataBase and writes data
  const mbA = new MetadataBase(dirA, { network: false })
  await mbA.ready()
  const keyA = mbA.key.toString('hex')
  await mbA.set('room', 'shared-room', { topics: ['synced'], discoverable: true })

  // Device B creates its own (different key)
  const mbB = new MetadataBase(dirB, { network: false })
  await mbB.ready()
  const keyB = mbB.key.toString('hex')
  assert(keyA !== keyB, 'devices start with different keys')

  // Device B joins Device A's MetadataBase
  await mbB.joinKey(Buffer.from(keyA, 'hex'))
  assertEqual(mbB.key.toString('hex'), keyA, 'after joinKey, B has A\'s key')
  // Note: B is NOT writable without networking — Protomux writer exchange
  // (which adds B as an indexer) requires a live Hyperswarm connection.
  // In production with network: true, both peers become writable automatically.
  assert(!mbB.writable, 'B is not writable without network (expected — needs Protomux)')

  // Key file updated on disk
  const savedKey = fs.readFileSync(path.join(dirB, 'metadata-key'), 'utf8').trim()
  assertEqual(savedKey, keyA, 'metadata-key file updated to A\'s key')

  // joinKey with same key is a no-op (doesn't throw)
  await mbB.joinKey(Buffer.from(keyA, 'hex'))
  assertEqual(mbB.key.toString('hex'), keyA, 'joinKey same key is no-op')

  // joinKey rejects invalid keys
  let threw = false
  try { await mbB.joinKey(Buffer.alloc(16)) } catch { threw = true }
  assert(threw, 'joinKey rejects non-32-byte key')

  await mbA.close()
  await mbB.close()
  rmDir(dirA)
  rmDir(dirB)
}

// ── Test: joinKey persists across restart ──────────────────────

console.log('\n--- joinKey persists across restart ---')

{
  const dirA = tmpDir('persist-a')
  const dirB = tmpDir('persist-b')

  // Device A
  const mbA = new MetadataBase(dirA, { network: false })
  await mbA.ready()
  const keyA = mbA.key.toString('hex')

  // Device B joins A, then closes
  const mbB1 = new MetadataBase(dirB, { network: false })
  await mbB1.ready()
  await mbB1.joinKey(Buffer.from(keyA, 'hex'))
  await mbB1.close()

  // Device B restarts — should still use A's key
  const mbB2 = new MetadataBase(dirB, { network: false })
  await mbB2.ready()
  assertEqual(mbB2.key.toString('hex'), keyA, 'joined key persists across restart')

  await mbA.close()
  await mbB2.close()
  rmDir(dirA)
  rmDir(dirB)
}

// ── Summary ──────────────────────────────────────────────────

console.log(`\n${'='.repeat(50)}`)
console.log(`Results: ${passCount}/${testCount} passed, ${failCount} failed`)
console.log('='.repeat(50))

// Cleanup tmp base
rmDir(tmpBase)

if (failCount > 0) {
  console.error('\nSOME TESTS FAILED')
  Bare.exit(1)
} else {
  console.log('\nALL TESTS PASSED')
}