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 const conn = (name: string, extra: Partial = {}): 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; readStore: () => Config[] } => { let store: Config[] = initial.store ?? [] const deps: RegistryDeps = { 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 = { 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 = { 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 = { name: 'h', port: 1, host: 'x' } const b: NamedConfig & Record = { 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'] }) ) }) })