Files
CLIProxyAPI/internal/config/plugin_config_save_test.go
Luis Pater afba07ba26 fix(config): preserve plugin configurations when saving yaml
- Replace the plugin configs subtree directly instead of merging to prevent zero values, booleans, and empty collections from being pruned as defaults.
- Ensure stale plugin keys and removed configuration entries are properly cleaned up while preserving comments.

Closes: #5907
2026-09-17 21:01:31 +08:00

479 lines
14 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func parseTestPluginConfig(t *testing.T, yamlText string) PluginInstanceConfig {
t.Helper()
var item PluginInstanceConfig
if errUnmarshal := yaml.Unmarshal([]byte(yamlText), &item); errUnmarshal != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errUnmarshal)
}
return item
}
func TestSaveConfigPreserveComments_PluginConfigPreservesZeroValuesAndBooleans(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `plugins:
enabled: true
configs:
sample:
name: initial
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
enabledFalse := false
cfg := &Config{
Plugins: PluginsConfig{
Enabled: true,
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{
"sample": parseTestPluginConfig(t, `name: updated
enabled: false
timeout: 0
empty_str: ""
empty_list: []
`),
},
},
}
cfg.Plugins.Configs["sample"] = PluginInstanceConfig{
Enabled: &enabledFalse,
Raw: cfg.Plugins.Configs["sample"].Raw,
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
savedText := string(savedBytes)
// In the reported bug, newly added false booleans, zeros, empty strings and empty lists
// are silently dropped by mergeMappingPreserve/isKnownDefaultValue.
for _, expected := range []string{
"enabled: false",
"timeout: 0",
"empty_list: []",
"empty_str: \"\"",
} {
if !strings.Contains(savedText, expected) {
t.Errorf("saved config missing %q; got:\n%s", expected, savedText)
}
}
var loaded struct {
Plugins struct {
Configs map[string]map[string]any `yaml:"configs"`
} `yaml:"plugins"`
}
if errUnmarshal := yaml.Unmarshal(savedBytes, &loaded); errUnmarshal != nil {
t.Fatalf("yaml.Unmarshal(savedBytes) error = %v", errUnmarshal)
}
sampleConfig := loaded.Plugins.Configs["sample"]
if sampleConfig["enabled"] != false {
t.Errorf("loaded enabled = %#v, want false", sampleConfig["enabled"])
}
if sampleConfig["timeout"] != 0 {
t.Errorf("loaded timeout = %#v, want 0", sampleConfig["timeout"])
}
if sampleConfig["empty_str"] != "" {
t.Errorf("loaded empty_str = %#v, want empty string", sampleConfig["empty_str"])
}
}
func TestSaveConfigPreserveComments_PluginConfigDeletesRemovedKeys(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `plugins:
enabled: true
configs:
sample:
name: initial
stale_key: should_be_removed
nested:
old_child: remove_me
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
cfg := &Config{
Plugins: PluginsConfig{
Enabled: true,
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{
"sample": parseTestPluginConfig(t, `name: updated
nested:
new_child: keep_me
`),
},
},
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
savedText := string(savedBytes)
// In the reported bug, mergeMappingPreserve does not delete stale keys in mappings.
if strings.Contains(savedText, "stale_key") {
t.Errorf("saved config unexpectedly contains stale_key; got:\n%s", savedText)
}
if strings.Contains(savedText, "old_child") {
t.Errorf("saved config unexpectedly contains old_child; got:\n%s", savedText)
}
if !strings.Contains(savedText, "new_child: keep_me") {
t.Errorf("saved config missing new_child: keep_me; got:\n%s", savedText)
}
}
func TestSaveConfigPreserveComments_PluginConfigPreservesSequenceReordering(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `plugins:
enabled: true
configs:
sample:
vision_models:
- name: model-a
detail: detail-a
old_prop: stale-a
- name: model-b
detail: detail-b
old_prop: stale-b
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
// Reorder sequence: model-b first, then model-a, with detail modified.
cfg := &Config{
Plugins: PluginsConfig{
Enabled: true,
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{
"sample": parseTestPluginConfig(t, `vision_models:
- name: model-b
detail: new-detail-b
extra_b: only-in-b
- name: model-a
detail: new-detail-a
`),
},
},
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
savedText := string(savedBytes)
idxB := strings.Index(savedText, "name: model-b")
idxA := strings.Index(savedText, "name: model-a")
if idxB == -1 || idxA == -1 {
t.Fatalf("saved config missing model-a or model-b; got:\n%s", savedText)
}
if idxB > idxA {
t.Errorf("expected model-b to precede model-a in reordered sequence; got:\n%s", savedText)
}
if !strings.Contains(savedText, "new-detail-b") || !strings.Contains(savedText, "new-detail-a") {
t.Errorf("saved config missing updated details; got:\n%s", savedText)
}
if !strings.Contains(savedText, "extra_b: only-in-b") {
t.Errorf("saved config missing extra_b; got:\n%s", savedText)
}
if strings.Contains(savedText, "old_prop") || strings.Contains(savedText, "stale-") {
t.Errorf("saved config unexpectedly contains stale properties from old sequence items; got:\n%s", savedText)
}
var loaded struct {
Plugins struct {
Configs map[string]struct {
VisionModels []struct {
Name string `yaml:"name"`
Detail string `yaml:"detail"`
ExtraB string `yaml:"extra_b"`
} `yaml:"vision_models"`
} `yaml:"configs"`
} `yaml:"plugins"`
}
if errUnmarshal := yaml.Unmarshal(savedBytes, &loaded); errUnmarshal != nil {
t.Fatalf("yaml.Unmarshal(savedBytes) error = %v", errUnmarshal)
}
models := loaded.Plugins.Configs["sample"].VisionModels
if len(models) != 2 {
t.Fatalf("len(models) = %d, want 2", len(models))
}
if models[0].Name != "model-b" || models[0].Detail != "new-detail-b" || models[0].ExtraB != "only-in-b" {
t.Errorf("models[0] = %+v, want model-b with new-detail-b and only-in-b", models[0])
}
if models[1].Name != "model-a" || models[1].Detail != "new-detail-a" || models[1].ExtraB != "" {
t.Errorf("models[1] = %+v, want model-a with new-detail-a and empty ExtraB", models[1])
}
}
func TestSaveConfigPreserveComments_PluginConfigCreatesNewSubtree(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `port: 8080
debug: false
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
enabledFalse := false
cfg := &Config{
Port: 8080,
Debug: false,
Plugins: PluginsConfig{
Enabled: true,
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{
"new-plugin": parseTestPluginConfig(t, `enabled: false
mode: minimal
count: 0
`),
},
},
}
cfg.Plugins.Configs["new-plugin"] = PluginInstanceConfig{
Enabled: &enabledFalse,
Raw: cfg.Plugins.Configs["new-plugin"].Raw,
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
savedText := string(savedBytes)
for _, expected := range []string{
"new-plugin:",
"enabled: false",
"mode: minimal",
"count: 0",
} {
if !strings.Contains(savedText, expected) {
t.Errorf("saved config missing %q; got:\n%s", expected, savedText)
}
}
}
func TestSaveConfigPreserveComments_PluginConfigCreatesNewSubtreeWithOnlyZeroValues(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `port: 8080
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
enabledFalse := false
cfg := &Config{
Port: 8080,
Plugins: PluginsConfig{
Enabled: false,
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{
"zero-plugin": parseTestPluginConfig(t, `enabled: false
timeout: 0
empty_str: ""
empty_list: []
`),
},
},
}
cfg.Plugins.Configs["zero-plugin"] = PluginInstanceConfig{
Enabled: &enabledFalse,
Raw: cfg.Plugins.Configs["zero-plugin"].Raw,
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
savedText := string(savedBytes)
for _, expected := range []string{
"plugins:",
"configs:",
"zero-plugin:",
"enabled: false",
"timeout: 0",
"empty_list: []",
} {
if !strings.Contains(savedText, expected) {
t.Errorf("saved config missing %q; got:\n%s", expected, savedText)
}
}
}
func TestSaveConfigPreserveComments_PluginConfigDeletesPluginAndClearsConfigs(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `plugins:
enabled: true
configs:
plugin-a:
name: a
plugin-b:
name: b
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
// Step 1: remove plugin-a, keep plugin-b
cfg := &Config{
Plugins: PluginsConfig{
Enabled: true,
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{
"plugin-b": parseTestPluginConfig(t, "name: b-updated\n"),
},
},
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() step 1 error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() step 1 error = %v", errRead)
}
savedText := string(savedBytes)
if strings.Contains(savedText, "plugin-a") {
t.Errorf("saved config step 1 still contains plugin-a; got:\n%s", savedText)
}
if !strings.Contains(savedText, "plugin-b:") || !strings.Contains(savedText, "b-updated") {
t.Errorf("saved config step 1 missing plugin-b; got:\n%s", savedText)
}
// Step 2: clear all configs
cfg.Plugins.Configs = map[string]PluginInstanceConfig{}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() step 2 error = %v", errSave)
}
savedBytes, errRead = os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() step 2 error = %v", errRead)
}
savedText = string(savedBytes)
if strings.Contains(savedText, "plugin-b") {
t.Errorf("saved config step 2 still contains plugin-b; got:\n%s", savedText)
}
if strings.Contains(savedText, "configs:") {
t.Errorf("saved config step 2 still contains configs section; got:\n%s", savedText)
}
}
func TestSaveConfigPreserveComments_PluginConfigPreservesCommentsOutsidePlugins(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `# Server listening port
port: 8080
# Plugins configuration section
plugins:
enabled: true
configs:
sample:
name: old
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
cfg := &Config{
Port: 8080,
Plugins: PluginsConfig{
Enabled: true,
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{
"sample": parseTestPluginConfig(t, "name: updated\nenabled: false\n"),
},
},
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
savedText := string(savedBytes)
if !strings.Contains(savedText, "# Server listening port") {
t.Errorf("saved config missing port comment; got:\n%s", savedText)
}
if !strings.Contains(savedText, "# Plugins configuration section") {
t.Errorf("saved config missing plugins comment; got:\n%s", savedText)
}
if !strings.Contains(savedText, "name: updated") || !strings.Contains(savedText, "enabled: false") {
t.Errorf("saved config missing updated plugin config; got:\n%s", savedText)
}
}
func TestSaveConfigPreserveComments_PluginConfigAddsConfigsWhenPluginsExistsWithoutConfigs(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
initialYAML := `plugins:
enabled: true
dir: custom-dir
`
if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
cfg := &Config{
Plugins: PluginsConfig{
Enabled: true,
Dir: "custom-dir",
Configs: map[string]PluginInstanceConfig{
"new-item": parseTestPluginConfig(t, "enabled: false\n"),
},
},
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
savedBytes, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
savedText := string(savedBytes)
if !strings.Contains(savedText, "dir: custom-dir") {
t.Errorf("saved config missing dir: custom-dir; got:\n%s", savedText)
}
if !strings.Contains(savedText, "new-item:") || !strings.Contains(savedText, "enabled: false") {
t.Errorf("saved config missing new-item with enabled: false; got:\n%s", savedText)
}
}