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 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"), )) }