Files
gochat/backend/internal/service/copilot_config_service_test.go
T
Rogeeandrogee 60ad320e8d [H-337] Fix Captain provider runtime and knowledge flow (#61)
* fix(H-337): configure Captain provider runtime

* fix(captain): make knowledge rebuild atomic

* fix(captain): scope retrieval provider failures

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-20 14:46:01 +08:00

218 lines
8.2 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupCopilotConfigServiceTest(t *testing.T) (*CopilotConfigService, *gorm.DB, *llm.ProviderManager) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.InstallationConfig{}))
manager := llm.NewProviderManager()
svc := NewCopilotConfigService(repository.NewInstallationConfigRepo(db), manager)
return svc, db, manager
}
func float64Ptr(v float64) *float64 { return &v }
func testCopilotInput(baseURL string) CopilotProviderConfigInput {
return CopilotProviderConfigInput{
Chat: CopilotChatConfigInput{
Provider: "openai_compatible",
BaseURL: baseURL,
Model: "custom-model",
CopilotSecretInput: CopilotSecretInput{APIKey: "secret-api-key"},
},
Embedding: CopilotEmbeddingConfigInput{
Mode: llm.EmbeddingModeReuseChat,
Provider: "openai_compatible",
BaseURL: baseURL,
Model: "custom-embedding",
Dimensions: 3,
},
Generation: CopilotGenerationConfigInput{Temperature: float64Ptr(0.4), MaxTokens: intPtr(800)},
Request: CopilotRequestConfigInput{TimeoutSeconds: intPtr(30), MaxRetries: intPtr(0)},
}
}
func TestCopilotConfigServiceDefaultsToUnconfigured(t *testing.T) {
svc, _, _ := setupCopilotConfigServiceTest(t)
payload, err := svc.Get(context.Background())
require.NoError(t, err)
assert.Equal(t, "openai", payload.Chat.Provider)
assert.Equal(t, "gpt-4o-mini", payload.Chat.Model)
assert.False(t, payload.Configured)
}
func TestCopilotConfigServiceInitializesRuntimeFallbackWithoutPersistence(t *testing.T) {
svc, db, manager := setupCopilotConfigServiceTest(t)
settings := defaultCopilotProviderSettings()
settings.Chat.Provider = "openai_compatible"
settings.Chat.BaseURL = "https://runtime.example.com/v1"
settings.Chat.Model = "gpt-5.6-luna"
settings.Embedding.Provider = "openai_compatible"
settings.Embedding.BaseURL = settings.Chat.BaseURL
settings.Embedding.Model = "runtime-embedding"
settings.Embedding.Dimensions = 3
raw, err := json.Marshal(settings)
require.NoError(t, err)
require.NoError(t, svc.InitializeWithRuntime(context.Background(), CopilotRuntimeConfigInput{
ProviderConfig: string(raw),
ChatAPIKey: "runtime-secret",
}))
snapshot, configured := manager.Snapshot()
require.True(t, configured)
assert.Equal(t, "gpt-5.6-luna", snapshot.ChatModel)
assert.Equal(t, "runtime-embedding", snapshot.EmbeddingModel)
payload, err := svc.Get(context.Background())
require.NoError(t, err)
assert.True(t, payload.Configured)
assert.Equal(t, "********", payload.Chat.APIKey.Masked)
encoded, err := json.Marshal(payload)
require.NoError(t, err)
assert.NotContains(t, string(encoded), "runtime-secret")
var count int64
require.NoError(t, db.Model(&model.InstallationConfig{}).Count(&count).Error)
assert.Zero(t, count)
}
func TestCopilotConfigServiceDatabaseConfigPrecedesRuntimeFallback(t *testing.T) {
svc, db, _ := setupCopilotConfigServiceTest(t)
_, err := svc.Update(context.Background(), testCopilotInput("https://database.example.com/v1"))
require.NoError(t, err)
runtimeSettings := defaultCopilotProviderSettings()
runtimeSettings.Chat.Provider = "openai_compatible"
runtimeSettings.Chat.BaseURL = "https://runtime.example.com/v1"
runtimeSettings.Chat.Model = "runtime-model"
raw, err := json.Marshal(runtimeSettings)
require.NoError(t, err)
manager := llm.NewProviderManager()
reloaded := NewCopilotConfigService(repository.NewInstallationConfigRepo(db), manager)
require.NoError(t, reloaded.InitializeWithRuntime(context.Background(), CopilotRuntimeConfigInput{
ProviderConfig: string(raw), ChatAPIKey: "runtime-secret",
}))
snapshot, configured := manager.Snapshot()
require.True(t, configured)
assert.Equal(t, "https://database.example.com/v1", snapshot.ChatBaseURL)
assert.Equal(t, "custom-model", snapshot.ChatModel)
}
func TestCopilotConfigServiceRejectsIncompleteRuntimeFallback(t *testing.T) {
svc, _, manager := setupCopilotConfigServiceTest(t)
err := svc.InitializeWithRuntime(context.Background(), CopilotRuntimeConfigInput{
ProviderConfig: `{"embedding":{"mode":"separate"}}`,
ChatAPIKey: "runtime-chat-key",
})
require.ErrorIs(t, err, llm.ErrProviderNotConfigured)
_, configured := manager.Snapshot()
assert.False(t, configured)
}
func TestCopilotConfigServiceSavesPlaintextKeysAndConfiguresManager(t *testing.T) {
svc, db, manager := setupCopilotConfigServiceTest(t)
payload, err := svc.Update(context.Background(), testCopilotInput("https://llm.example.com/v1/"))
require.NoError(t, err)
assert.True(t, payload.Configured)
assert.Equal(t, "sec****-key", payload.Chat.APIKey.Masked)
var stored model.InstallationConfig
require.NoError(t, db.Where("name = ?", copilotChatAPIKeyConfigKey).First(&stored).Error)
assert.Equal(t, "secret-api-key", stored.Value)
snapshot, configured := manager.Snapshot()
assert.True(t, configured)
assert.Equal(t, "custom-model", snapshot.ChatModel)
assert.Equal(t, "https://llm.example.com/v1", snapshot.ChatBaseURL)
assert.Equal(t, 0, snapshot.MaxRetries)
}
func TestCopilotConfigServiceClearsActiveProvider(t *testing.T) {
svc, _, manager := setupCopilotConfigServiceTest(t)
_, err := svc.Update(context.Background(), testCopilotInput("https://llm.example.com/v1"))
require.NoError(t, err)
input := testCopilotInput("https://llm.example.com/v1")
input.Chat.APIKey = ""
input.Chat.ClearAPIKey = true
payload, err := svc.Update(context.Background(), input)
require.NoError(t, err)
assert.False(t, payload.Configured)
_, configured := manager.Snapshot()
assert.False(t, configured)
}
func TestCopilotConfigServiceRejectsAnthropicWithoutSeparateEmbedding(t *testing.T) {
svc, _, _ := setupCopilotConfigServiceTest(t)
input := testCopilotInput("https://api.anthropic.com")
input.Chat.Provider = "anthropic"
_, err := svc.Update(context.Background(), input)
require.ErrorContains(t, err, "separate embedding provider")
}
func TestCopilotConfigServiceRejectsCredentialsInBaseURL(t *testing.T) {
svc, _, _ := setupCopilotConfigServiceTest(t)
input := testCopilotInput("https://user:secret@llm.example.com/v1")
_, err := svc.Update(context.Background(), input)
require.ErrorContains(t, err, "must not contain credentials")
}
func TestCopilotConfigServiceTestsChatAndEmbeddingWithoutChangingSavedConfig(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/chat/completions":
var req llm.ChatRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`))
case "/embeddings":
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"custom-embedding"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
svc, _, _ := setupCopilotConfigServiceTest(t)
input := testCopilotInput(server.URL)
health, err := svc.Test(context.Background(), input)
require.NoError(t, err)
assert.True(t, health.Chat.OK)
assert.True(t, health.Embedding.OK)
payload, err := svc.Get(context.Background())
require.NoError(t, err)
assert.False(t, payload.Configured, "testing candidate settings must not persist them")
}
func TestNormalizeCopilotProviderErrorDoesNotExposeProviderResponse(t *testing.T) {
providerErr := fmt.Errorf("chat completion: %w", &llm.APIError{
StatusCode: http.StatusInternalServerError,
Message: "complete provider response containing sk-secret-value",
})
normalized := normalizeCopilotProviderError(providerErr)
assert.Equal(t, "provider request failed", normalized)
assert.NotContains(t, normalized, "sk-secret-value")
assert.Equal(t, "provider response format is incompatible", normalizeCopilotProviderError(
fmt.Errorf("unmarshal chat response: invalid character"),
))
}