Files
MyGoNavi/internal/ai/service/config_store_test.go
Syngnat f7d71c6c5c feat(ai): 增加用户提示词与 MCP/Skills 扩展能力
- 支持用户级自定义提示词加载保存与聊天会话注入

- 新增 MCP 服务配置、工具发现与按次调用链路

- 增加 Skills 配置、作用域注入与前后端类型同步

- 补充配置存储与前端行为测试并更新依赖
2026-06-07 17:56:45 +08:00

327 lines
9.6 KiB
Go

package aiservice
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/secretstore"
)
func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
legacy := aiConfig{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "openai",
Name: "OpenAI",
APIKey: "sk-test",
BaseURL: "https://api.openai.com/v1",
Headers: map[string]string{
"Authorization": "Bearer test",
"X-Team": "platform",
},
},
},
}
data, err := json.MarshalIndent(legacy, "", " ")
if err != nil {
t.Fatalf("MarshalIndent returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(configStore.configDir, aiConfigFileName), data, 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(snapshot.Providers) != 1 {
t.Fatalf("expected 1 provider, got %d", len(snapshot.Providers))
}
if snapshot.Providers[0].APIKey != "sk-test" {
t.Fatalf("expected runtime provider to restore apiKey, got %q", snapshot.Providers[0].APIKey)
}
if snapshot.Providers[0].Headers["Authorization"] != "Bearer test" {
t.Fatalf("expected runtime provider to restore sensitive header, got %#v", snapshot.Providers[0].Headers)
}
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("GetAIProvider returned error: %v", err)
}
if !ok {
t.Fatal("expected migrated provider secret bundle in daily store")
}
if stored.APIKey != "sk-test" {
t.Fatalf("expected migrated apiKey in store, got %q", stored.APIKey)
}
rewritten, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(rewritten)
if strings.Contains(text, "sk-test") {
t.Fatalf("expected rewritten config to be secretless, got %s", text)
}
if strings.Contains(text, "Bearer test") {
t.Fatalf("expected rewritten config to remove sensitive headers, got %s", text)
}
}
func TestProviderConfigStoreSavePersistsSecretlessMetadata(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "openai",
Name: "OpenAI",
APIKey: "sk-test",
BaseURL: "https://api.openai.com/v1",
Headers: map[string]string{
"Authorization": "Bearer test",
"X-Team": "platform",
},
},
},
ActiveProvider: "openai-main",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(configData)
if strings.Contains(text, "sk-test") {
t.Fatalf("expected config file to be secretless, got %s", text)
}
if strings.Contains(text, "Bearer test") {
t.Fatalf("expected config file to remove sensitive headers, got %s", text)
}
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("GetAIProvider returned error: %v", err)
}
if !ok {
t.Fatal("expected provider secret bundle in daily store")
}
if stored.APIKey != "sk-test" {
t.Fatalf("expected stored apiKey, got %q", stored.APIKey)
}
if stored.SensitiveHeaders["Authorization"] != "Bearer test" {
t.Fatalf("expected stored sensitive header, got %#v", stored.SensitiveHeaders)
}
}
func TestProviderConfigStoreSaveKeepsExistingSecretRef(t *testing.T) {
withTestAIGOOS(t, "linux")
store := newFakeProviderSecretStore()
configStore := newProviderConfigStore(t.TempDir(), store)
ref, err := secretstore.BuildRef(providerSecretKind, "openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
payload, err := json.Marshal(providerSecretBundle{
APIKey: "sk-existing",
SensitiveHeaders: map[string]string{
"Authorization": "Bearer existing",
},
})
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if err := store.Put(ref, payload); err != nil {
t.Fatalf("Put returned error: %v", err)
}
err = configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "openai",
Name: "OpenAI",
HasSecret: true,
SecretRef: ref,
BaseURL: "https://gateway.openai.com/v1",
Headers: map[string]string{
"X-Team": "platform",
},
},
},
ActiveProvider: "openai-main",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
if err != nil {
t.Fatalf("GetAIProvider returned error: %v", err)
}
if !ok {
t.Fatal("expected existing provider secret bundle to be migrated to daily store")
}
if stored.APIKey != "sk-existing" {
t.Fatalf("expected existing apiKey to be kept, got %q", stored.APIKey)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(snapshot.Providers) != 1 {
t.Fatalf("expected 1 provider after reload, got %d", len(snapshot.Providers))
}
if snapshot.Providers[0].APIKey != "sk-existing" {
t.Fatalf("expected reload to restore existing apiKey, got %q", snapshot.Providers[0].APIKey)
}
if snapshot.Providers[0].Headers["Authorization"] != "Bearer existing" {
t.Fatalf("expected reload to restore existing sensitive header, got %#v", snapshot.Providers[0].Headers)
}
}
func TestProviderConfigStoreSaveAndLoadUserPromptSettings(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
expected := ai.UserPromptSettings{
Global: "所有回答先给结论。",
Database: "生成 SQL 前先确认字段名。",
JVM: "优先输出资源级风险判断。",
JVMDiagnostic: "先给诊断计划,再给命令。",
}
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
ActiveProvider: "",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
UserPromptSettings: expected,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if snapshot.UserPromptSettings != expected {
t.Fatalf("expected user prompt settings %#v, got %#v", expected, snapshot.UserPromptSettings)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(configData)
if !strings.Contains(text, `"userPromptSettings"`) {
t.Fatalf("expected config file to contain userPromptSettings, got %s", text)
}
}
func TestProviderConfigStoreSaveAndLoadMCPServers(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
expected := []ai.MCPServerConfig{
{
ID: "mcp-local",
Name: "本地文件助手",
Transport: ai.MCPTransportStdio,
Command: "node",
Args: []string{"server.js", "--stdio"},
Env: map[string]string{"API_KEY": "test"},
Enabled: true,
TimeoutSeconds: 18,
},
}
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
ActiveProvider: "",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
MCPServers: expected,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if !reflect.DeepEqual(snapshot.MCPServers, expected) {
t.Fatalf("expected MCP servers %#v, got %#v", expected, snapshot.MCPServers)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !strings.Contains(string(configData), `"mcpServers"`) {
t.Fatalf("expected config file to contain mcpServers, got %s", string(configData))
}
}
func TestProviderConfigStoreSaveAndLoadSkills(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
expected := []ai.SkillConfig{
{
ID: "skill-sql-review",
Name: "SQL 审查",
Description: "生成 SQL 前先校验字段和风险",
SystemPrompt: "先确认字段,再输出 SQL。",
Enabled: true,
Scopes: []string{string(ai.SkillScopeDatabase)},
RequiredTools: []string{"get_columns", "execute_sql"},
},
}
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
ActiveProvider: "",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
Skills: expected,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if !reflect.DeepEqual(snapshot.Skills, expected) {
t.Fatalf("expected skills %#v, got %#v", expected, snapshot.Skills)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !strings.Contains(string(configData), `"skills"`) {
t.Fatalf("expected config file to contain skills, got %s", string(configData))
}
}