Files
gochat/backend/internal/service/copilot_config_service_test.go
T

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