import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ConnectionNotFoundError, configHash, createRegistry, RegistryError } from '../../../src/config/registry.js' import { writeStore } from '../../../src/config/store.js' import type { ConnectionConfig } from '../../../src/config/types.js' const conn = (name: string, extra: Partial = {}): ConnectionConfig => ({ name, type: 'postgres', host: 'localhost', user: 'postgres', readonly: false, ...extra }) describe('registry', () => { let dir: string let storePath: string let configPath: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dbmole-registry-')) storePath = join(dir, 'connections.json') configPath = join(dir, 'config.json') vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { rmSync(dir, { recursive: true, force: true }) vi.restoreAllMocks() }) it('merges layers with env > config > store priority', () => { writeStore(storePath, [conn('a', { host: 'from-store' }), conn('s')]) writeFileSync( configPath, JSON.stringify({ connections: [conn('a', { host: 'from-config' }), conn('c')] }) ) const env = { DBMOLE_CONNECTIONS: JSON.stringify([conn('a', { host: 'from-env' })]) } const registry = createRegistry({ storePath, configPath, env }) const list = registry.list() expect(list.map((r) => r.config.name)).toEqual(['a', 'c', 's']) expect(registry.resolve('a').config.host).toBe('from-env') expect(registry.resolve('a').source).toBe('env') expect(registry.resolve('c').source).toBe('config') expect(registry.resolve('s').source).toBe('store') }) it('resolve throws with available names listed', () => { writeStore(storePath, [conn('only')]) const registry = createRegistry({ storePath, env: {} }) expect(() => registry.resolve('nope')).toThrow(ConnectionNotFoundError) expect(() => registry.resolve('nope')).toThrow(/only/) }) it('sees external store edits without recreation (hot reload)', () => { const registry = createRegistry({ storePath, env: {} }) expect(registry.list()).toEqual([]) writeStore(storePath, [conn('late')]) expect(registry.resolve('late').config.name).toBe('late') }) it('add writes to store and rejects duplicates from any layer', () => { const env = { DBMOLE_CONNECTIONS: JSON.stringify([conn('envy')]) } const registry = createRegistry({ storePath, env }) const added = registry.add(conn('fresh')) expect(added.source).toBe('store') expect(registry.resolve('fresh').source).toBe('store') expect(() => registry.add(conn('fresh'))).toThrow(RegistryError) expect(() => registry.add(conn('envy'))).toThrow(/env/) }) it('update patches fields, removes them via null, and revalidates', () => { writeStore(storePath, [conn('u', { database: 'old', port: 5433 })]) const registry = createRegistry({ storePath, env: {} }) const updated = registry.update('u', { database: 'new', port: null, readonly: true }) expect(updated.config.database).toBe('new') expect(updated.config.port).toBeUndefined() expect(updated.config.readonly).toBe(true) expect(registry.resolve('u').config.database).toBe('new') expect(() => registry.update('u', { host: '' })).toThrow() }) it('update can rename unless the new name is taken', () => { writeStore(storePath, [conn('old-name'), conn('taken')]) const registry = createRegistry({ storePath, env: {} }) 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 and remove refuse non-store connections', () => { const env = { DBMOLE_CONNECTIONS: JSON.stringify([conn('envy')]) } const registry = createRegistry({ storePath, env }) expect(() => registry.update('envy', { host: 'x' })).toThrow(/env/) expect(() => registry.remove('envy')).toThrow(/env/) }) it('remove deletes from store', () => { writeStore(storePath, [conn('gone'), conn('stays')]) const registry = createRegistry({ storePath, env: {} }) registry.remove('gone') expect(() => registry.resolve('gone')).toThrow(ConnectionNotFoundError) expect(registry.resolve('stays').config.name).toBe('stays') }) it('update and remove of unknown names throw not-found', () => { const registry = createRegistry({ storePath, env: {} }) expect(() => registry.update('ghost', {})).toThrow(ConnectionNotFoundError) expect(() => registry.remove('ghost')).toThrow(ConnectionNotFoundError) }) }) describe('configHash', () => { it('is stable across key order', () => { const a = conn('h', { port: 1, database: 'd' }) const b = { database: 'd', port: 1, ...conn('h') } as ConnectionConfig expect(configHash(a)).toBe(configHash(b)) }) it('changes when a value changes', () => { expect(configHash(conn('h'))).not.toBe(configHash(conn('h', { port: 9 }))) }) })