OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / repo-follower-mechanism.test.js

262 lines · javascript ref:
// repo-follower-mechanism.test.js — RepoFollower in-process proof.
//
// Verifies the core data flow without spinning up a daemon:
//   1. Follower opens source's RepoEventLog by key, replicates entries.
//   2. Follower opens source's HyperIssues by bootstrap key, sees issues
//      in its FollowRegistry (per-source namespace).
//   3. Live entries for the followed repo name trigger sync.pull().
//   4. Live entries for OTHER repo names are ignored.
//   5. The follower never enrolls as a writer (tracker.writable === false).
//
// Mocks `sync` (we count pull invocations; no real git) and `swarm`
// (join returns an inert handle; corestores are wired via
// replicateAndSync instead).

import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from '#node:fs'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import { replicateAndSync } from 'autobase-test-helpers'

import { HyperIssues } from 'hyper-issues'
import { RepoEventLog } from '../lib/services/repo-event-log.js'
import { FollowRegistry } from '../lib/follow-registry.js'
import { RepoFollower } from '../lib/services/repo-follower.js'

function makeStore () {
  return new Corestore(mkdtempSync(join(tmpdir(), 'pgv3-rf-mech-')))
}

function makeMockSync () {
  const pulls = []
  return {
    remotes: new Map(),
    addRemote (name, srvKey) { this.remotes.set(name, srvKey) },
    async pull (remoteName, repoName) {
      pulls.push({ remoteName, repoName, at: Date.now() })
    },
    pulls
  }
}

function makeMockSwarm () {
  const joins = []
  return {
    joins,
    join (topic, opts) {
      joins.push({ topic, opts })
      return {
        async flushed () {},
        async destroy () {}
      }
    }
  }
}

async function waitFor (predicate, { ms = 5000, step = 50 } = {}) {
  const deadline = Date.now() + ms
  while (Date.now() < deadline) {
    if (await predicate()) return true
    await new Promise((r) => setTimeout(r, step))
  }
  return false
}

test('follower replicates source RepoEventLog + tracker, ignores other repos', async (t) => {
  // ── SOURCE setup ──
  const storeA = makeStore()
  const sourceLog = new RepoEventLog({ corestore: storeA })
  await sourceLog.ready()

  // Source's tracker for 'foo'
  const trackerA = new HyperIssues(storeA, { namespace: 'issues:repo:foo' })
  await trackerA.ready()
  const issueId = await trackerA.create({ title: 'pre-follow issue', author: 'alice' })

  // Source appends event for 'foo' and an unrelated 'other'
  await sourceLog.append({
    op: 0, name: 'foo', head: 'aaa', ts: Date.now(),
    issueBootstrapKey: trackerA.key
  })
  await sourceLog.append({
    op: 0, name: 'other', head: 'bbb', ts: Date.now(),
    issueBootstrapKey: null
  })

  // The "source server key" stand-in. In production it's the daemon's
  // identity.publicKeyHex; here we use the log's discoveryKey hex
  // (just needs to be a stable 64-hex per source).
  const sourceSrvKey = sourceLog.core.discoveryKey.toString('hex')

  // ── FOLLOWER setup ──
  const storeB = makeStore()
  const followReg = new FollowRegistry(storeB)
  const sync = makeMockSync()
  const swarm = makeMockSwarm()

  const follower = new RepoFollower({
    corestore: storeB,
    swarm,
    sync,
    followRegistry: followReg,
    sourceServerKey: sourceSrvKey,
    repoName: 'foo',
    issueBootstrapKey: trackerA.keyHex,
    repoEventLogKey: sourceLog.core.key
  })

  await follower.start()

  // The event log was opened; we need to feed bytes to it. RepoEventLog
  // opens `corestore.get({ key })` — we manually wire the two stores
  // via replicateAndSync. Use the autobase under the tracker as the
  // sync target (the event log core piggybacks the same connection
  // since we're sharing the corestore.replicate transport).
  //
  // For the event log + tracker autobase together, drive sync on:
  //   - tracker autobases (so tracker view replicates)
  //   - event-log cores (so event entries replicate)
  // replicateAndSync handles autobases; for the bare event-log core
  // we drive update() after pumping the autobase pipe.
  const followerTracker = followReg.getOpenRepo(sourceSrvKey, 'foo')
  t.ok(followerTracker, 'follower opened tracker')

  // Pump replication for the tracker autobase first.
  await replicateAndSync([trackerA.base, followerTracker.base])

  // Now pump replication for the event log core via a direct stream.
  const sa = storeA.replicate(true)
  const sb = storeB.replicate(false)
  sa.pipe(sb).pipe(sa)
  await new Promise((r) => setTimeout(r, 100))
  try { await follower._eventLog.core.update({ wait: true }) } catch {}

  // ── ASSERTIONS ──

  // (1) Follower sees source's issue.
  const issue = await followerTracker.get(issueId)
  t.ok(issue, 'follower sees source issue via tracker view')
  t.is(issue.title, 'pre-follow issue')

  // (2) Follower is NOT a writer of the source's tracker.
  t.is(followerTracker.writable, false, 'follower tracker is read-only')

  // (3) Wait for the live loop to consume both event log entries and
  //     trigger a pull for 'foo' (one entry matches, one doesn't).
  await waitFor(() => sync.pulls.some((p) => p.repoName === 'foo'))

  const fooPulls = sync.pulls.filter((p) => p.repoName === 'foo')
  t.ok(fooPulls.length >= 1, 'pulled foo at least once (initial OR live-loop)')
  const otherPulls = sync.pulls.filter((p) => p.repoName === 'other')
  t.is(otherPulls.length, 0, 'did NOT pull other (filtered by name)')

  // (4) addRemote happened under the follow:<srvKey> name, NOT 'primary'.
  t.is(sync.remotes.size, 1)
  t.is(sync.remotes.get('follow:' + sourceSrvKey.toLowerCase()), sourceSrvKey.toLowerCase())
  t.absent(sync.remotes.has('primary'), 'follower does NOT reserve "primary"')

  // (5) swarm.join called with client:true (read-only discovery).
  t.is(swarm.joins.length, 1, 'one swarm.join')
  t.is(swarm.joins[0].opts.server, false, 'server:false')
  t.is(swarm.joins[0].opts.client, true,  'client:true')

  // ── CLEANUP ──
  sa.destroy(); sb.destroy()
  await follower.close()
  await followReg.close()
  await trackerA.close()
  await sourceLog.close()
  await storeA.close()
  await storeB.close()
})

test('live new issue on source replicates to follower', async (t) => {
  const storeA = makeStore()
  const sourceLog = new RepoEventLog({ corestore: storeA })
  await sourceLog.ready()
  const trackerA = new HyperIssues(storeA, { namespace: 'issues:repo:bar' })
  await trackerA.ready()
  const sourceSrvKey = sourceLog.core.discoveryKey.toString('hex')

  const storeB = makeStore()
  const followReg = new FollowRegistry(storeB)
  const sync = makeMockSync()
  const swarm = makeMockSwarm()

  const follower = new RepoFollower({
    corestore: storeB,
    swarm,
    sync,
    followRegistry: followReg,
    sourceServerKey: sourceSrvKey,
    repoName: 'bar',
    issueBootstrapKey: trackerA.keyHex,
    repoEventLogKey: sourceLog.core.key
  })

  await follower.start()

  // Open replication stream for the duration of the test.
  const sa = storeA.replicate(true)
  const sb = storeB.replicate(false)
  sa.pipe(sb).pipe(sa)
  await new Promise((r) => setTimeout(r, 100))

  // Now create an issue AFTER follower started.
  const id = await trackerA.create({ title: 'live issue', author: 'alice' })
  const followerTracker = followReg.getOpenRepo(sourceSrvKey, 'bar')
  t.ok(followerTracker)

  // Wait for replication to land the new issue on the follower view.
  const seen = await waitFor(async () => {
    try {
      await followerTracker.base.update()
      const got = await followerTracker.get(id)
      return !!got
    } catch { return false }
  })
  t.ok(seen, 'follower observed live issue')

  sa.destroy(); sb.destroy()
  await follower.close()
  await followReg.close()
  await trackerA.close()
  await sourceLog.close()
  await storeA.close()
  await storeB.close()
})

test('follower starts without event log key (no live loop, still pulls once)', async (t) => {
  const storeA = makeStore()
  const trackerA = new HyperIssues(storeA, { namespace: 'issues:repo:baz' })
  await trackerA.ready()

  const storeB = makeStore()
  const followReg = new FollowRegistry(storeB)
  const sync = makeMockSync()
  const swarm = makeMockSwarm()

  const follower = new RepoFollower({
    corestore: storeB,
    swarm,
    sync,
    followRegistry: followReg,
    sourceServerKey: 'cafe' + 'd'.repeat(60),
    repoName: 'baz',
    issueBootstrapKey: trackerA.keyHex,
    repoEventLogKey: null
  })

  await follower.start()
  await waitFor(() => sync.pulls.length >= 1)
  t.is(sync.pulls.length, 1, 'one initial pull only')
  t.absent(follower._eventLog, 'no event log opened')

  await follower.close()
  await followReg.close()
  await trackerA.close()
  await storeA.close()
  await storeB.close()
})