396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
/**
|
|
* @fileoverview Integration tests for ConfigManager
|
|
* Tests the orchestration of all configuration services
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { ConfigManager } from './config-manager.js';
|
|
import { DEFAULT_CONFIG_VALUES } from '../interfaces/configuration.interface.js';
|
|
import { ConfigLoader } from './services/config-loader.service.js';
|
|
import { ConfigMerger } from './services/config-merger.service.js';
|
|
import { RuntimeStateManager } from './services/runtime-state-manager.service.js';
|
|
import { ConfigPersistence } from './services/config-persistence.service.js';
|
|
import { EnvironmentConfigProvider } from './services/environment-config-provider.service.js';
|
|
|
|
// Mock all services
|
|
vi.mock('./services/config-loader.service.js');
|
|
vi.mock('./services/config-merger.service.js');
|
|
vi.mock('./services/runtime-state-manager.service.js');
|
|
vi.mock('./services/config-persistence.service.js');
|
|
vi.mock('./services/environment-config-provider.service.js');
|
|
|
|
describe('ConfigManager', () => {
|
|
let manager: ConfigManager;
|
|
const testProjectRoot = '/test/project';
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
|
|
// Clear environment variables
|
|
Object.keys(process.env).forEach((key) => {
|
|
if (key.startsWith('TASKMASTER_')) {
|
|
delete process.env[key];
|
|
}
|
|
});
|
|
|
|
// Setup default mock behaviors
|
|
vi.mocked(ConfigLoader).mockImplementation(
|
|
() =>
|
|
({
|
|
getDefaultConfig: vi.fn().mockReturnValue({
|
|
models: { main: 'default-model', fallback: 'fallback-model' },
|
|
storage: { type: 'file' },
|
|
version: '1.0.0'
|
|
}),
|
|
loadLocalConfig: vi.fn().mockResolvedValue(null),
|
|
loadGlobalConfig: vi.fn().mockResolvedValue(null),
|
|
hasLocalConfig: vi.fn().mockResolvedValue(false),
|
|
hasGlobalConfig: vi.fn().mockResolvedValue(false)
|
|
}) as any
|
|
);
|
|
|
|
vi.mocked(ConfigMerger).mockImplementation(
|
|
() =>
|
|
({
|
|
addSource: vi.fn(),
|
|
clearSources: vi.fn(),
|
|
merge: vi.fn().mockReturnValue({
|
|
models: { main: 'merged-model', fallback: 'fallback-model' },
|
|
storage: { type: 'file' }
|
|
}),
|
|
getSources: vi.fn().mockReturnValue([]),
|
|
hasSource: vi.fn().mockReturnValue(false),
|
|
removeSource: vi.fn().mockReturnValue(false)
|
|
}) as any
|
|
);
|
|
|
|
vi.mocked(RuntimeStateManager).mockImplementation(
|
|
() =>
|
|
({
|
|
loadState: vi.fn().mockResolvedValue({ activeTag: 'master' }),
|
|
saveState: vi.fn().mockResolvedValue(undefined),
|
|
getCurrentTag: vi.fn().mockReturnValue('master'),
|
|
setCurrentTag: vi.fn().mockResolvedValue(undefined),
|
|
getState: vi.fn().mockReturnValue({ activeTag: 'master' }),
|
|
updateMetadata: vi.fn().mockResolvedValue(undefined),
|
|
clearState: vi.fn().mockResolvedValue(undefined)
|
|
}) as any
|
|
);
|
|
|
|
vi.mocked(ConfigPersistence).mockImplementation(
|
|
() =>
|
|
({
|
|
saveConfig: vi.fn().mockResolvedValue(undefined),
|
|
configExists: vi.fn().mockResolvedValue(false),
|
|
deleteConfig: vi.fn().mockResolvedValue(undefined),
|
|
getBackups: vi.fn().mockResolvedValue([]),
|
|
restoreFromBackup: vi.fn().mockResolvedValue(undefined)
|
|
}) as any
|
|
);
|
|
|
|
vi.mocked(EnvironmentConfigProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
loadConfig: vi.fn().mockReturnValue({}),
|
|
getRuntimeState: vi.fn().mockReturnValue({}),
|
|
hasEnvVar: vi.fn().mockReturnValue(false),
|
|
getAllTaskmasterEnvVars: vi.fn().mockReturnValue({}),
|
|
addMapping: vi.fn(),
|
|
getMappings: vi.fn().mockReturnValue([])
|
|
}) as any
|
|
);
|
|
|
|
// Since constructor is private, we need to use the factory method
|
|
// But for testing, we'll create a test instance using create()
|
|
manager = await ConfigManager.create(testProjectRoot);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
describe('creation', () => {
|
|
it('should initialize all services when created', () => {
|
|
// Services should have been initialized during beforeEach
|
|
expect(ConfigLoader).toHaveBeenCalledWith(testProjectRoot);
|
|
expect(ConfigMerger).toHaveBeenCalled();
|
|
expect(RuntimeStateManager).toHaveBeenCalledWith(testProjectRoot);
|
|
expect(ConfigPersistence).toHaveBeenCalledWith(testProjectRoot);
|
|
expect(EnvironmentConfigProvider).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('create (factory method)', () => {
|
|
it('should create and initialize manager', async () => {
|
|
const createdManager = await ConfigManager.create(testProjectRoot);
|
|
|
|
expect(createdManager).toBeInstanceOf(ConfigManager);
|
|
expect(createdManager.getConfig()).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('initialization (via create)', () => {
|
|
it('should load and merge all configuration sources', () => {
|
|
// Manager was created in beforeEach, so initialization already happened
|
|
const loader = (manager as any).loader;
|
|
const merger = (manager as any).merger;
|
|
const stateManager = (manager as any).stateManager;
|
|
const envProvider = (manager as any).envProvider;
|
|
|
|
// Verify loading sequence
|
|
expect(merger.clearSources).toHaveBeenCalled();
|
|
expect(loader.getDefaultConfig).toHaveBeenCalled();
|
|
expect(loader.loadGlobalConfig).toHaveBeenCalled();
|
|
expect(loader.loadLocalConfig).toHaveBeenCalled();
|
|
expect(envProvider.loadConfig).toHaveBeenCalled();
|
|
expect(merger.merge).toHaveBeenCalled();
|
|
expect(stateManager.loadState).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should add sources with correct precedence during creation', () => {
|
|
const merger = (manager as any).merger;
|
|
|
|
// Check that sources were added with correct precedence
|
|
expect(merger.addSource).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'defaults',
|
|
precedence: 0
|
|
})
|
|
);
|
|
|
|
// Note: local and env sources may not be added if they don't exist
|
|
// The mock setup determines what gets called
|
|
});
|
|
});
|
|
|
|
describe('configuration access', () => {
|
|
// Manager is already initialized in the main beforeEach
|
|
|
|
it('should return merged configuration', () => {
|
|
const config = manager.getConfig();
|
|
expect(config).toEqual({
|
|
models: { main: 'merged-model', fallback: 'fallback-model' },
|
|
storage: { type: 'file' }
|
|
});
|
|
});
|
|
|
|
it('should return storage configuration', () => {
|
|
const storage = manager.getStorageConfig();
|
|
expect(storage).toEqual({ type: 'file' });
|
|
});
|
|
|
|
it('should return API storage configuration when configured', async () => {
|
|
// Create a new instance with API storage config
|
|
vi.mocked(ConfigMerger).mockImplementationOnce(
|
|
() =>
|
|
({
|
|
addSource: vi.fn(),
|
|
clearSources: vi.fn(),
|
|
merge: vi.fn().mockReturnValue({
|
|
storage: {
|
|
type: 'api',
|
|
apiEndpoint: 'https://api.example.com',
|
|
apiAccessToken: 'token123'
|
|
}
|
|
}),
|
|
getSources: vi.fn().mockReturnValue([]),
|
|
hasSource: vi.fn().mockReturnValue(false),
|
|
removeSource: vi.fn().mockReturnValue(false)
|
|
}) as any
|
|
);
|
|
|
|
const apiManager = await ConfigManager.create(testProjectRoot);
|
|
|
|
const storage = apiManager.getStorageConfig();
|
|
expect(storage).toEqual({
|
|
type: 'api',
|
|
apiEndpoint: 'https://api.example.com',
|
|
apiAccessToken: 'token123'
|
|
});
|
|
});
|
|
|
|
it('should return model configuration', () => {
|
|
const models = manager.getModelConfig();
|
|
expect(models).toEqual({
|
|
main: 'merged-model',
|
|
fallback: 'fallback-model'
|
|
});
|
|
});
|
|
|
|
it('should return default models when not configured', () => {
|
|
// Update the mock for current instance
|
|
const merger = (manager as any).merger;
|
|
merger.merge.mockReturnValue({});
|
|
// Force re-merge
|
|
(manager as any).config = merger.merge();
|
|
|
|
const models = manager.getModelConfig();
|
|
expect(models).toEqual({
|
|
main: DEFAULT_CONFIG_VALUES.MODELS.MAIN,
|
|
fallback: DEFAULT_CONFIG_VALUES.MODELS.FALLBACK
|
|
});
|
|
});
|
|
|
|
it('should return response language', () => {
|
|
const language = manager.getResponseLanguage();
|
|
expect(language).toBe('English');
|
|
});
|
|
|
|
it('should return custom response language', () => {
|
|
// Update config for current instance
|
|
(manager as any).config = {
|
|
custom: { responseLanguage: 'Spanish' }
|
|
};
|
|
|
|
const language = manager.getResponseLanguage();
|
|
expect(language).toBe('Spanish');
|
|
});
|
|
|
|
it('should return project root', () => {
|
|
expect(manager.getProjectRoot()).toBe(testProjectRoot);
|
|
});
|
|
|
|
it('should check if API is explicitly configured', () => {
|
|
expect(manager.isApiExplicitlyConfigured()).toBe(false);
|
|
});
|
|
|
|
it('should detect when API is explicitly configured', () => {
|
|
// Update config for current instance
|
|
(manager as any).config = {
|
|
storage: {
|
|
type: 'api',
|
|
apiEndpoint: 'https://api.example.com',
|
|
apiAccessToken: 'token'
|
|
}
|
|
};
|
|
|
|
expect(manager.isApiExplicitlyConfigured()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('runtime state', () => {
|
|
// Manager is already initialized in the main beforeEach
|
|
|
|
it('should get active tag from state manager', () => {
|
|
const tag = manager.getActiveTag();
|
|
expect(tag).toBe('master');
|
|
});
|
|
|
|
it('should set active tag through state manager', async () => {
|
|
await manager.setActiveTag('feature-branch');
|
|
|
|
const stateManager = (manager as any).stateManager;
|
|
expect(stateManager.setCurrentTag).toHaveBeenCalledWith('feature-branch');
|
|
});
|
|
});
|
|
|
|
describe('configuration updates', () => {
|
|
// Manager is already initialized in the main beforeEach
|
|
|
|
it('should update configuration and save', async () => {
|
|
const updates = {
|
|
models: { main: 'new-model', fallback: 'fallback-model' }
|
|
};
|
|
await manager.updateConfig(updates);
|
|
|
|
const persistence = (manager as any).persistence;
|
|
expect(persistence.saveConfig).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should re-initialize after update to maintain precedence', async () => {
|
|
const merger = (manager as any).merger;
|
|
merger.clearSources.mockClear();
|
|
|
|
await manager.updateConfig({ custom: { test: 'value' } });
|
|
|
|
expect(merger.clearSources).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should set response language', async () => {
|
|
await manager.setResponseLanguage('French');
|
|
|
|
const persistence = (manager as any).persistence;
|
|
expect(persistence.saveConfig).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
custom: { responseLanguage: 'French' }
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should save configuration with options', async () => {
|
|
await manager.saveConfig();
|
|
|
|
const persistence = (manager as any).persistence;
|
|
expect(persistence.saveConfig).toHaveBeenCalledWith(expect.any(Object), {
|
|
createBackup: true,
|
|
atomic: true
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('utilities', () => {
|
|
// Manager is already initialized in the main beforeEach
|
|
|
|
it('should reset configuration to defaults', async () => {
|
|
await manager.reset();
|
|
|
|
const persistence = (manager as any).persistence;
|
|
const stateManager = (manager as any).stateManager;
|
|
|
|
expect(persistence.deleteConfig).toHaveBeenCalled();
|
|
expect(stateManager.clearState).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should re-initialize after reset', async () => {
|
|
const merger = (manager as any).merger;
|
|
merger.clearSources.mockClear();
|
|
|
|
await manager.reset();
|
|
|
|
expect(merger.clearSources).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should get configuration sources for debugging', () => {
|
|
const merger = (manager as any).merger;
|
|
const mockSources = [{ name: 'test', config: {}, precedence: 1 }];
|
|
merger.getSources.mockReturnValue(mockSources);
|
|
|
|
const sources = manager.getConfigSources();
|
|
|
|
expect(sources).toEqual(mockSources);
|
|
});
|
|
|
|
it('should return no-op function for watch (not implemented)', () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const callback = vi.fn();
|
|
|
|
const unsubscribe = manager.watch(callback);
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'Configuration watching not yet implemented'
|
|
);
|
|
expect(unsubscribe).toBeInstanceOf(Function);
|
|
|
|
// Calling unsubscribe should not throw
|
|
expect(() => unsubscribe()).not.toThrow();
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should handle missing services gracefully', async () => {
|
|
// Even if a service fails, manager should still work
|
|
const loader = (manager as any).loader;
|
|
loader.loadLocalConfig.mockRejectedValue(new Error('File error'));
|
|
|
|
// Creating a new manager should not throw even if service fails
|
|
await expect(
|
|
ConfigManager.create(testProjectRoot)
|
|
).resolves.not.toThrow();
|
|
});
|
|
});
|
|
});
|