/**
* 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')
})