Files
Cytown 667fc85d54 refactor(config): make config.Channel to multiple instance support
add new field type to Channel struct
config.channels refactor to channel_list
update config version to 3
update the docs
2026-04-13 22:21:21 +08:00

398 lines
12 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// Tests for buildModelWithProtocol helper function.
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
result := buildModelWithProtocol("openai", "gpt-5.4")
if result != "openai/gpt-5.4" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
}
}
func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
result := buildModelWithProtocol("openrouter", "openrouter/auto")
if result != "openrouter/auto" {
t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto")
}
}
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
if result != "openrouter/claude-sonnet-4.6" {
t.Errorf(
"buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q",
result,
"openrouter/claude-sonnet-4.6",
)
}
}
// ---------------------------------------------------------------------------
// V0/V1/V2 → V3 migration tests
// ---------------------------------------------------------------------------
// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V3 migration produces
// correct Enabled fields and version.
func TestLoadConfig_V0MigrateProducesV2(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
v0Config := `{
"model_list": [
{
"model_name": "gpt-4",
"model": "openai/gpt-4",
"api_key": "sk-test"
},
{
"model_name": "claude",
"model": "anthropic/claude"
},
{
"model_name": "local-model",
"model": "vllm/custom-model"
}
],
"gateway": {"host": "127.0.0.1", "port": 18790}
}`
if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Version != CurrentVersion {
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
}
// Check enabled status
modelEnabled := func(name string) bool {
m, err := cfg.GetModelConfig(name)
if err != nil {
return false
}
return m.Enabled
}
if !modelEnabled("gpt-4") {
t.Error("gpt-4 with API key from V0 should be enabled")
}
if modelEnabled("claude") {
t.Error("claude without API key from V0 should be disabled")
}
if !modelEnabled("local-model") {
t.Error("local-model from V0 should be enabled")
}
}
// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error.
func TestLoadConfig_UnsupportedVersion(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}`
if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err := LoadConfig(configPath)
if err == nil {
t.Fatal("LoadConfig should return error for unsupported version")
}
if !containsString(err.Error(), "unsupported config version") {
t.Errorf("error = %q, want 'unsupported config version'", err.Error())
}
}
func containsString(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// TestMigrateV0ToV3 verifies V0 (legacy, no version) → V3 migration.
// V0 configs use the old providers format without model_list.
func TestMigrateV0ToV3(t *testing.T) {
// V0 config: no version field, uses legacy providers
v0Config := `{
"agents": {
"defaults": {
"provider": "openai",
"model": "gpt-4"
}
},
"providers": {
"openai": {
"api_key": "sk-test123",
"api_base": "https://api.openai.com/v1"
}
},
"channels": {
"telegram": {
"token": "bot-token"
},
"discord": {
"mention_only": true
}
}
}`
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600))
m, err := loadConfigMap(configPath)
require.NoError(t, err)
err = migrateV0ToV1(m)
require.NoError(t, err)
err = migrateV1ToV2(m)
require.NoError(t, err)
err = migrateV2ToV3(m)
require.NoError(t, err)
// Version should be set to CurrentVersion
require.Equal(t, CurrentVersion, m["version"])
// Providers should be converted to model_list
modelList, ok := m["model_list"].([]any)
require.True(t, ok, "model_list should exist")
require.NotEmpty(t, modelList, "model_list should not be empty")
t.Logf("modelList: %+v", modelList)
// First model should be the user's configured provider with user's model
firstModel := modelList[0].(map[string]any)
require.Equal(t, "openai", firstModel["model_name"])
require.Equal(t, "openai/gpt-4", firstModel["model"])
// api_key is converted to api_keys during migration
require.Contains(t, firstModel, "api_keys", "api_keys should exist")
// Channels should be converted to nested format with channel_list
channelList, ok := m["channel_list"].(map[string]any)
require.True(t, ok, "channel_list should exist")
require.NotContains(t, m, "channels", "old 'channels' key should be removed")
// telegram channel should have settings
telegram := channelList["telegram"].(map[string]any)
require.Equal(t, "telegram", telegram["type"])
require.Contains(t, telegram, "settings", "telegram should have settings")
settings := telegram["settings"].(map[string]any)
require.Equal(t, "bot-token", settings["token"])
// discord channel should have group_trigger and mention_only in group_trigger
discord := channelList["discord"].(map[string]any)
require.Equal(t, "discord", discord["type"])
discordGroupTrigger := discord["group_trigger"].(map[string]any)
require.Equal(t, true, discordGroupTrigger["mention_only"])
}
// TestMigrateV0ToV3_WithExistingModelList preserves existing model_list when present.
func TestMigrateV0ToV3_WithExistingModelList(t *testing.T) {
v0Config := `{
"model_list": [
{"model_name": "custom", "model": "openai/custom-model", "api_key": "sk-existing"}
],
"channels": {
"telegram": {"token": "bot123"}
}
}`
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600))
m, err := loadConfigMap(configPath)
require.NoError(t, err)
err = migrateV0ToV1(m)
require.NoError(t, err)
err = migrateV1ToV2(m)
require.NoError(t, err)
err = migrateV2ToV3(m)
require.NoError(t, err)
// Existing model_list should be preserved (not overridden by providers)
modelList := m["model_list"].([]any)
require.Len(t, modelList, 1)
firstModel := modelList[0].(map[string]any)
require.Equal(t, "custom", firstModel["model_name"])
}
// TestMigrateV1ToV3 verifies V1 → V3 migration.
// V1 uses flat channel format without "settings" wrapper.
func TestMigrateV1ToV3(t *testing.T) {
v1Config := `{
"version": 1,
"model_list": [
{"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-test"}
],
"channels": {
"telegram": {
"token": "bot-token",
"base_url": "https://custom.api.com"
},
"discord": {
"mention_only": true,
"proxy": "socks5://localhost:1080"
},
"onebot": {
"ws_url": "ws://localhost:3001",
"group_trigger_prefix": ["/"]
}
}
}`
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
m, err := loadConfigMap(configPath)
require.NoError(t, err)
err = migrateV1ToV2(m)
require.NoError(t, err)
err = migrateV2ToV3(m)
require.NoError(t, err)
// Version should be set to CurrentVersion
require.Equal(t, CurrentVersion, m["version"])
// Channels should be converted to nested format
channelList, ok := m["channel_list"].(map[string]any)
require.True(t, ok, "channel_list should exist")
require.NotContains(t, m, "channels", "old 'channels' key should be removed")
// telegram: flat fields moved to settings
telegram := channelList["telegram"].(map[string]any)
require.Equal(t, "telegram", telegram["type"])
tgSettings := telegram["settings"].(map[string]any)
require.Equal(t, "bot-token", tgSettings["token"])
require.Equal(t, "https://custom.api.com", tgSettings["base_url"])
// discord: mention_only should be moved to group_trigger
discord := channelList["discord"].(map[string]any)
require.Equal(t, "discord", discord["type"])
require.Contains(t, discord, "group_trigger", "mention_only should be migrated to group_trigger")
gt := discord["group_trigger"].(map[string]any)
require.Equal(t, true, gt["mention_only"])
discordSettings := discord["settings"].(map[string]any)
require.Equal(t, "socks5://localhost:1080", discordSettings["proxy"])
// onebot: group_trigger_prefix should be moved to group_trigger.prefixes
onebot := channelList["onebot"].(map[string]any)
require.Equal(t, "onebot", onebot["type"])
obGroupTrigger := onebot["group_trigger"].(map[string]any)
require.Equal(
t,
[]any{"/"},
obGroupTrigger["prefixes"],
"group_trigger_prefix should be moved to group_trigger.prefixes",
)
obSettings := onebot["settings"].(map[string]any)
require.Equal(t, "ws://localhost:3001", obSettings["ws_url"])
}
// TestMigrateV1ToV3_ApiKeyConversion verifies api_key → api_keys conversion.
func TestMigrateV1ToV3_ApiKeyConversion(t *testing.T) {
v1Config := `{
"version": 1,
"model_list": [
{"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-single"},
{"model_name": "no-key", "model": "openai/no-key"}
],
"channels": {
"telegram": {"token": "bot"}
}
}`
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
m, err := loadConfigMap(configPath)
require.NoError(t, err)
err = migrateV1ToV2(m)
require.NoError(t, err)
err = migrateV2ToV3(m)
require.NoError(t, err)
// api_key should be converted to api_keys array
modelList := m["model_list"].([]any)
firstModel := modelList[0].(map[string]any)
require.NotContains(t, firstModel, "api_key", "api_key should be removed")
require.Contains(t, firstModel, "api_keys", "api_keys should exist")
// api_keys can be []string or []any depending on how it was set
if apiKeys, ok := firstModel["api_keys"].([]string); ok {
require.Len(t, apiKeys, 1)
require.Equal(t, "sk-single", apiKeys[0])
} else if apiKeys, ok := firstModel["api_keys"].([]any); ok {
require.Len(t, apiKeys, 1)
require.Equal(t, "sk-single", apiKeys[0])
} else {
t.Fatalf("api_keys has unexpected type: %T", firstModel["api_keys"])
}
// Model without api_key should not have api_keys added
secondModel := modelList[1].(map[string]any)
require.NotContains(t, secondModel, "api_key")
require.NotContains(t, secondModel, "api_keys")
}
// TestMigrateV1ToV3_AlreadyNestedFormat leaves already-nested channels unchanged.
func TestMigrateV1ToV3_AlreadyNestedFormat(t *testing.T) {
v1Config := `{
"version": 1,
"model_list": [
{"model_name": "gpt-4", "model": "openai/gpt-4"}
],
"channels": {
"telegram": {
"type": "telegram",
"settings": {
"token": "bot-token"
}
}
}
}`
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
m, err := loadConfigMap(configPath)
require.NoError(t, err)
err = migrateV1ToV2(m)
require.NoError(t, err)
err = migrateV2ToV3(m)
require.NoError(t, err)
channelList := m["channel_list"].(map[string]any)
telegram := channelList["telegram"].(map[string]any)
// Should not be double-wrapped
require.Equal(t, "telegram", telegram["type"])
settings := telegram["settings"].(map[string]any)
require.Equal(t, "bot-token", settings["token"])
// Should NOT have nested settings inside settings
require.NotContains(t, settings, "settings")
}