diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index ced0fdc..525ee87 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -76,6 +76,73 @@ const DEFAULT_SETTINGS: SettingsData = { } } +/** + * Check if value is a plain object + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Deep merge two objects, only updating fields present in target + * Preserves all fields from source that are not in target + */ +function deepMerge(source: T, target: Partial): T { + const result = { ...source } + + for (const key in target) { + if (key in target) { + const targetValue = target[key] + const sourceValue = result[key] + + if (isObject(targetValue) && isObject(sourceValue)) { + result[key] = deepMerge(sourceValue, targetValue) + } else if (targetValue !== undefined) { + result[key] = targetValue as T[Extract] + } + } + } + + return result +} + +/** + * UI editable field whitelist + * Fields that can be modified through the settings UI + */ +const UI_EDITABLE_FIELDS: string[] = [ + 'erp.url', + 'erp.username', + 'erp.password', + // Add more fields as UI expands +] + +/** + * Validate that settings only contain editable fields + */ +function validateEditableFields(settings: Partial): { + valid: boolean + invalidFields: string[] +} { + const invalidFields: string[] = [] + + for (const [section, values] of Object.entries(settings)) { + if (values && typeof values === 'object') { + for (const field of Object.keys(values)) { + const fieldPath = `${section}.${field}` + if (!UI_EDITABLE_FIELDS.includes(fieldPath)) { + invalidFields.push(fieldPath) + } + } + } + } + + return { + valid: invalidFields.length === 0, + invalidFields + } +} + /** * Configuration Manager Class */ diff --git a/tests/main/services/config/config-manager.test.ts b/tests/main/services/config/config-manager.test.ts new file mode 100644 index 0000000..35440de --- /dev/null +++ b/tests/main/services/config/config-manager.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { ConfigManager } from '@services/config/config-manager' +import type { SettingsData } from '@types/settings.types' + +describe('ConfigManager - deep merge utilities', () => { + it('should deep merge objects, updating only specified fields', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + // Setup initial state + const initial: SettingsData = { + erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, + database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' }, + extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, + validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, + execution: { dryRun: false } + } + + // Load initial settings + await manager.saveAllSettings(initial) + + // Partial update + const partial = { + erp: { url: 'http://new.com' } + } + + const result = await manager.savePartialSettings(partial) + + expect(result.success).toBe(true) + + const current = manager.getAllSettings() + + // Updated field + expect(current.erp.url).toBe('http://new.com') + + // Preserved fields + expect(current.erp.username).toBe('user1') + expect(current.database.dbType).toBe('mysql') + expect(current.paths.dataDir).toBe('/data') + }) +})