OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues / test / basic.test.js

533 lines · javascript ref:
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { replicateAndSync } from 'autobase-test-helpers'
import { HyperIssues, ZERO_OID } from '../index.js'

function tmpStore () {
  const dir = mkdtempSync(join(tmpdir(), 'hyper-issues-test-'))
  return new Corestore(dir)
}

async function makeTracker (t, namespace = 'test') {
  const store = tmpStore()
  const tracker = new HyperIssues(store, { namespace })
  await tracker.ready()
  t.teardown(async () => {
    try { await tracker.close() } catch {}
    try { await store.close() } catch {}
  })
  return tracker
}

test('create and retrieve issue', async (t) => {
  const tracker = await makeTracker(t, 'test-1')

  const id = await tracker.create({
    title: 'First bug',
    body: 'Something is broken',
    author: 'user-1'
  })

  t.is(id, 1)

  const issue = await tracker.get(id)
  t.is(issue.title, 'First bug')
  t.is(issue.body, 'Something is broken')
  t.is(issue.author, 'user-1')
  t.is(issue.status, 'open')
})

test('update issue status', async (t) => {
  const tracker = await makeTracker(t, 'test-2')

  const id = await tracker.create({ title: 'Bug', author: 'user-1' })
  await tracker.update(id, { status: 'closed' }, 'user-1')

  const issue = await tracker.get(id)
  t.is(issue.status, 'closed')
})

test('list issues with filters', async (t) => {
  const tracker = await makeTracker(t, 'test-3')

  await tracker.create({ title: 'Open bug', author: 'user-1' })
  await tracker.create({ title: 'Another open', author: 'user-2' })
  const closedId = await tracker.create({ title: 'Closed one', author: 'user-1' })
  await tracker.update(closedId, { status: 'closed' }, 'user-1')

  const open = await tracker.list({ status: 'open' })
  t.is(open.length, 2)

  const closed = await tracker.list({ status: 'closed' })
  t.is(closed.length, 1)

  const all = await tracker.list()
  t.is(all.length, 3)

  const byAuthor = await tracker.list({ author: 'user-1' })
  t.is(byAuthor.length, 2)

  const searched = await tracker.list({ search: 'another' })
  t.is(searched.length, 1)
})

test('comments', async (t) => {
  const tracker = await makeTracker(t, 'test-4')

  const id = await tracker.create({ title: 'Bug', author: 'user-1' })

  const commentId = await tracker.comment(id, {
    body: 'I can reproduce this',
    author: 'user-2'
  })

  t.ok(commentId)

  const comments = await tracker.getComments(id)
  t.is(comments.length, 1)
  t.is(comments[0].body, 'I can reproduce this')
  t.is(comments[0].author, 'user-2')
})

test('edit and delete comments', async (t) => {
  const tracker = await makeTracker(t, 'test-4b')

  const id = await tracker.create({ title: 'Bug', author: 'user-1' })
  const cId = await tracker.comment(id, { body: 'first', author: 'user-2' })

  await tracker.editComment(id, cId, 'edited', 'user-2')
  let comments = await tracker.getComments(id)
  t.is(comments[0].body, 'edited')
  t.ok(comments[0].editedAt)
  t.is(comments[0].editedBy, 'user-2')

  await tracker.deleteComment(id, cId, 'user-2')
  comments = await tracker.getComments(id)
  t.is(comments.length, 0)
})

test('labels', async (t) => {
  const tracker = await makeTracker(t, 'test-5')

  await tracker.createLabel('bug', '#ff0000', 'Something broken', 'Bug')
  await tracker.createLabel('feature', '#00ff00', 'New feature', 'Feature')

  const labels = await tracker.listLabels()
  t.is(labels.length, 2)

  const bug = await tracker.getLabel('bug')
  t.is(bug.color, '#ff0000')
  t.is(bug.displayName, 'Bug')

  await tracker.updateLabel('bug', { color: '#aa0000' })
  const updated = await tracker.getLabel('bug')
  t.is(updated.color, '#aa0000')

  await tracker.deleteLabel('feature')
  const after = await tracker.listLabels()
  t.is(after.length, 1)
})

test('issue counts', async (t) => {
  const tracker = await makeTracker(t, 'test-6')

  await tracker.create({ title: 'one', author: 'user-1' })
  await tracker.create({ title: 'two', author: 'user-1' })
  const id3 = await tracker.create({ title: 'three', author: 'user-1' })
  await tracker.update(id3, { status: 'closed' }, 'user-1')

  const counts = await tracker.counts()
  t.is(counts.open, 2)
  t.is(counts.closed, 1)
  t.is(counts.total, 3)
})

test('auto-incrementing IDs', async (t) => {
  const tracker = await makeTracker(t, 'test-7')

  const id1 = await tracker.create({ title: 'a', author: 'u' })
  const id2 = await tracker.create({ title: 'b', author: 'u' })
  const id3 = await tracker.create({ title: 'c', author: 'u' })

  t.is(id1, 1)
  t.is(id2, 2)
  t.is(id3, 3)
})

test('refs (optional git feature)', async (t) => {
  const tracker = await makeTracker(t, 'test-8')

  await tracker.updateRef('refs/heads/main', 'abc123', 'user-1')
  await tracker.updateRef('refs/heads/dev', 'def456', 'user-1')

  const main = await tracker.getRef('refs/heads/main')
  t.is(main.hash, 'abc123')

  const refs = await tracker.listRefs()
  t.is(refs.length, 2)

  await tracker.deleteRef('refs/heads/dev', 'user-1')
  const after = await tracker.listRefs()
  t.is(after.length, 1)
})

// ── gip-overlay P1: fast-forward (compare-and-swap) refs ─────────
// The apply rejects a ref move whose declared prior oid (oldOid) does not
// match the current view value. Reachability-FF stays enforced locally by
// git-receive-pack; this guard is the cross-writer concurrency check.

test('refs FF: fast-forward update accepted', async (t) => {
  const tracker = await makeTracker(t, 'test-ref-ff-1')

  // create — no prior value (oldOid omitted == absent)
  await tracker.updateRef('refs/heads/main', 'aaa', 'user-1')
  t.is((await tracker.getRef('refs/heads/main')).hash, 'aaa')

  // fast-forward: declare the observed oid, move it forward
  await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa')
  t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'ref moved aaa -> bbb')
})

test('refs FF: non-fast-forward (stale oldOid) rejected', async (t) => {
  const tracker = await makeTracker(t, 'test-ref-ff-2')

  await tracker.updateRef('refs/heads/main', 'aaa', 'user-1')        // create
  await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa') // ff aaa -> bbb
  t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb')

  // stale: writer still believes ref is at 'aaa' — must be rejected
  await tracker.updateRef('refs/heads/main', 'ccc', 'user-1', 'aaa')
  t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'stale update rejected, ref unchanged')
})

test('refs FF: create with non-zero oldOid rejected; zero-oid create accepted', async (t) => {
  const tracker = await makeTracker(t, 'test-ref-ff-3')

  // claims a prior value but ref is absent → reject
  await tracker.updateRef('refs/heads/new', 'xxx', 'user-1', 'deadbeef')
  t.is(await tracker.getRef('refs/heads/new'), null, 'phantom-prior create rejected')

  // zero-oid explicitly means "must not exist" → accepted
  await tracker.updateRef('refs/heads/new', 'xxx', 'user-1', ZERO_OID)
  t.is((await tracker.getRef('refs/heads/new')).hash, 'xxx', 'zero-oid create accepted')
})

test('refs FF: linearized competing updates — first wins, second rejected', async (t) => {
  const tracker = await makeTracker(t, 'test-ref-ff-4')

  await tracker.updateRef('refs/heads/main', 'base', 'user-1') // create

  // both updates branched from 'base'. After linearization the first
  // applied wins; the second (still citing 'base') is non-FF and dropped.
  await tracker.updateRef('refs/heads/main', 'win', 'user-1', 'base')
  await tracker.updateRef('refs/heads/main', 'lose', 'user-2', 'base')

  t.is((await tracker.getRef('refs/heads/main')).hash, 'win', 'first FF wins, second rejected')
})

test('refs FF: delete honours oldOid (stale delete rejected, matching accepted)', async (t) => {
  const tracker = await makeTracker(t, 'test-ref-ff-5')

  await tracker.updateRef('refs/heads/main', 'aaa', 'user-1')
  await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa')

  // stale delete (cites the old value) → rejected
  await tracker.deleteRef('refs/heads/main', 'user-1', 'aaa')
  t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'stale delete rejected')

  // matching delete → accepted
  await tracker.deleteRef('refs/heads/main', 'user-1', 'bbb')
  t.is(await tracker.getRef('refs/heads/main'), null, 'matching delete removed ref')
})

// ── gip-overlay P3: bundle chain bookkeeping ────────────────────
// Each push records the overlay content hash of its delta bundle, keyed by
// the tip oid it produces, with the baseOid it builds on. A fetcher walks
// oid -> baseOid back to genesis (ZERO) to resolve which bundles to pull.

test('bundles: add and retrieve a bundle entry', async (t) => {
  const tracker = await makeTracker(t, 'test-bundle-1')

  await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'hashB', size: 123, ref: 'refs/heads/main' }, 'user-1')
  const b = await tracker.getBundle('o2')
  t.is(b.baseOid, 'o1')
  t.is(b.contentHash, 'hashB')
  t.is(b.size, 123)
  t.is(b.ref, 'refs/heads/main')
  t.is(b.addedBy, 'user-1')
})

test('bundles: missing oid resolves to null; creation bundle defaults baseOid to ZERO', async (t) => {
  const tracker = await makeTracker(t, 'test-bundle-2')

  t.is(await tracker.getBundle('nope'), null, 'unknown oid → null')

  await tracker.addBundle({ oid: 'genesis', contentHash: 'hashA', size: 9 }, 'user-1') // no baseOid
  const b = await tracker.getBundle('genesis')
  t.is(b.baseOid, ZERO_OID, 'creation bundle baseOid defaults to ZERO_OID')
})

test('bundles: add-bundle is idempotent (first writer wins)', async (t) => {
  const tracker = await makeTracker(t, 'test-bundle-3')

  await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'first', size: 1 }, 'user-1')
  await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'second', size: 2 }, 'user-2')
  const b = await tracker.getBundle('o2')
  t.is(b.contentHash, 'first', 'replay/duplicate does not overwrite')
})

test('meta key-value storage', async (t) => {
  const tracker = await makeTracker(t, 'test-9')

  await tracker.setMeta('description', 'My project')
  await tracker.setMeta('homepage', 'https://example.com')

  t.is(await tracker.getMeta('description'), 'My project')
  t.is(await tracker.getMeta('homepage'), 'https://example.com')
  t.is(await tracker.getMeta('missing'), null)
})

test('key properties', async (t) => {
  const tracker = await makeTracker(t, 'test-10')

  t.ok(tracker.key, 'has key')
  t.is(typeof tracker.keyHex, 'string')
  t.is(tracker.keyHex.length, 64, 'keyHex is 32 bytes hex')
  t.ok(tracker.localKey, 'has localKey')
  t.is(tracker.writable, true, 'first writer is writable')
})

test('add labels to an issue', async (t) => {
  const tracker = await makeTracker(t, 'test-11')

  await tracker.createLabel('bug', '#ff0000')
  await tracker.createLabel('urgent', '#ffaa00')

  const id = await tracker.create({ title: 'Bug', author: 'user-1' })
  await tracker.addLabels(id, ['bug', 'urgent'], 'user-1')

  const issue = await tracker.get(id)
  t.alike(issue.labels.sort(), ['bug', 'urgent'])

  await tracker.removeLabels(id, ['urgent'], 'user-1')
  const after = await tracker.get(id)
  t.alike(after.labels, ['bug'])
})

test('initializeDefaultLabels', async (t) => {
  const tracker = await makeTracker(t, 'test-12')

  const labels = await tracker.initializeDefaultLabels()
  t.ok(labels.length > 0, 'creates default labels')
  t.ok(labels.find(l => l.name === 'bug'))
  t.ok(labels.find(l => l.name === 'enhancement'))

  // Calling again should not duplicate
  await tracker.initializeDefaultLabels()
  const second = await tracker.listLabels()
  t.is(second.length, labels.length)
})

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

test('two trackers replicate via autobase', async (t) => {
  const storeA = tmpStore()
  const storeB = tmpStore()

  const trackerA = new HyperIssues(storeA, { namespace: 'shared' })
  await trackerA.ready()

  // A creates an issue and a label
  await trackerA.createLabel('bug', '#ff0000')
  await trackerA.create({ title: 'shared bug', author: 'alice' })
  await trackerA.base.update()

  const trackerB = new HyperIssues(storeB, {
    namespace: 'shared',
    bootstrapKey: trackerA.keyHex
  })
  await trackerB.ready()

  await replicateAndSync([trackerA.base, trackerB.base])

  // B should see A's data
  const issue = await trackerB.get(1)
  t.ok(issue, 'B received the issue')
  t.is(issue.title, 'shared bug')

  const labels = await trackerB.listLabels()
  t.is(labels.length, 1)
  t.is(labels[0].name, 'bug')

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

test('two writers can both append after addWriter', async (t) => {
  const storeA = tmpStore()
  const storeB = tmpStore()

  const trackerA = new HyperIssues(storeA, { namespace: 'shared2' })
  await trackerA.ready()

  const trackerB = new HyperIssues(storeB, {
    namespace: 'shared2',
    bootstrapKey: trackerA.keyHex
  })
  await trackerB.ready()

  // Initial sync so B knows the room structure
  await replicateAndSync([trackerA.base, trackerB.base])

  // A authorizes B as a writer
  await trackerA.addWriter('bob', { writerKey: trackerB.localKeyHex, role: 'writer' })
  await replicateAndSync([trackerA.base, trackerB.base])

  t.ok(trackerB.writable, 'B is now writable')

  // Alice creates first, sync, then Bob creates.
  // (Concurrent create() from two writers picks the same auto-increment ID
  // because each writer reads its local counter independently. This is a
  // known limitation of auto-increment in multiwriter — to avoid the
  // collision, sync between creates so each peer sees the latest counter.
  // For collision-free concurrent IDs, use UUID-style IDs in the schema.)
  await trackerA.create({ title: 'from alice', author: 'alice' })
  await replicateAndSync([trackerA.base, trackerB.base])
  await trackerB.create({ title: 'from bob', author: 'bob' })

  await replicateAndSync([trackerA.base, trackerB.base])

  // Both should see both issues
  const allA = await trackerA.list({ sort: 'oldest' })
  const allB = await trackerB.list({ sort: 'oldest' })

  t.is(allA.length, 2, 'A sees both')
  t.is(allB.length, 2, 'B sees both')
  t.alike(allA.map(i => i.title).sort(), ['from alice', 'from bob'])

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

// ── gip-overlay P4: writer-set membership IS the ref push authority ──
// A node may advance refs iff its writer key is in the per-repo autobase's
// writer set. Grant = addWriter (repo invite/join); revoke = removeWriter.
// A non-writer's op never reaches the linearized view (forgery rejected);
// reads are open regardless.

test('refs authority: writer-set gates ref advances (forgery / grant / revoke)', async (t) => {
  const storeA = tmpStore()
  const storeB = tmpStore()
  const A = new HyperIssues(storeA, { namespace: 'gip-auth' })
  await A.ready()
  const B = new HyperIssues(storeB, { namespace: 'gip-auth', bootstrapKey: A.keyHex })
  await B.ready()
  t.teardown(async () => {
    for (const x of [A, B, storeA, storeB]) { try { await x.close() } catch {} }
  })
  await replicateAndSync([A.base, B.base])

  // 1. forgery: B is NOT a writer — its ref op must not reach A's view
  t.absent(B.writable, 'B is not a writer yet')
  try { await B.updateRef('refs/heads/main', 'aaa', 'bob') } catch {}
  await replicateAndSync([A.base, B.base])
  t.is(await A.getRef('refs/heads/main'), null, 'non-writer ref op rejected (forgery)')

  // 2. grant: addWriter B → B advances the ref, A sees it
  await A.addWriter('bob', { writerKey: B.localKeyHex, role: 'writer' })
  await replicateAndSync([A.base, B.base])
  t.ok(B.writable, 'B writable after grant')
  await B.updateRef('refs/heads/main', 'bbb', 'bob')
  await replicateAndSync([A.base, B.base])
  t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'granted writer advance accepted')

  // 3. revoke: removeWriter B → B's later op is not applied; reads still work
  await A.removeWriter('bob')
  await replicateAndSync([A.base, B.base])
  try { await B.updateRef('refs/heads/main', 'ccc', 'bob', 'bbb') } catch {}
  await replicateAndSync([A.base, B.base])
  t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'post-revoke op not applied — ref unchanged')
  t.is((await B.getRef('refs/heads/main')).hash, 'bbb', 'revoked node can still READ')

  // 4. remaining writer (A) keeps write authority
  await A.updateRef('refs/heads/main', 'ddd', 'alice', 'bbb')
  await replicateAndSync([A.base, B.base])
  t.is((await A.getRef('refs/heads/main')).hash, 'ddd', 'owner still advances refs after revoke')
})

test('createWithId preserves id, status, and timestamps (migration path)', async (t) => {
  const tracker = await makeTracker(t, 'migration-1')

  await tracker.createWithId(0, {
    title: 'issue zero',
    body: 'v1 used id=0',
    author: 'fleeky',
    status: 'closed',
    createdAt: 1733078495188,
    updatedAt: 1734033780311
  })
  await tracker.createWithId(42, {
    title: 'sparse id',
    author: 'fleeky',
    status: 'open',
    createdAt: 1700000000000
  })

  const zero = await tracker.get(0)
  t.is(zero.id, 0, 'id=0 preserved')
  t.is(zero.status, 'closed')
  t.is(zero.createdAt, 1733078495188, 'createdAt preserved')
  t.is(zero.updatedAt, 1734033780311, 'updatedAt preserved')

  const forty2 = await tracker.get(42)
  t.is(forty2.id, 42)
  t.is(forty2.createdAt, 1700000000000)

  const newId = await tracker.create({ title: 'post-migration', author: 'fleeky' })
  t.is(newId, 43, 'auto-increment picks up after highest migrated id')
})

test('createWithId is idempotent (second call skips overwrite)', async (t) => {
  const tracker = await makeTracker(t, 'migration-2')

  await tracker.createWithId(5, { title: 'first', author: 'fleeky', status: 'open' })
  await tracker.createWithId(5, { title: 'OVERWRITE ATTEMPT', author: 'malicious', status: 'closed' })

  const issue = await tracker.get(5)
  t.is(issue.title, 'first', 'original preserved')
  t.is(issue.author, 'fleeky')
  t.is(issue.status, 'open')
})

test('commentWithId preserves commentId + createdAt (migration path)', async (t) => {
  const tracker = await makeTracker(t, 'migration-3')

  await tracker.createWithId(1, { title: 'with comments', author: 'fleeky' })
  await tracker.commentWithId(1, 'v1-comment-id-abc', {
    body: 'first comment',
    author: 'fleeky',
    createdAt: 1700000000000
  })
  await tracker.commentWithId(1, 'v1-comment-id-abc', {
    body: 'OVERWRITE',
    author: 'mal'
  })

  const comments = await tracker.getComments(1)
  t.is(comments.length, 1)
  t.is(comments[0].commentId, 'v1-comment-id-abc', 'commentId preserved')
  t.is(comments[0].body, 'first comment', 'original body preserved')
  t.is(comments[0].createdAt, 1700000000000, 'createdAt preserved')
})