OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / namespace-manager.test.js

461 lines · javascript ref:
/**
 * Unit tests for lib/namespace-manager.js
 *
 * Tests namespace (group) CRUD, repo membership, member management,
 * validation, and migration.
 */

import test from 'brittle'
import { NamespaceManager } from '../../lib/namespace-manager.js'
import { MockRegistry } from '../helpers/fixtures.js'

function createManager () {
  const registry = new MockRegistry()
  // Mock repoRegistry with setNamespace
  const repoRegistry = {
    _namespaces: new Map(),
    async setNamespace (repoName, namespaceName) {
      this._namespaces.set(repoName, namespaceName)
    },
    async get (repoName) {
      return null // Not needed for namespace tests
    }
  }
  return new NamespaceManager({ registry, repoRegistry })
}

// ========================================
// Name Validation
// ========================================

test('validateName - accepts valid names', (t) => {
  const mgr = createManager()

  t.ok(mgr.validateName('holepunchto').valid)
  t.ok(mgr.validateName('hyper-fleeky').valid)
  t.ok(mgr.validateName('a').valid)
  t.ok(mgr.validateName('my-group-123').valid)
  t.ok(mgr.validateName('x1').valid)
})

test('validateName - rejects invalid names', (t) => {
  const mgr = createManager()

  t.absent(mgr.validateName('').valid)
  t.absent(mgr.validateName(null).valid)
  t.absent(mgr.validateName('Uppercase').valid)
  t.absent(mgr.validateName('has spaces').valid)
  t.absent(mgr.validateName('-leading').valid)
  t.absent(mgr.validateName('trailing-').valid)
  t.absent(mgr.validateName('double--hyphen').valid)
  t.absent(mgr.validateName('special!chars').valid)
})

test('validateName - rejects reserved names', (t) => {
  const mgr = createManager()

  t.absent(mgr.validateName('api').valid)
  t.absent(mgr.validateName('admin').valid)
  t.absent(mgr.validateName('login').valid)
  t.absent(mgr.validateName('static').valid)
  t.absent(mgr.validateName('settings').valid)
})

// ========================================
// CRUD
// ========================================

test('create - creates a group namespace', async (t) => {
  const mgr = createManager()

  const ns = await mgr.create('holepunchto', 'group', {
    displayName: 'Holepunch',
    description: 'Core repos'
  })

  t.is(ns.name, 'holepunchto')
  t.is(ns.type, 'group')
  t.is(ns.displayName, 'Holepunch')
  t.is(ns.description, 'Core repos')
  t.alike(ns.repos, [])
  t.alike(ns.members, [])
  t.ok(ns.createdAt)
})

test('create - creates a user namespace', async (t) => {
  const mgr = createManager()

  const ns = await mgr.create('alice', 'user', { userId: 'user123' })

  t.is(ns.type, 'user')
  t.is(ns.userId, 'user123')
})

test('create - rejects invalid name', async (t) => {
  const mgr = createManager()

  try {
    await mgr.create('INVALID', 'group')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('lowercase'))
  }
})

test('create - rejects reserved name', async (t) => {
  const mgr = createManager()

  try {
    await mgr.create('api', 'group')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('reserved'))
  }
})

test('create - rejects duplicate name', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')

  try {
    await mgr.create('mygroup', 'group')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('already exists'))
  }
})

test('create - rejects invalid type', async (t) => {
  const mgr = createManager()

  try {
    await mgr.create('test', 'invalid')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('user'))
  }
})

test('get - retrieves namespace', async (t) => {
  const mgr = createManager()

  await mgr.create('testgroup', 'group', { description: 'Test' })
  const ns = await mgr.get('testgroup')

  t.is(ns.name, 'testgroup')
  t.is(ns.description, 'Test')
})

test('get - returns null for nonexistent', async (t) => {
  const mgr = createManager()

  const ns = await mgr.get('nonexistent')
  t.is(ns, null)
})

test('update - updates allowed fields', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')
  const updated = await mgr.update('mygroup', {
    displayName: 'New Name',
    description: 'Updated desc'
  })

  t.is(updated.displayName, 'New Name')
  t.is(updated.description, 'Updated desc')
})

test('update - throws for nonexistent', async (t) => {
  const mgr = createManager()

  try {
    await mgr.update('nonexistent', { description: 'x' })
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('not found'))
  }
})

test('delete - deletes empty namespace', async (t) => {
  const mgr = createManager()

  await mgr.create('todelete', 'group')
  await mgr.delete('todelete')

  const ns = await mgr.get('todelete')
  t.is(ns, null)
})

test('delete - throws for non-empty namespace', async (t) => {
  const mgr = createManager()

  await mgr.create('notempty', 'group')
  await mgr.addRepo('notempty', 'some-repo')

  try {
    await mgr.delete('notempty')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('still has'))
  }
})

test('delete - throws for nonexistent', async (t) => {
  const mgr = createManager()

  try {
    await mgr.delete('nonexistent')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('not found'))
  }
})

test('list - lists all namespaces', async (t) => {
  const mgr = createManager()

  await mgr.create('group1', 'group')
  await mgr.create('user1', 'user')
  await mgr.create('group2', 'group')

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

test('list - filters by type', async (t) => {
  const mgr = createManager()

  await mgr.create('group1', 'group')
  await mgr.create('user1', 'user')
  await mgr.create('group2', 'group')

  const groups = await mgr.list({ type: 'group' })
  t.is(groups.length, 2)

  const users = await mgr.list({ type: 'user' })
  t.is(users.length, 1)
})

// ========================================
// Repo membership
// ========================================

test('addRepo - adds repo to namespace', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')
  const ns = await mgr.addRepo('mygroup', 'hypercore')

  t.ok(ns.repos.includes('hypercore'))
})

test('addRepo - idempotent (no duplicate)', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')
  await mgr.addRepo('mygroup', 'hypercore')
  const ns = await mgr.addRepo('mygroup', 'hypercore')

  t.is(ns.repos.filter(r => r === 'hypercore').length, 1)
})

test('addRepo - removes from other namespace first', async (t) => {
  const mgr = createManager()

  await mgr.create('group1', 'group')
  await mgr.create('group2', 'group')

  await mgr.addRepo('group1', 'hypercore')
  await mgr.addRepo('group2', 'hypercore')

  const g1 = await mgr.get('group1')
  const g2 = await mgr.get('group2')

  t.absent(g1.repos.includes('hypercore'))
  t.ok(g2.repos.includes('hypercore'))
})

test('addRepo - throws for nonexistent namespace', async (t) => {
  const mgr = createManager()

  try {
    await mgr.addRepo('nonexistent', 'repo')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('not found'))
  }
})

test('removeRepo - removes repo from namespace', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')
  await mgr.addRepo('mygroup', 'hypercore')
  const ns = await mgr.removeRepo('mygroup', 'hypercore')

  t.absent(ns.repos.includes('hypercore'))
})

test('removeRepo - throws if repo not in namespace', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')

  try {
    await mgr.removeRepo('mygroup', 'not-here')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('not in namespace'))
  }
})

test('moveRepo - moves between namespaces', async (t) => {
  const mgr = createManager()

  await mgr.create('from', 'group')
  await mgr.create('to', 'group')

  await mgr.addRepo('from', 'hypercore')
  await mgr.moveRepo('hypercore', 'from', 'to')

  const fromNs = await mgr.get('from')
  const toNs = await mgr.get('to')

  t.absent(fromNs.repos.includes('hypercore'))
  t.ok(toNs.repos.includes('hypercore'))
})

// ========================================
// Group members
// ========================================

test('addMember - adds member to group', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')
  const ns = await mgr.addMember('mygroup', 'user123', 'admin')

  t.is(ns.members.length, 1)
  t.is(ns.members[0].userId, 'user123')
  t.is(ns.members[0].role, 'admin')
})

test('addMember - updates role for existing member', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')
  await mgr.addMember('mygroup', 'user123', 'member')
  const ns = await mgr.addMember('mygroup', 'user123', 'admin')

  t.is(ns.members.length, 1)
  t.is(ns.members[0].role, 'admin')
})

test('addMember - rejects for user namespace', async (t) => {
  const mgr = createManager()

  await mgr.create('alice', 'user')

  try {
    await mgr.addMember('alice', 'user123', 'member')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('group'))
  }
})

test('removeMember - removes member', async (t) => {
  const mgr = createManager()

  await mgr.create('mygroup', 'group')
  await mgr.addMember('mygroup', 'user123', 'member')
  const ns = await mgr.removeMember('mygroup', 'user123')

  t.is(ns.members.length, 0)
})

test('getUserNamespaces - returns namespaces user belongs to', async (t) => {
  const mgr = createManager()

  await mgr.create('group1', 'group')
  await mgr.create('group2', 'group')
  await mgr.create('alice', 'user', { userId: 'user123' })

  await mgr.addMember('group1', 'user123', 'member')

  const namespaces = await mgr.getUserNamespaces('user123')
  t.is(namespaces.length, 2) // group1 (member) + alice (user)
})

// ========================================
// Startup
// ========================================

test('ensureDefaultNamespace - creates if missing', async (t) => {
  const mgr = createManager()

  const ns = await mgr.ensureDefaultNamespace('local')
  t.is(ns.name, 'local')
  t.is(ns.type, 'group')
})

test('ensureDefaultNamespace - returns existing', async (t) => {
  const mgr = createManager()

  await mgr.ensureDefaultNamespace('local')
  const ns = await mgr.ensureDefaultNamespace('local')
  t.is(ns.name, 'local')
})

// ========================================
// Migration
// ========================================

test('migrateExistingRepos - creates namespaces from categories', async (t) => {
  const mgr = createManager()

  const repos = [
    { name: 'hypercore', category: 'hyper-fleeky' },
    { name: 'hyperbee', category: 'hyper-fleeky' },
    { name: 'wirecore', category: 'evbogue' },
    { name: 'random', category: 'uncategorized' }
  ]

  const result = await mgr.migrateExistingRepos(repos)

  t.ok(result.created >= 2, 'should create namespaces for categories')
  t.ok(result.assigned >= 3, 'should assign repos')

  const hf = await mgr.get('hyper-fleeky')
  t.ok(hf, 'hyper-fleeky namespace should exist')
  t.ok(hf.repos.includes('hypercore'))
  t.ok(hf.repos.includes('hyperbee'))

  const ev = await mgr.get('evbogue')
  t.ok(ev)
  t.ok(ev.repos.includes('wirecore'))

  // 'uncategorized' maps to 'local'
  const local = await mgr.get('local')
  t.ok(local)
  t.ok(local.repos.includes('random'))
})

test('migrateExistingRepos - idempotent', async (t) => {
  const mgr = createManager()

  const repos = [
    { name: 'hypercore', category: 'hyper-fleeky' }
  ]

  await mgr.migrateExistingRepos(repos)
  const result2 = await mgr.migrateExistingRepos(repos)

  t.is(result2.skipped, 1, 'second run should skip already-assigned repo')

  const hf = await mgr.get('hyper-fleeky')
  t.is(hf.repos.filter(r => r === 'hypercore').length, 1, 'no duplicates')
})