package ai import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) func sampleResult() status.ScanResult { return status.ScanResult{ ScannedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), ParentDir: "/home/user/projects", Repos: []status.RepoInfo{ {Path: "/home/user/projects/api", Name: "api", Branch: "main", Status: status.StatusClean}, {Path: "/home/user/projects/web", Name: "web", Branch: "feat/login", Status: status.StatusModified, ModifiedFiles: []string{"a.go"}}, {Path: "/home/user/projects/lib", Name: "lib", Branch: "main", Status: status.StatusBehind, BehindBy: 5}, }, } } func suggestionsJSON() string { return `[{"repo_path":"/home/user/projects/web","action":"commit","message":"commit your changes","command":"git add -A && git commit -m wip","priority":2}, {"repo_path":"/home/user/projects/lib","action":"pull","message":"pull latest","command":"git pull","priority":1}]` } func TestNewProviderOpenAIRequiresKey(t *testing.T) { t.Setenv("GITFLOW_TEST_AI_KEY", "") if _, err := NewProvider(config.AIConfig{Provider: "openai", APIKeyEnv: "GITFLOW_TEST_AI_KEY"}); err == nil { t.Error("NewProvider(openai, no key) succeeded, want error") } } func TestNewProviderAnthropicRequiresKey(t *testing.T) { t.Setenv("GITFLOW_TEST_AI_KEY", "") if _, err := NewProvider(config.AIConfig{Provider: "anthropic", APIKeyEnv: "GITFLOW_TEST_AI_KEY"}); err == nil { t.Error("NewProvider(anthropic, no key) succeeded, want error") } } func TestNewProviderOllamaNeedsNoKey(t *testing.T) { p, err := NewProvider(config.AIConfig{Provider: "ollama"}) if err != nil { t.Fatalf("NewProvider(ollama): %v", err) } if p.Name() != "ollama" { t.Errorf("Name = %q, want ollama", p.Name()) } } func TestNewProviderUnknown(t *testing.T) { if _, err := NewProvider(config.AIConfig{Provider: "magic"}); err == nil { t.Error("NewProvider(magic) succeeded, want error") } } func TestOpenAIProvider(t *testing.T) { var gotReq chatRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer test-key" { t.Errorf("Authorization = %q, want Bearer test-key", got) } if got := r.URL.Path; got != "/chat/completions" { t.Errorf("path = %q, want /chat/completions", got) } if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { t.Fatal(err) } w.Header().Set("Content-Type", "application/json") content := "```json\n" + suggestionsJSON() + "\n```" resp, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{ "message": map[string]any{"role": "assistant", "content": content}, }}, }) _, _ = w.Write(resp) })) defer srv.Close() p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "test-key") sugs, err := p.Suggest(context.Background(), sampleResult()) if err != nil { t.Fatalf("Suggest: %v", err) } if gotReq.Model != "gpt-test" { t.Errorf("model = %q, want gpt-test", gotReq.Model) } if len(gotReq.Messages) != 2 || gotReq.Messages[0].Role != "system" { t.Errorf("messages malformed: %+v", gotReq.Messages) } if len(sugs) != 2 { t.Fatalf("got %d suggestions, want 2", len(sugs)) } if sugs[0].Action != "commit" || sugs[0].Priority != 2 || sugs[0].RepoPath != "/home/user/projects/web" { t.Errorf("suggestion[0] = %+v", sugs[0]) } } func TestOpenAIProviderErrorBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"error":{"message":"insufficient quota"}}`)) })) defer srv.Close() p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "k") if _, err := p.Suggest(context.Background(), sampleResult()); err == nil { t.Error("Suggest succeeded with API error body, want error") } } func TestOpenAIProviderHTTPFailure(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) defer srv.Close() p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "k") if _, err := p.Suggest(context.Background(), sampleResult()); err == nil { t.Error("Suggest succeeded with 500, want error") } } func TestOllamaProvider(t *testing.T) { var gotReq ollamaRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Path; got != "/api/chat" { t.Errorf("path = %q, want /api/chat", got) } if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { t.Fatal(err) } resp, _ := json.Marshal(map[string]any{ "message": map[string]any{"role": "assistant", "content": suggestionsJSON()}, "error": "", }) _, _ = w.Write(resp) })) defer srv.Close() p := NewOllama(config.AIConfig{BaseURL: srv.URL, Model: "llama-test"}) sugs, err := p.Suggest(context.Background(), sampleResult()) if err != nil { t.Fatalf("Suggest: %v", err) } if gotReq.Model != "llama-test" || gotReq.Format != "json" || gotReq.Stream { t.Errorf("request malformed: %+v", gotReq) } if len(sugs) != 2 { t.Errorf("got %d suggestions, want 2", len(sugs)) } } func TestOllamaProviderErrorField(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"error":"model not found"}`)) })) defer srv.Close() p := NewOllama(config.AIConfig{BaseURL: srv.URL}) if _, err := p.Suggest(context.Background(), sampleResult()); err == nil { t.Error("Suggest succeeded with error field, want error") } } func TestAnthropicProvider(t *testing.T) { var gotReq anthropicRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("x-api-key"); got != "test-key" { t.Errorf("x-api-key = %q", got) } if got := r.Header.Get("anthropic-version"); got != anthropicVersion { t.Errorf("anthropic-version = %q", got) } if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { t.Fatal(err) } resp, _ := json.Marshal(map[string]any{ "content": []any{map[string]any{"type": "text", "text": suggestionsJSON()}}, }) _, _ = w.Write(resp) })) defer srv.Close() p := NewAnthropic(config.AIConfig{BaseURL: srv.URL, Model: "claude-test"}, "test-key") sugs, err := p.Suggest(context.Background(), sampleResult()) if err != nil { t.Fatalf("Suggest: %v", err) } if gotReq.MaxTokens != 1024 || gotReq.System == "" { t.Errorf("request malformed: %+v", gotReq) } if len(sugs) != 2 { t.Errorf("got %d suggestions, want 2", len(sugs)) } } func TestParseSuggestions(t *testing.T) { plain := `[{"repo_path":"/a","action":"push","message":"m","command":"git push","priority":2}]` if got, err := parseSuggestions(plain); err != nil || len(got) != 1 { t.Errorf("plain: got %v, err %v", got, err) } fenced := "Here you go:\n```json\n" + plain + "\n```\nHope that helps!" if got, err := parseSuggestions(fenced); err != nil || len(got) != 1 { t.Errorf("fenced: got %v, err %v", got, err) } if got, err := parseSuggestions("[]"); err != nil || len(got) != 0 { t.Errorf("empty: got %v, err %v", got, err) } if _, err := parseSuggestions("no json here"); err == nil { t.Error("garbage accepted, want error") } bad := `[{"repo_path":` // truncated JSON if _, err := parseSuggestions(bad); err == nil { t.Error("truncated JSON accepted, want error") } } func TestParseSuggestionsClampsPriority(t *testing.T) { out, err := parseSuggestions(`[{"priority":7},{"priority":-3},{"priority":1}]`) if err != nil { t.Fatal(err) } want := []int{2, 0, 1} for i, w := range want { if out[i].Priority != w { t.Errorf("suggestion[%d] priority = %d, want %d", i, out[i].Priority, w) } } } func TestBuildPrompt(t *testing.T) { prompt := BuildPrompt(sampleResult()) for _, want := range []string{ `"/home/user/projects"`, "api", "web", "lib", "feat/login", "repo_path", "priority", "Return ONLY a JSON array", } { if !strings.Contains(prompt, want) { t.Errorf("prompt missing %q", want) } } if strings.Contains(prompt, "\x00") { t.Error("prompt contains NUL bytes") } } // TestProviderHonorsCancellation ensures a cancelled context fails fast // instead of waiting for the upstream. func TestProviderHonorsCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() p := NewOpenAI(config.AIConfig{BaseURL: "http://127.0.0.1:1"}, "k") if _, err := p.Suggest(ctx, sampleResult()); err == nil { t.Error("Suggest(cancelled ctx) succeeded, want error") } }