OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / issue-index.test.js

126 lines · javascript ref:
/**
 * issue-index: record authored issues, upsert-by-ref, recency order,
 * disk persistence + restart survival. Powers the public profile
 * "Recent issues" list.
 */

import test from 'brittle'
import { mkdtemp, rm, mkdir, readFile } from '#node:fs/promises'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import { issueHandlers } from '../lib/handlers/issues.js'
import { listUserIssues } from '../lib/services/issue-index.js'

function fresh (tag) { return import('../lib/services/issue-index.js?fresh=' + tag) }

test('issue-index: record, upsert, order, persistence + restart', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'ii-'))
  const storage = join(root, 's')
  await mkdir(storage, { recursive: true })
  t.teardown(() => rm(root, { recursive: true, force: true }))

  const m = await fresh('1')
  await m.configureIssueIndex(storage)
  m.recordIssue('alice', { scope: 'repo', name: 'app', id: 1, title: 'first', status: 'open', createdAt: 1000 })
  m.recordIssue('alice', { scope: 'org', name: 'acme', id: 7, title: 'org bug', status: 'open', createdAt: 3000 })
  m.recordIssue('alice', { scope: 'repo', name: 'app', id: 1, title: 'first', status: 'closed', createdAt: 1000 }) // upsert status
  m.recordIssue('bob', { scope: 'repo', name: 'app', id: 2, title: 'bobs', status: 'open', createdAt: 2000 })
  m.recordIssue(null, { scope: 'repo', name: 'app', id: 9 }) // unattributable → ignored
  m.recordIssue('carol', { scope: 'repo', name: 'app' }) // no id → ignored

  const alice = m.listUserIssues('alice')
  t.is(alice.length, 2, 'alice authored 2 distinct issues (upsert did not duplicate)')
  t.is(alice[0].id, 7, 'most-recent (createdAt) first')
  const app1 = alice.find(i => i.id === 1)
  t.is(app1.status, 'closed', 'upsert refreshed status')
  t.is(app1.scope, 'repo', 'scope preserved')
  t.is(m.listUserIssues('bob').length, 1, 'bob has 1')
  t.is(m.listUserIssues('carol').length, 0, 'no-id issue ignored')
  t.is(m.listUserIssues('nobody').length, 0, 'unknown user → empty')

  // limit
  m.recordIssue('alice', { scope: 'repo', name: 'app', id: 3, title: 't3', createdAt: 4000 })
  t.is(m.listUserIssues('alice', 1).length, 1, 'limit honored')
  t.is(m.listUserIssues('alice', 1)[0].id, 3, 'limit returns newest')

  await m.flushIssueIndex()
  const disk = JSON.parse(await readFile(join(storage, 'issue-index.json'), 'utf8'))
  t.ok(disk.users.alice && disk.users.alice['repo:app:1'], 'persisted keyed by scope:name:id')

  const m2 = await fresh('2')
  await m2.configureIssueIndex(storage)
  const alice2 = m2.listUserIssues('alice')
  t.is(alice2.length, 3, 'survived restart (2 + the extra)')
  t.is(alice2[0].id, 3, 'order preserved after restart')
})

test('issue-index: backfill enumerates all trackers and records authors', async (t) => {
  const m = await fresh('backfill')
  // No storage configured → persist is a no-op; we only assert in-memory state.
  //
  // The index is module state, and `fresh(tag)` does NOT give a distinct
  // module under Bare — it resolves by realpath and ignores the ?fresh query,
  // so every test in this file shares one index. Without this reset the
  // previous test's alice entries are still present and the counts below read
  // 5 instead of 3. (Under Node the imports really are distinct, which is why
  // this only started failing when the suite moved to Bare.)
  m.resetIssueIndexForTests()

  const fakeSvc = {
    issueRegistry: {
      keys: async () => ({ repos: { app: {}, tools: {} }, orgs: { acme: {} } }),
      // Real view records carry the author as `author`, not `userId`.
      getForRepo: async (name) => ({
        list: async () => name === 'app'
          ? [
              { id: 1, title: 'a1', author: 'alice', status: 'open', createdAt: 1000 },
              { id: 2, title: 'a2', author: 'bob', status: 'closed', createdAt: 2000 }
            ]
          : [{ id: 1, title: 't1', author: 'alice', status: 'open', createdAt: 4000 }]
      }),
      getForOrg: async () => ({
        list: async () => [{ id: 5, title: 'o5', author: 'alice', status: 'open', createdAt: 3000 }]
      })
    }
  }

  const r = await m.backfillIssueIndex(fakeSvc)
  t.is(r.repos, 2, 'scanned 2 repos')
  t.is(r.orgs, 1, 'scanned 1 org')
  t.is(r.indexed, 4, 'recorded 4 issues')

  const alice = m.listUserIssues('alice')
  t.is(alice.length, 3, 'alice authored 3 (app#1, tools#1, acme#5)')
  t.is(alice[0].id, 1, 'newest first: tools#1 @4000')
  t.is(alice[0].name, 'tools', 'tools repo')
  t.is(alice[0].scope, 'repo')
  t.is(alice.find(i => i.scope === 'org').name, 'acme', 'org issue captured')
  t.is(m.listUserIssues('bob').length, 1, 'bob authored 1')
})

test('P2: comment records participation; update refreshes status under original author', async (t) => {
  let stored = { id: 5, title: 'T', author: 'orig', status: 'open', createdAt: 1000 }
  const svc = {
    identityRegistry: {},
    issueRegistry: {
      getForRepo: async () => ({
        comment: async () => 1,
        update: async (id, upd) => { stored = { ...stored, ...upd } },
        get: async () => stored
      })
    }
  }
  const H = issueHandlers(svc)
  const comment = H.find(h => h.name === 'comment-issue')
  const update = H.find(h => h.name === 'update-issue')

  await comment.fn({ transport: 'uds' }, { repo: 'app', id: 5, body: 'hi', author: 'commenter' })
  t.ok(listUserIssues('commenter').some(i => i.id === 5), 'commented-on issue indexed under the commenter')

  await update.fn({ transport: 'uds' }, { repo: 'app', id: 5, status: 'closed', author: 'someone-else' })
  const orig = listUserIssues('orig').find(i => i.id === 5)
  t.ok(orig, 'issue indexed under the ORIGINAL author on update (not the updater)')
  t.is(orig.status, 'closed', 'status refreshed to closed')
  t.absent(listUserIssues('someone-else').some(i => i.id === 5), 'updater is NOT credited as author')
})