🐛 Fixed configuration merge override issue
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,23 +67,20 @@ func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *
|
|||||||
result := &MigrationResult{
|
result := &MigrationResult{
|
||||||
MissingFields: missingFields,
|
MissingFields: missingFields,
|
||||||
Migrated: len(missingFields) > 0,
|
Migrated: len(missingFields) > 0,
|
||||||
Description: fmt.Sprintf("Detected %d missing configuration fields", len(missingFields)),
|
Description: fmt.Sprintf("Found %d missing fields", len(missingFields)),
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no missing fields, return early
|
// No migration needed
|
||||||
if !result.Migrated {
|
if !result.Migrated {
|
||||||
cm.logger.Info("No missing configuration fields detected")
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only create backup if we actually need to migrate (has missing fields)
|
// Create backup before migration
|
||||||
if len(missingFields) > 0 {
|
backupPath, err := cm.createBackup()
|
||||||
if backupPath, err := cm.createBackup(); err != nil {
|
if err != nil {
|
||||||
cm.logger.Error("Failed to create backup", "error", err)
|
return result, fmt.Errorf("backup creation failed: %w", err)
|
||||||
} else {
|
}
|
||||||
result.BackupPath = backupPath
|
result.BackupPath = backupPath
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge missing fields from default config
|
// Merge missing fields from default config
|
||||||
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
|
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
|
||||||
@@ -94,19 +92,25 @@ func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *
|
|||||||
return result, fmt.Errorf("failed to save updated config: %w", err)
|
return result, fmt.Errorf("failed to save updated config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cm.logger.Info("Configuration migration completed successfully", "migratedFields", len(missingFields))
|
// Clean up backup on success
|
||||||
|
if backupPath != "" {
|
||||||
|
if err := os.Remove(backupPath); err != nil {
|
||||||
|
cm.logger.Error("Failed to remove backup", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectMissingFields detects missing configuration fields
|
// detectMissingFields detects missing configuration fields
|
||||||
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
|
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
|
||||||
var missingFields []string
|
var missing []string
|
||||||
cm.findMissingFieldsRecursive("", defaultConfig, current, &missingFields)
|
cm.findMissing("", defaultConfig, current, &missing)
|
||||||
return missingFields
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
// findMissingFieldsRecursive recursively finds missing fields
|
// findMissing recursively finds missing fields
|
||||||
func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
|
func (cm *ConfigMigrator) findMissing(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
|
||||||
for key, defaultVal := range defaultMap {
|
for key, defaultVal := range defaultMap {
|
||||||
fullKey := key
|
fullKey := key
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
@@ -115,38 +119,66 @@ func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap,
|
|||||||
|
|
||||||
currentVal, exists := currentMap[key]
|
currentVal, exists := currentMap[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Field is completely missing
|
// Field completely missing - add it
|
||||||
*missing = append(*missing, fullKey)
|
*missing = append(*missing, fullKey)
|
||||||
} else {
|
} else if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
|
||||||
// Check nested structures
|
|
||||||
if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
|
|
||||||
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
|
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
|
||||||
cm.findMissingFieldsRecursive(fullKey, defaultNestedMap, currentNestedMap, missing)
|
// Both are maps, recurse into them
|
||||||
} else {
|
cm.findMissing(fullKey, defaultNestedMap, currentNestedMap, missing)
|
||||||
// Current value is not a map but default is, structure mismatch
|
|
||||||
*missing = append(*missing, fullKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Type mismatch: user has different type, don't recurse
|
||||||
}
|
}
|
||||||
|
// For non-map default values, field exists, preserve user's value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeDefaultFields merges default values for missing fields into current config
|
// mergeDefaultFields merges default values for missing fields into current config
|
||||||
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
|
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
|
||||||
|
actuallyMerged := 0
|
||||||
|
|
||||||
for _, field := range missingFields {
|
for _, field := range missingFields {
|
||||||
defaultValue := defaultConfig.Get(field)
|
// Use Exists() for better semantic checking
|
||||||
if defaultValue != nil {
|
if !current.Exists(field) && defaultConfig.Exists(field) {
|
||||||
|
// Check if setting this field would conflict with existing user values
|
||||||
|
if !cm.wouldCreateTypeConflict(current, field) {
|
||||||
|
if defaultValue := defaultConfig.Get(field); defaultValue != nil {
|
||||||
current.Set(field, defaultValue)
|
current.Set(field, defaultValue)
|
||||||
cm.logger.Debug("Merged missing field", "field", field, "value", defaultValue)
|
actuallyMerged++
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last modified timestamp
|
// Update timestamp if we actually merged fields
|
||||||
|
if actuallyMerged > 0 {
|
||||||
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wouldCreateTypeConflict checks if setting a field would overwrite existing user data
|
||||||
|
func (cm *ConfigMigrator) wouldCreateTypeConflict(current *koanf.Koanf, fieldPath string) bool {
|
||||||
|
parts := strings.Split(fieldPath, ".")
|
||||||
|
|
||||||
|
// Check each parent path to see if user has a non-map value there
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
parentPath := strings.Join(parts[:i], ".")
|
||||||
|
|
||||||
|
// Use Exists() for better semantic checking
|
||||||
|
if current.Exists(parentPath) {
|
||||||
|
if parentValue := current.Get(parentPath); parentValue != nil {
|
||||||
|
// If parent exists and is not a map, setting this field would overwrite it
|
||||||
|
if _, isMap := parentValue.(map[string]interface{}); !isMap {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// createBackup creates a backup of the configuration file
|
// createBackup creates a backup of the configuration file
|
||||||
func (cm *ConfigMigrator) createBackup() (string, error) {
|
func (cm *ConfigMigrator) createBackup() (string, error) {
|
||||||
if _, err := os.Stat(cm.configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(cm.configPath); os.IsNotExist(err) {
|
||||||
@@ -165,7 +197,6 @@ func (cm *ConfigMigrator) createBackup() (string, error) {
|
|||||||
return "", fmt.Errorf("failed to create backup: %w", err)
|
return "", fmt.Errorf("failed to create backup: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cm.logger.Info("Configuration backup created", "path", backupPath)
|
|
||||||
return backupPath, nil
|
return backupPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
jsonparser "github.com/knadh/koanf/parsers/json"
|
jsonparser "github.com/knadh/koanf/parsers/json"
|
||||||
"github.com/knadh/koanf/providers/file"
|
"github.com/knadh/koanf/providers/file"
|
||||||
"github.com/knadh/koanf/v2"
|
"github.com/knadh/koanf/v2"
|
||||||
@@ -158,10 +159,795 @@ func TestConfigMigrator_AutoMigrate(t *testing.T) {
|
|||||||
assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value")
|
assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value")
|
||||||
assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value")
|
assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value")
|
||||||
|
|
||||||
// Check that backup was created
|
// Check that backup was cleaned up after successful migration
|
||||||
backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to list backup files: %v", err)
|
t.Fatalf("Failed to list backup files: %v", err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, 1, len(backupFiles), "One backup file should have been created")
|
assert.Equal(t, 0, len(backupFiles), "Backup file should have been cleaned up after successful migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_NoOverwrite tests that user configuration is never overwritten
|
||||||
|
func TestConfigMigrator_NoOverwrite(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_no_overwrite_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create user config with custom values that differ from defaults
|
||||||
|
userConfig := map[string]interface{}{
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "CustomAppName", // Different from default
|
||||||
|
"version": "2.0.0", // Different from default
|
||||||
|
"theme": "custom", // Different from default
|
||||||
|
},
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"name": "Custom User", // Different from default
|
||||||
|
"email": "custom@example.com", // Different from default
|
||||||
|
"settings": map[string]interface{}{
|
||||||
|
"autoSave": false, // Different from default
|
||||||
|
"language": "zh", // Different from default
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config file
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
// Create migrator and load config
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Create default config with different values
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "DefaultApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "light"
|
||||||
|
defaultConfig.User.Name = "Default User"
|
||||||
|
defaultConfig.User.Email = "default@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true // This should be added
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value" // This should be added
|
||||||
|
defaultConfig.NewSection.Enabled = true // This should be added
|
||||||
|
defaultConfig.NewSection.Value = "new section" // This should be added
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result.Migrated)
|
||||||
|
|
||||||
|
// Verify user values are preserved
|
||||||
|
assert.Equal(t, "CustomAppName", k.String("app.name"), "User's app name should not be overwritten")
|
||||||
|
assert.Equal(t, "2.0.0", k.String("app.version"), "User's version should not be overwritten")
|
||||||
|
assert.Equal(t, "custom", k.String("app.theme"), "User's theme should not be overwritten")
|
||||||
|
assert.Equal(t, "Custom User", k.String("user.name"), "User's name should not be overwritten")
|
||||||
|
assert.Equal(t, "custom@example.com", k.String("user.email"), "User's email should not be overwritten")
|
||||||
|
assert.False(t, k.Bool("user.settings.autoSave"), "User's autoSave should not be overwritten")
|
||||||
|
assert.Equal(t, "zh", k.String("user.settings.language"), "User's language should not be overwritten")
|
||||||
|
|
||||||
|
// Verify missing fields were added with default values
|
||||||
|
assert.True(t, k.Bool("user.settings.newSetting"), "Missing field should be added")
|
||||||
|
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "Missing field should be added")
|
||||||
|
assert.True(t, k.Bool("newSection.enabled"), "Missing section should be added")
|
||||||
|
assert.Equal(t, "new section", k.String("newSection.value"), "Missing section should be added")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_TypeMismatch tests handling of type mismatches
|
||||||
|
func TestConfigMigrator_TypeMismatch(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_type_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create user config where some fields have different types
|
||||||
|
userConfig := map[string]interface{}{
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "TestApp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"theme": "dark",
|
||||||
|
},
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"settings": "simple_string", // This is a string, but default is an object
|
||||||
|
},
|
||||||
|
"newSection": 123, // This is a number, but default is an object
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config file
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
// Create migrator and load config
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Create default config
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "dark"
|
||||||
|
defaultConfig.User.Name = "Test User"
|
||||||
|
defaultConfig.User.Email = "test@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||||
|
defaultConfig.NewSection.Enabled = true
|
||||||
|
defaultConfig.NewSection.Value = "new section"
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Should detect missing fields but refuse to merge them due to type conflicts
|
||||||
|
assert.True(t, result.Migrated, "Migration should be attempted")
|
||||||
|
assert.Greater(t, len(result.MissingFields), 0, "Should detect missing fields even with type mismatch")
|
||||||
|
|
||||||
|
// Verify user's type-mismatched values are preserved (not overwritten)
|
||||||
|
assert.Equal(t, "simple_string", k.String("user.settings"), "User's string value should be preserved")
|
||||||
|
assert.Equal(t, int64(123), k.Int64("newSection"), "User's number value should be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_ComplexNested tests complex nested structure migration
|
||||||
|
func TestConfigMigrator_ComplexNested(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_complex_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Complex user config with deep nesting
|
||||||
|
userConfig := map[string]interface{}{
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "TestApp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"advanced": map[string]interface{}{
|
||||||
|
"logging": map[string]interface{}{
|
||||||
|
"level": "info",
|
||||||
|
"file": "/var/log/app.log",
|
||||||
|
// Missing: format, rotation
|
||||||
|
},
|
||||||
|
"performance": map[string]interface{}{
|
||||||
|
"cache": true,
|
||||||
|
// Missing: timeout, maxConnections
|
||||||
|
},
|
||||||
|
// Missing: security section
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins": map[string]interface{}{
|
||||||
|
"enabled": []string{"plugin1", "plugin2"},
|
||||||
|
// Missing: config section
|
||||||
|
},
|
||||||
|
// Missing: monitoring section
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default config with additional nested fields
|
||||||
|
type ComplexConfig struct {
|
||||||
|
App struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Advanced struct {
|
||||||
|
Logging struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Rotation bool `json:"rotation"`
|
||||||
|
} `json:"logging"`
|
||||||
|
Performance struct {
|
||||||
|
Cache bool `json:"cache"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
MaxConnections int `json:"maxConnections"`
|
||||||
|
} `json:"performance"`
|
||||||
|
Security struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
TokenType string `json:"tokenType"`
|
||||||
|
ExpireTime int `json:"expireTime"`
|
||||||
|
} `json:"security"`
|
||||||
|
} `json:"advanced"`
|
||||||
|
} `json:"app"`
|
||||||
|
Plugins struct {
|
||||||
|
Enabled []string `json:"enabled"`
|
||||||
|
Config struct {
|
||||||
|
LoadOrder []string `json:"loadOrder"`
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
} `json:"config"`
|
||||||
|
} `json:"plugins"`
|
||||||
|
Monitoring struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
} `json:"monitoring"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config file
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
// Create migrator and load config
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Create complete default config
|
||||||
|
defaultConfig := ComplexConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Advanced.Logging.Level = "info"
|
||||||
|
defaultConfig.App.Advanced.Logging.File = "/var/log/app.log"
|
||||||
|
defaultConfig.App.Advanced.Logging.Format = "json"
|
||||||
|
defaultConfig.App.Advanced.Logging.Rotation = true
|
||||||
|
defaultConfig.App.Advanced.Performance.Cache = true
|
||||||
|
defaultConfig.App.Advanced.Performance.Timeout = 30
|
||||||
|
defaultConfig.App.Advanced.Performance.MaxConnections = 100
|
||||||
|
defaultConfig.App.Advanced.Security.Enabled = true
|
||||||
|
defaultConfig.App.Advanced.Security.TokenType = "JWT"
|
||||||
|
defaultConfig.App.Advanced.Security.ExpireTime = 3600
|
||||||
|
defaultConfig.Plugins.Enabled = []string{"plugin1", "plugin2"}
|
||||||
|
defaultConfig.Plugins.Config.LoadOrder = []string{"plugin1", "plugin2"}
|
||||||
|
defaultConfig.Plugins.Config.Settings = map[string]string{"key": "value"}
|
||||||
|
defaultConfig.Monitoring.Enabled = true
|
||||||
|
defaultConfig.Monitoring.Endpoint = "/metrics"
|
||||||
|
defaultConfig.Monitoring.Interval = 60
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result.Migrated)
|
||||||
|
|
||||||
|
// Verify user values are preserved
|
||||||
|
assert.Equal(t, "info", k.String("app.advanced.logging.level"))
|
||||||
|
assert.Equal(t, "/var/log/app.log", k.String("app.advanced.logging.file"))
|
||||||
|
assert.True(t, k.Bool("app.advanced.performance.cache"))
|
||||||
|
|
||||||
|
// Verify missing fields were added
|
||||||
|
assert.Equal(t, "json", k.String("app.advanced.logging.format"))
|
||||||
|
assert.True(t, k.Bool("app.advanced.logging.rotation"))
|
||||||
|
assert.Equal(t, 30, k.Int("app.advanced.performance.timeout"))
|
||||||
|
assert.Equal(t, 100, k.Int("app.advanced.performance.maxConnections"))
|
||||||
|
assert.True(t, k.Bool("app.advanced.security.enabled"))
|
||||||
|
assert.Equal(t, "JWT", k.String("app.advanced.security.tokenType"))
|
||||||
|
assert.Equal(t, 3600, k.Int("app.advanced.security.expireTime"))
|
||||||
|
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.config.loadOrder"))
|
||||||
|
assert.True(t, k.Bool("monitoring.enabled"))
|
||||||
|
assert.Equal(t, "/metrics", k.String("monitoring.endpoint"))
|
||||||
|
assert.Equal(t, 60, k.Int("monitoring.interval"))
|
||||||
|
|
||||||
|
t.Logf("Detected missing fields: %v", result.MissingFields)
|
||||||
|
// Should detect multiple missing fields
|
||||||
|
assert.Greater(t, len(result.MissingFields), 5, "Should detect multiple missing fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_MultipleMigrations tests running migration multiple times
|
||||||
|
func TestConfigMigrator_MultipleMigrations(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_multiple_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
configPath := createTestConfig(t, tempDir)
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
|
||||||
|
// Create default config
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "dark"
|
||||||
|
defaultConfig.User.Name = "Test User"
|
||||||
|
defaultConfig.User.Email = "test@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||||
|
defaultConfig.NewSection.Enabled = true
|
||||||
|
defaultConfig.NewSection.Value = "new section"
|
||||||
|
|
||||||
|
// First migration
|
||||||
|
k1 := koanf.New(".")
|
||||||
|
k1.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
result1, err := migrator.AutoMigrate(defaultConfig, k1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result1.Migrated, "First migration should be performed")
|
||||||
|
|
||||||
|
// Second migration - should detect no missing fields
|
||||||
|
k2 := koanf.New(".")
|
||||||
|
k2.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
result2, err := migrator.AutoMigrate(defaultConfig, k2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, result2.Migrated, "Second migration should not be needed")
|
||||||
|
assert.Equal(t, 0, len(result2.MissingFields), "No fields should be missing in second migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_BackupHandling tests backup creation and cleanup
|
||||||
|
func TestConfigMigrator_BackupHandling(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_backup_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
configPath := createTestConfig(t, tempDir)
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
|
||||||
|
// Create default config
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "dark"
|
||||||
|
defaultConfig.User.Name = "Test User"
|
||||||
|
defaultConfig.User.Email = "test@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||||
|
defaultConfig.NewSection.Enabled = true
|
||||||
|
defaultConfig.NewSection.Value = "new section"
|
||||||
|
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result.Migrated)
|
||||||
|
|
||||||
|
// Backup should be cleaned up after successful migration
|
||||||
|
backupFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
||||||
|
assert.Equal(t, 0, len(backupFiles), "Backup should be cleaned up after successful migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_NoMigrationNeeded tests when no migration is needed
|
||||||
|
func TestConfigMigrator_NoMigrationNeeded(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_no_migration_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create complete config (no missing fields)
|
||||||
|
completeConfig := map[string]interface{}{
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "TestApp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"theme": "dark",
|
||||||
|
},
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"settings": map[string]interface{}{
|
||||||
|
"autoSave": true,
|
||||||
|
"language": "en",
|
||||||
|
"newSetting": true,
|
||||||
|
"newSetting2": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"newSection": map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"value": "new section",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(completeConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Create matching default config
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "dark"
|
||||||
|
defaultConfig.User.Name = "Test User"
|
||||||
|
defaultConfig.User.Email = "test@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||||
|
defaultConfig.NewSection.Enabled = true
|
||||||
|
defaultConfig.NewSection.Value = "new section"
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, result.Migrated, "No migration should be needed")
|
||||||
|
assert.Equal(t, 0, len(result.MissingFields), "No fields should be missing")
|
||||||
|
|
||||||
|
// No backup should be created
|
||||||
|
backupFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
||||||
|
assert.Equal(t, 0, len(backupFiles), "No backup should be created when migration is not needed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_PartialOverride tests partial user override scenarios
|
||||||
|
func TestConfigMigrator_PartialOverride(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_partial_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create user config with partial overrides
|
||||||
|
userConfig := map[string]interface{}{
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "CustomApp",
|
||||||
|
"version": "2.0.0", // User custom value
|
||||||
|
// Missing: theme (should use default)
|
||||||
|
},
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"name": "Custom User",
|
||||||
|
"email": "custom@example.com",
|
||||||
|
"settings": map[string]interface{}{
|
||||||
|
"autoSave": false, // User custom value
|
||||||
|
"language": "zh", // User custom value
|
||||||
|
// Missing: newSetting, newSetting2 (should use defaults)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Missing: newSection (should use defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Create complete default config
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "DefaultApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "light" // Should be added
|
||||||
|
defaultConfig.User.Name = "Default User"
|
||||||
|
defaultConfig.User.Email = "default@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true // Should be added
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value" // Should be added
|
||||||
|
defaultConfig.NewSection.Enabled = true // Should be added
|
||||||
|
defaultConfig.NewSection.Value = "new section" // Should be added
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result.Migrated)
|
||||||
|
|
||||||
|
// Verify user values are preserved
|
||||||
|
assert.Equal(t, "CustomApp", k.String("app.name"))
|
||||||
|
assert.Equal(t, "2.0.0", k.String("app.version"))
|
||||||
|
assert.Equal(t, "Custom User", k.String("user.name"))
|
||||||
|
assert.Equal(t, "custom@example.com", k.String("user.email"))
|
||||||
|
assert.False(t, k.Bool("user.settings.autoSave"))
|
||||||
|
assert.Equal(t, "zh", k.String("user.settings.language"))
|
||||||
|
|
||||||
|
// Verify missing fields were added with defaults
|
||||||
|
assert.Equal(t, "light", k.String("app.theme"))
|
||||||
|
assert.True(t, k.Bool("user.settings.newSetting"))
|
||||||
|
assert.Equal(t, "value", k.String("user.settings.newSetting2"))
|
||||||
|
assert.True(t, k.Bool("newSection.enabled"))
|
||||||
|
assert.Equal(t, "new section", k.String("newSection.value"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_ArrayMerge tests array and slice handling
|
||||||
|
func TestConfigMigrator_ArrayMerge(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_array_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Config with arrays
|
||||||
|
type ArrayConfig struct {
|
||||||
|
Plugins struct {
|
||||||
|
Enabled []string `json:"enabled"`
|
||||||
|
Config struct {
|
||||||
|
LoadOrder []string `json:"loadOrder"`
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
} `json:"config"`
|
||||||
|
} `json:"plugins"`
|
||||||
|
Database struct {
|
||||||
|
Hosts []string `json:"hosts"`
|
||||||
|
Ports []int `json:"ports"`
|
||||||
|
} `json:"database"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User config with some arrays
|
||||||
|
userConfig := map[string]interface{}{
|
||||||
|
"plugins": map[string]interface{}{
|
||||||
|
"enabled": []string{"plugin1", "plugin2"}, // User's plugin list
|
||||||
|
// Missing: config section
|
||||||
|
},
|
||||||
|
// Missing: database section
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Create default config with arrays
|
||||||
|
defaultConfig := ArrayConfig{}
|
||||||
|
defaultConfig.Plugins.Enabled = []string{"defaultPlugin1", "defaultPlugin2"}
|
||||||
|
defaultConfig.Plugins.Config.LoadOrder = []string{"plugin1", "plugin2"}
|
||||||
|
defaultConfig.Plugins.Config.Settings = map[string]string{"timeout": "30"}
|
||||||
|
defaultConfig.Database.Hosts = []string{"localhost", "backup.host"}
|
||||||
|
defaultConfig.Database.Ports = []int{5432, 5433}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result.Migrated)
|
||||||
|
|
||||||
|
// User's array should be preserved
|
||||||
|
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.enabled"))
|
||||||
|
|
||||||
|
// Missing arrays should be added from defaults
|
||||||
|
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.config.loadOrder"))
|
||||||
|
assert.Equal(t, []string{"localhost", "backup.host"}, k.Strings("database.hosts"))
|
||||||
|
assert.Equal(t, []int{5432, 5433}, k.Ints("database.ports"))
|
||||||
|
|
||||||
|
expectedSettings := map[string]string{"timeout": "30"}
|
||||||
|
assert.Equal(t, expectedSettings, k.StringMap("plugins.config.settings"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_DeepNesting tests very deep nested structures
|
||||||
|
func TestConfigMigrator_DeepNesting(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_deep_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Deep nested config
|
||||||
|
type DeepConfig struct {
|
||||||
|
Level1 struct {
|
||||||
|
Level2 struct {
|
||||||
|
Level3 struct {
|
||||||
|
Level4 struct {
|
||||||
|
Level5 struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"level5"`
|
||||||
|
} `json:"level4"`
|
||||||
|
} `json:"level3"`
|
||||||
|
} `json:"level2"`
|
||||||
|
} `json:"level1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User config with partial deep nesting
|
||||||
|
userConfig := map[string]interface{}{
|
||||||
|
"level1": map[string]interface{}{
|
||||||
|
"level2": map[string]interface{}{
|
||||||
|
"level3": map[string]interface{}{
|
||||||
|
// Missing level4 completely
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Create default config with complete deep nesting
|
||||||
|
defaultConfig := DeepConfig{}
|
||||||
|
defaultConfig.Level1.Level2.Level3.Level4.Level5.Value = "deep_value"
|
||||||
|
defaultConfig.Level1.Level2.Level3.Level4.Level5.Count = 42
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result.Migrated)
|
||||||
|
|
||||||
|
// Verify deep nested values were added
|
||||||
|
assert.Equal(t, "deep_value", k.String("level1.level2.level3.level4.level5.value"))
|
||||||
|
assert.Equal(t, 42, k.Int("level1.level2.level3.level4.level5.count"))
|
||||||
|
|
||||||
|
t.Logf("Missing fields: %v", result.MissingFields)
|
||||||
|
assert.Equal(t, 2, len(result.MissingFields), "Should detect 2 missing deep nested fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_EdgeCases tests various edge cases
|
||||||
|
func TestConfigMigrator_EdgeCases(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_edge_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Edge case config with various data types
|
||||||
|
userConfig := map[string]interface{}{
|
||||||
|
"string_empty": "",
|
||||||
|
"string_spaces": " ",
|
||||||
|
"number_zero": 0,
|
||||||
|
"number_float": 3.14,
|
||||||
|
"bool_false": false,
|
||||||
|
"array_empty": []interface{}{},
|
||||||
|
"map_empty": map[string]interface{}{},
|
||||||
|
"null_value": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||||
|
os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
|
||||||
|
logger := log.New()
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
// Default config with different values
|
||||||
|
type EdgeConfig struct {
|
||||||
|
StringEmpty string `json:"string_empty"`
|
||||||
|
StringSpaces string `json:"string_spaces"`
|
||||||
|
NumberZero int `json:"number_zero"`
|
||||||
|
NumberFloat float64 `json:"number_float"`
|
||||||
|
BoolFalse bool `json:"bool_false"`
|
||||||
|
ArrayEmpty []string `json:"array_empty"`
|
||||||
|
MapEmpty map[string]interface{} `json:"map_empty"`
|
||||||
|
NullValue *string `json:"null_value"`
|
||||||
|
NewField string `json:"new_field"` // This should be added
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig := EdgeConfig{}
|
||||||
|
defaultConfig.StringEmpty = "default_string"
|
||||||
|
defaultConfig.StringSpaces = "default_spaces"
|
||||||
|
defaultConfig.NumberZero = 42
|
||||||
|
defaultConfig.NumberFloat = 2.71
|
||||||
|
defaultConfig.BoolFalse = true
|
||||||
|
defaultConfig.ArrayEmpty = []string{"default"}
|
||||||
|
defaultConfig.MapEmpty = map[string]interface{}{"key": "value"}
|
||||||
|
defaultValue := "default_null"
|
||||||
|
defaultConfig.NullValue = &defaultValue
|
||||||
|
defaultConfig.NewField = "new_field_value"
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// All user edge case values should be preserved (they exist, even if empty/zero/false)
|
||||||
|
assert.Equal(t, "", k.String("string_empty"))
|
||||||
|
assert.Equal(t, " ", k.String("string_spaces"))
|
||||||
|
assert.Equal(t, 0, k.Int("number_zero"))
|
||||||
|
assert.Equal(t, 3.14, k.Float64("number_float"))
|
||||||
|
assert.False(t, k.Bool("bool_false"))
|
||||||
|
assert.Equal(t, []string{}, k.Strings("array_empty"))
|
||||||
|
|
||||||
|
// Only truly missing field should be added
|
||||||
|
assert.Equal(t, "new_field_value", k.String("new_field"))
|
||||||
|
|
||||||
|
// Should detect 2 missing fields: new_field and map_empty.key
|
||||||
|
// The user has an empty map, but default config has a key inside that map
|
||||||
|
assert.Equal(t, 2, len(result.MissingFields), "Should detect 2 missing fields: new_field and map_empty.key")
|
||||||
|
assert.Contains(t, result.MissingFields, "new_field")
|
||||||
|
assert.Contains(t, result.MissingFields, "map_empty.key")
|
||||||
|
|
||||||
|
// Verify that the key was added to the empty map
|
||||||
|
assert.Equal(t, "value", k.String("map_empty.key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_ErrorHandling tests error conditions
|
||||||
|
func TestConfigMigrator_ErrorHandling(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_error_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
logger := log.New()
|
||||||
|
configPath := filepath.Join(tempDir, "nonexistent.json")
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
|
||||||
|
// Test with empty koanf (no config file loaded)
|
||||||
|
k := koanf.New(".")
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
|
||||||
|
// This should still work (creates config from scratch)
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, result.Migrated)
|
||||||
|
assert.Greater(t, len(result.MissingFields), 0)
|
||||||
|
|
||||||
|
// Test with corrupted config file
|
||||||
|
corruptedPath := filepath.Join(tempDir, "corrupted.json")
|
||||||
|
os.WriteFile(corruptedPath, []byte("{invalid json"), 0644)
|
||||||
|
|
||||||
|
k2 := koanf.New(".")
|
||||||
|
// This should fail gracefully when trying to load the corrupted file
|
||||||
|
err = k2.Load(file.Provider(corruptedPath), jsonparser.Parser())
|
||||||
|
assert.Error(t, err, "Should fail to load corrupted JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_ConcurrentAccess tests concurrent migration access
|
||||||
|
func TestConfigMigrator_ConcurrentAccess(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_concurrent_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
configPath := createTestConfig(t, tempDir)
|
||||||
|
logger := log.New()
|
||||||
|
|
||||||
|
// Create multiple migrators
|
||||||
|
numWorkers := 5
|
||||||
|
results := make(chan *MigrationResult, numWorkers)
|
||||||
|
errors := make(chan error, numWorkers)
|
||||||
|
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "dark"
|
||||||
|
defaultConfig.User.Name = "Test User"
|
||||||
|
defaultConfig.User.Email = "test@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||||
|
defaultConfig.NewSection.Enabled = true
|
||||||
|
defaultConfig.NewSection.Value = "new section"
|
||||||
|
|
||||||
|
// Run concurrent migrations
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
go func(workerID int) {
|
||||||
|
// Each worker gets its own config path to avoid file conflicts
|
||||||
|
workerConfigPath := filepath.Join(tempDir, fmt.Sprintf("config_%d.json", workerID))
|
||||||
|
|
||||||
|
// Copy the original config for this worker
|
||||||
|
originalData, _ := os.ReadFile(configPath)
|
||||||
|
os.WriteFile(workerConfigPath, originalData, 0644)
|
||||||
|
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, fmt.Sprintf("config_%d", workerID), workerConfigPath)
|
||||||
|
k := koanf.New(".")
|
||||||
|
k.Load(file.Provider(workerConfigPath), jsonparser.Parser())
|
||||||
|
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
if err != nil {
|
||||||
|
errors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- result
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
select {
|
||||||
|
case result := <-results:
|
||||||
|
assert.True(t, result.Migrated, "Each worker should successfully migrate")
|
||||||
|
assert.Equal(t, 4, len(result.MissingFields), "Each worker should detect same missing fields")
|
||||||
|
case err := <-errors:
|
||||||
|
t.Errorf("Worker failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user