Files
dbmole-mcp/packages/core/test/unit/config/registry.test.ts
T

209 lines
8.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import * as z from 'zod'
import {
ConnectionNotFoundError,
configHash,
createRegistry,
type NamedConfig,
type RegistryDeps,
RegistryError
} from '../../../src/config/registry.js'
const schema = z
.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/),
type: z.enum(['alpha', 'beta']),
host: z.string().min(1),
port: z.number().int().positive().optional(),
label: z.string().optional()
})
.strict()
type Config = z.infer<typeof schema>
const conn = (name: string, extra: Partial<Config> = {}): Config => ({
name,
type: 'alpha',
host: 'localhost',
...extra
})
// In-memory deps: a closure-backed store plus static config/env arrays.
const makeDeps = (
initial: { store?: Config[]; config?: Config[]; env?: Config[] } = {}
): { deps: RegistryDeps<Config>; readStore: () => Config[] } => {
let store: Config[] = initial.store ?? []
const deps: RegistryDeps<Config> = {
schema,
readStore: () => store,
writeStore: (configs) => {
store = configs
},
readConfigFile: () => initial.config ?? [],
readEnvConnections: () => initial.env ?? []
}
return { deps, readStore: () => store }
}
describe('createRegistry', () => {
it('merges layers with store > config > env priority', () => {
const { deps } = makeDeps({
store: [conn('a', { host: 'from-store' }), conn('s')],
config: [conn('a', { host: 'from-config' }), conn('c')],
env: [conn('a', { host: 'from-env' }), conn('e')]
})
const registry = createRegistry(deps)
// store wins for 'a'; the later layers loop overwrites earlier ones, so
// ordering store->config->env means env is applied last and wins. Mirror
// the dbmole binder which feeds store first, env last: env overrides.
expect(registry.resolve('a').source).toBe('env')
expect(registry.resolve('a').config.host).toBe('from-env')
expect(registry.resolve('c').source).toBe('config')
expect(registry.resolve('e').source).toBe('env')
expect(registry.resolve('s').source).toBe('store')
})
it('list returns connections sorted by name', () => {
const { deps } = makeDeps({ store: [conn('zeta'), conn('alpha'), conn('mid')] })
const registry = createRegistry(deps)
expect(registry.list().map((r) => r.config.name)).toEqual(['alpha', 'mid', 'zeta'])
})
it('resolve throws not-found with available names listed', () => {
const { deps } = makeDeps({ store: [conn('only')] })
const registry = createRegistry(deps)
expect(() => registry.resolve('nope')).toThrow(ConnectionNotFoundError)
expect(() => registry.resolve('nope')).toThrow(/only/)
})
it('add persists to the store and rejects duplicates from any layer', () => {
const { deps, readStore } = makeDeps({ env: [conn('envy')] })
const registry = createRegistry(deps)
const added = registry.add(conn('fresh'))
expect(added.source).toBe('store')
expect(readStore().map((c) => c.name)).toEqual(['fresh'])
expect(() => registry.add(conn('fresh'))).toThrow(RegistryError)
expect(() => registry.add(conn('envy'))).toThrow(/env/)
})
it('update sets fields and deletes them via null', () => {
const { deps } = makeDeps({ store: [conn('u', { label: 'old', port: 100 })] })
const registry = createRegistry(deps)
const updated = registry.update('u', { label: 'new', port: null })
expect(updated.config.label).toBe('new')
expect(updated.config.port).toBeUndefined()
expect(registry.resolve('u').config.label).toBe('new')
})
it('keeps the port when type is unchanged', () => {
const { deps } = makeDeps({ store: [conn('same', { type: 'alpha', port: 100 })] })
const registry = createRegistry(deps)
const updated = registry.update('same', { label: 'x' })
expect(updated.config.port).toBe(100)
})
it('drops the inherited port when switching type without an explicit port', () => {
const { deps } = makeDeps({ store: [conn('switch', { type: 'alpha', port: 100 })] })
const registry = createRegistry(deps)
const updated = registry.update('switch', { type: 'beta' })
expect(updated.config.type).toBe('beta')
expect(updated.config.port).toBeUndefined()
})
it('keeps an explicit port when switching type', () => {
const { deps } = makeDeps({ store: [conn('switch', { type: 'alpha', port: 100 })] })
const registry = createRegistry(deps)
const updated = registry.update('switch', { type: 'beta', port: 200 })
expect(updated.config.port).toBe(200)
})
it('clears the port when switching type with an explicit null', () => {
const { deps } = makeDeps({ store: [conn('switch', { type: 'alpha', port: 100 })] })
const registry = createRegistry(deps)
const updated = registry.update('switch', { type: 'beta', port: null })
expect(updated.config.port).toBeUndefined()
})
it('renames unless the new name is taken', () => {
const { deps } = makeDeps({ store: [conn('old-name'), conn('taken')] })
const registry = createRegistry(deps)
registry.update('old-name', { name: 'new-name' })
expect(registry.resolve('new-name').config.name).toBe('new-name')
expect(() => registry.resolve('old-name')).toThrow(ConnectionNotFoundError)
expect(() => registry.update('new-name', { name: 'taken' })).toThrow(RegistryError)
})
it('update of an unknown name throws not-found', () => {
const { deps } = makeDeps()
const registry = createRegistry(deps)
expect(() => registry.update('ghost', {})).toThrow(ConnectionNotFoundError)
})
it('remove deletes from the store', () => {
const { deps } = makeDeps({ store: [conn('gone'), conn('stays')] })
const registry = createRegistry(deps)
registry.remove('gone')
expect(() => registry.resolve('gone')).toThrow(ConnectionNotFoundError)
expect(registry.resolve('stays').config.name).toBe('stays')
})
it('refuses to update or remove a connection from a read-only source', () => {
const { deps } = makeDeps({ env: [conn('envy')], config: [conn('cfg')] })
const registry = createRegistry(deps)
expect(() => registry.update('envy', { host: 'x' })).toThrow(/env/)
expect(() => registry.remove('envy')).toThrow(/env/)
expect(() => registry.update('cfg', { host: 'x' })).toThrow(/config/)
expect(() => registry.remove('cfg')).toThrow(/config/)
})
it('throws not-found if the store entry vanishes after the source check', () => {
// Defensive guard: requireStoreEntry sees the entry, but a concurrent
// edit empties the store before update re-reads it, so findIndex misses.
let reads = 0
const deps: RegistryDeps<Config> = {
schema,
// First read (requireStoreEntry) sees the entry; later reads are empty.
readStore: () => {
reads += 1
return reads === 1 ? [conn('vanish')] : []
},
writeStore: () => {},
readConfigFile: () => [],
readEnvConnections: () => []
}
const registry = createRegistry(deps)
expect(() => registry.update('vanish', { host: 'x' })).toThrow(ConnectionNotFoundError)
})
it('reflects external store edits without recreation', () => {
const store: Config[] = []
const deps: RegistryDeps<Config> = {
schema,
readStore: () => store,
writeStore: (configs) => {
store.splice(0, store.length, ...configs)
},
readConfigFile: () => [],
readEnvConnections: () => []
}
const registry = createRegistry(deps)
expect(registry.list()).toEqual([])
store.push(conn('late'))
expect(registry.resolve('late').config.name).toBe('late')
})
})
describe('configHash', () => {
it('is stable across key order', () => {
const a: NamedConfig & Record<string, unknown> = { name: 'h', port: 1, host: 'x' }
const b: NamedConfig & Record<string, unknown> = { host: 'x', name: 'h', port: 1 }
expect(configHash(a)).toBe(configHash(b))
})
it('changes when a nested value changes', () => {
expect(configHash({ name: 'h', tags: ['a', 'b'] })).not.toBe(
configHash({ name: 'h', tags: ['a', 'c'] })
)
})
})