OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / migrate-org-issues-linked-core.test.js

127 lines · javascript ref:
// FULL migration through the registry entry point (orgsToPerOrg via the
// runner): v1 single-orgs autobase + v1 separate per-org issue trackers
// (`issues:org:<name>`) -> v2 per-org HyperOrgs whose rooms carry the composed
// issues schema (#42 linked-core). Proves migrateOrgIssues (the orchestrator
// added to migrations.js) actually wires source trackers onto the org rooms —
// i.e. the migration is INVOKED end-to-end, not just the pure transform.

import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import { HyperOrgs } from 'hyper-orgs'
import { HyperIssues, issuesSchema } from 'hyper-issues'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'

import { runMigrations } from '../lib/services/migrate/runner.js'
import { orgsToPerOrg } from '../lib/services/migrate/migrations.js'

// v1 single-`orgs` autobase: append create-org ops; apply puts org:{name}.
async function makeOldOrgsBase (t) {
  const store = new Corestore(mkdtempSync(join(tmpdir(), 'lc-old-orgs-')))
  const base = new Autobase(store, null, {
    valueEncoding: 'json',
    open (s) { return new Hyperbee(s.get('view', { valueEncoding: 'json' }), { keyEncoding: 'utf-8', valueEncoding: 'json' }) },
    async apply (nodes, view) {
      for (const node of nodes) {
        const op = node.value
        if (op && op.type === 'create-org') await view.put('org:' + op.org.name, op.org)
      }
    }
  })
  await base.ready()
  t.teardown(async () => { try { await base.close() } catch {}; try { await store.close() } catch {} })
  return base
}

// A v1 per-org issue tracker = a standalone HyperIssues (same separate-autobase
// shape prod used for `issues:org:<name>`).
async function makeOldOrgTracker (t, ns) {
  const store = new Corestore(mkdtempSync(join(tmpdir(), 'lc-old-tracker-')))
  const tracker = new HyperIssues(store, { namespace: ns })
  await tracker.ready()
  t.teardown(async () => { try { await tracker.close() } catch {}; try { await store.close() } catch {} })
  return tracker
}

test('full orgsToPerOrg migration replays org issues into the linked-core org room', async (t) => {
  // 1. v1 orgs
  const oldOrgs = await makeOldOrgsBase(t)
  const ORGS = [
    { name: 'acme', type: 'group', displayName: 'Acme', members: [{ userId: 'alice', role: 'owner' }], resources: [] },
    { name: 'globex', type: 'group', members: [], resources: [] }
  ]
  for (const o of ORGS) await oldOrgs.append({ type: 'create-org', org: o })
  await oldOrgs.update()

  // 2. v1 separate issue trackers, one per org
  const acmeTracker = await makeOldOrgTracker(t, 'issues:org:acme')
  await acmeTracker.createLabel('bug', '#d73a4a', 'broken', 'Bug')
  const a1 = await acmeTracker.create({ title: 'acme bug', body: 'b', author: 'alice', labels: ['bug'] })
  const a2 = await acmeTracker.create({ title: 'acme feature', author: 'bob' })
  await acmeTracker.update(a2, { status: 'closed' }, 'bob')
  await acmeTracker.comment(a1, { body: 'repro', author: 'bob' })

  const globexTracker = await makeOldOrgTracker(t, 'issues:org:globex')
  const g1 = await globexTracker.create({ title: 'globex bug', author: 'carol' })

  const oldTrackers = new Map([['acme', acmeTracker], ['globex', globexTracker]])

  // 3. target: per-org HyperOrgs with the COMPOSED issues schema (#42 — the
  // daemon injects extraSchemas:[issuesSchema] so each org room handles issue ops).
  const newStore = new Corestore(mkdtempSync(join(tmpdir(), 'lc-new-')))
  const newOrgs = new HyperOrgs(newStore, { namespace: 'orgs', extraSchemas: [issuesSchema] })
  await newOrgs.ready()
  t.teardown(async () => { try { await newOrgs.close() } catch {}; try { await newStore.close() } catch {} })

  // 4. run the REAL migration via the runner, supplying the source opener.
  let version = 0
  const r = await runMigrations({
    getVersion: async () => version,
    setVersion: async (v) => { version = v },
    migrations: [orgsToPerOrg],
    ctx: {
      oldOrgsBase: oldOrgs,
      newOrgs,
      openOldOrgTracker: async (name) => oldTrackers.get(name) || null
    }
  })
  t.is(r.version, 2, 'schema v2 after migration')

  // 5. orgs present
  const orgs = await newOrgs.list()
  t.alike(orgs.map(o => o.name).sort(), ['acme', 'globex'], 'both orgs migrated')

  // 6. org issues live in the org's OWN room — wrap getOrgRoom with HyperIssues
  const acmeRoom = newOrgs.getOrgRoom('acme')
  t.ok(acmeRoom, 'acme has a room')
  const acmeIssues = new HyperIssues(null, { room: acmeRoom, ownsRoom: false })
  const i1 = await acmeIssues.get(a1)
  const i2 = await acmeIssues.get(a2)
  t.is(i1.title, 'acme bug', 'acme issue 1 migrated into the org room')
  t.is(i1.author, 'alice', 'author preserved')
  t.alike(i1.labels, ['bug'], 'labels preserved')
  t.is(i2.status, 'closed', 'closed status preserved')
  const c1 = await acmeIssues.getComments(a1)
  t.is(c1.length, 1, 'comment migrated')
  t.is(c1[0].body, 'repro', 'comment body preserved')

  const globexRoom = newOrgs.getOrgRoom('globex')
  const globexIssues = new HyperIssues(null, { room: globexRoom, ownsRoom: false })
  const gi = await globexIssues.get(g1)
  t.is(gi.title, 'globex bug', 'globex issue migrated into ITS own room')

  // 7. LINKED-CORE property (#42): each org's issues live in ITS OWN autobase.
  // (Both trackers number from 1, so isolation shows in counts + content, not
  // ids: acme has 2 issues, globex 1, and globex's id-1 is "globex bug".)
  t.not(acmeRoom.keyHex, globexRoom.keyHex, 'each org is its own autobase')
  const acmeAll = await acmeIssues.list()
  const globexAll = await globexIssues.list()
  t.is(acmeAll.length, 2, 'acme room holds exactly its 2 issues')
  t.is(globexAll.length, 1, 'globex room holds exactly its 1 issue')
  t.absent(globexAll.find(i => i.title === 'acme bug'), "acme's issue did not bleed into globex's room")
  t.absent(acmeAll.find(i => i.title === 'globex bug'), "globex's issue did not bleed into acme's room")
})