Pulse/internal/ai/providers/gemini_test.go
rcourtman d3eb6a7148 test: improve test coverage for AI, license, config, and monitoring packages
New test files:
- internal/ai/providers/gemini_test.go: Comprehensive Gemini provider tests
- internal/api/ai_intelligence_handlers_test.go: AI intelligence endpoint tests
- internal/api/ai_patrol_handlers_test.go: AI patrol endpoint tests
- internal/api/license_handlers_test.go: License API handler tests
- internal/api/security_oidc_response_test.go: OIDC response formatting tests
- internal/config/ai_config_test.go: AI configuration function tests
- internal/config/persistence_ai_test.go: AI config persistence tests
- internal/config/persistence_extended_test.go: Extended persistence tests
- internal/license/persistence_test.go: License persistence tests
- internal/license/pubkey_test.go: Public key handling tests
- internal/monitoring/host_agent_temps_test.go: Temperature processing tests

Enhanced existing files:
- internal/api/updates_test.go: Added update handler tests
- internal/license/license_test.go: Added Service method tests

Coverage improvements:
- ai/providers: 57.3% -> 73.0% (+15.7%)
- license: 78.3% -> 85.9% (+7.6%)
- config: 49.7% -> 53.9% (+4.2%)
- monitoring: 49.8% -> 50.8% (+1.0%)
- api: 28.4% -> 29.8% (+1.4%)
2025-12-19 22:49:30 +00:00

529 lines
14 KiB
Go

package providers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestNewGeminiClient(t *testing.T) {
t.Parallel()
client := NewGeminiClient("test-api-key", "gemini-pro", "")
if client == nil {
t.Fatal("expected non-nil client")
}
if client.apiKey != "test-api-key" {
t.Errorf("expected apiKey 'test-api-key', got %q", client.apiKey)
}
if client.model != "gemini-pro" {
t.Errorf("expected model 'gemini-pro', got %q", client.model)
}
if client.baseURL != geminiAPIURL {
t.Errorf("expected default baseURL, got %q", client.baseURL)
}
}
func TestNewGeminiClient_StripPrefix(t *testing.T) {
t.Parallel()
client := NewGeminiClient("api-key", "gemini:gemini-1.5-pro", "")
if client.model != "gemini-1.5-pro" {
t.Errorf("expected model with prefix stripped, got %q", client.model)
}
}
func TestNewGeminiClient_CustomBaseURL(t *testing.T) {
t.Parallel()
client := NewGeminiClient("api-key", "gemini-pro", "https://custom.api.example.com")
if client.baseURL != "https://custom.api.example.com" {
t.Errorf("expected custom baseURL, got %q", client.baseURL)
}
}
func TestGeminiClient_Name(t *testing.T) {
t.Parallel()
client := NewGeminiClient("key", "model", "")
if client.Name() != "gemini" {
t.Errorf("expected Name() to return 'gemini', got %q", client.Name())
}
}
func TestGeminiClient_Chat_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
if !strings.Contains(r.URL.Path, "generateContent") {
t.Errorf("expected generateContent in path, got %s", r.URL.Path)
}
resp := geminiResponse{
Candidates: []geminiCandidate{
{
Content: geminiContent{
Parts: []geminiPart{
{Text: "Hello! I'm Gemini."},
},
},
FinishReason: "STOP",
},
},
UsageMetadata: &geminiUsageMetadata{
PromptTokenCount: 10,
CandidatesTokenCount: 20,
TotalTokenCount: 30,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
resp, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Hello"}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "Hello! I'm Gemini." {
t.Errorf("expected content 'Hello! I'm Gemini.', got %q", resp.Content)
}
if resp.InputTokens != 10 {
t.Errorf("expected 10 input tokens, got %d", resp.InputTokens)
}
if resp.OutputTokens != 20 {
t.Errorf("expected 20 output tokens, got %d", resp.OutputTokens)
}
if resp.StopReason != "end_turn" {
t.Errorf("expected stop reason 'end_turn', got %q", resp.StopReason)
}
}
func TestGeminiClient_Chat_WithSystemPrompt(t *testing.T) {
var receivedReq geminiRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&receivedReq)
resp := geminiResponse{
Candidates: []geminiCandidate{
{
Content: geminiContent{
Parts: []geminiPart{{Text: "Response"}},
},
FinishReason: "STOP",
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Hello"}},
System: "You are a helpful assistant",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedReq.SystemInstruction == nil {
t.Fatal("expected system instruction to be set")
}
if len(receivedReq.SystemInstruction.Parts) == 0 {
t.Fatal("expected system instruction parts")
}
if receivedReq.SystemInstruction.Parts[0].Text != "You are a helpful assistant" {
t.Errorf("expected system instruction text, got %q", receivedReq.SystemInstruction.Parts[0].Text)
}
}
func TestGeminiClient_Chat_WithMaxTokens(t *testing.T) {
var receivedReq geminiRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&receivedReq)
resp := geminiResponse{
Candidates: []geminiCandidate{
{
Content: geminiContent{
Parts: []geminiPart{{Text: "Response"}},
},
FinishReason: "STOP",
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Hello"}},
MaxTokens: 1024,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedReq.GenerationConfig == nil {
t.Fatal("expected generation config to be set")
}
if receivedReq.GenerationConfig.MaxOutputTokens != 1024 {
t.Errorf("expected max tokens 1024, got %d", receivedReq.GenerationConfig.MaxOutputTokens)
}
}
func TestGeminiClient_Chat_ToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := geminiResponse{
Candidates: []geminiCandidate{
{
Content: geminiContent{
Parts: []geminiPart{
{
FunctionCall: &geminiFunctionCall{
Name: "get_weather",
Args: map[string]interface{}{"location": "NYC"},
},
},
},
},
FinishReason: "STOP",
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
resp, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "What's the weather in NYC?"}},
Tools: []Tool{
{
Type: "function",
Name: "get_weather",
Description: "Get weather info",
},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StopReason != "tool_use" {
t.Errorf("expected stop reason 'tool_use', got %q", resp.StopReason)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls))
}
if resp.ToolCalls[0].Name != "get_weather" {
t.Errorf("expected tool name 'get_weather', got %q", resp.ToolCalls[0].Name)
}
}
func TestGeminiClient_Chat_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(geminiError{
Error: struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
}{
Code: 401,
Message: "Invalid API key",
Status: "UNAUTHENTICATED",
},
})
}))
defer server.Close()
client := NewGeminiClient("invalid-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Hello"}},
})
if err == nil {
t.Error("expected error for invalid API key")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected error to contain status code, got %v", err)
}
}
func TestGeminiClient_Chat_NoCandidates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := geminiResponse{
Candidates: []geminiCandidate{},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Hello"}},
})
if err == nil {
t.Error("expected error for no candidates")
}
if !strings.Contains(err.Error(), "no response candidates") {
t.Errorf("expected 'no response candidates' error, got %v", err)
}
}
func TestGeminiClient_Chat_SafetyBlocked(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := geminiResponse{
Candidates: []geminiCandidate{
{
Content: geminiContent{Parts: []geminiPart{}},
FinishReason: "SAFETY",
SafetyRatings: []geminySafety{
{Category: "HARM_CATEGORY_DANGEROUS", Probability: "HIGH", Blocked: true},
},
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Something dangerous"}},
})
if err == nil {
t.Error("expected error for safety blocked content")
}
if !strings.Contains(err.Error(), "safety") {
t.Errorf("expected 'safety' in error, got %v", err)
}
}
func TestGeminiClient_Chat_PromptBlocked(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := geminiResponse{
PromptFeedback: &geminiPromptFeedback{
BlockReason: "SAFETY",
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Blocked prompt"}},
})
if err == nil {
t.Error("expected error for blocked prompt")
}
if !strings.Contains(err.Error(), "blocked by Gemini") {
t.Errorf("expected 'blocked by Gemini' error, got %v", err)
}
}
func TestGeminiClient_ListModels_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Errorf("expected GET, got %s", r.Method)
}
if !strings.Contains(r.URL.Path, "/models") {
t.Errorf("expected /models in path, got %s", r.URL.Path)
}
resp := struct {
Models []struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
} `json:"models"`
}{
Models: []struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
}{
{
Name: "models/gemini-1.5-pro",
DisplayName: "Gemini 1.5 Pro",
Description: "Advanced language model",
SupportedGenerationMethods: []string{"generateContent"},
},
{
Name: "models/gemini-1.5-flash",
DisplayName: "Gemini 1.5 Flash",
Description: "Fast model",
SupportedGenerationMethods: []string{"generateContent"},
},
{
Name: "models/text-embedding-004", // Should be filtered
DisplayName: "Text Embedding",
Description: "Embedding model",
SupportedGenerationMethods: []string{"embedContent"},
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
models, err := client.ListModels(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should only include gemini-1.5-* models, not embedding
if len(models) != 2 {
t.Errorf("expected 2 models, got %d", len(models))
}
}
func TestGeminiClient_ListModels_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.ListModels(ctx)
if err == nil {
t.Error("expected error for server error")
}
}
func TestGeminiClient_TestConnection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := struct {
Models []interface{} `json:"models"`
}{Models: []interface{}{}}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
err := client.TestConnection(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestGeminiClient_Chat_NetworkError(t *testing.T) {
client := NewGeminiClient("test-key", "gemini-pro", "http://localhost:99999")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{{Role: "user", Content: "Hello"}},
})
if err == nil {
t.Error("expected error for network failure")
}
}
func TestGeminiClient_Chat_RoleConversion(t *testing.T) {
var receivedReq geminiRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&receivedReq)
resp := geminiResponse{
Candidates: []geminiCandidate{
{
Content: geminiContent{Parts: []geminiPart{{Text: "Ok"}}},
FinishReason: "STOP",
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL)
ctx := context.Background()
_, err := client.Chat(ctx, ChatRequest{
Messages: []Message{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there"},
{Role: "user", Content: "How are you?"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify role conversion: assistant -> model
if len(receivedReq.Contents) != 3 {
t.Fatalf("expected 3 contents, got %d", len(receivedReq.Contents))
}
if receivedReq.Contents[0].Role != "user" {
t.Errorf("expected first role 'user', got %q", receivedReq.Contents[0].Role)
}
if receivedReq.Contents[1].Role != "model" {
t.Errorf("expected second role 'model', got %q", receivedReq.Contents[1].Role)
}
if receivedReq.Contents[2].Role != "user" {
t.Errorf("expected third role 'user', got %q", receivedReq.Contents[2].Role)
}
}