diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-20 18:35:28 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-20 18:35:28 +0300 |
| commit | d2666ade55d40b6383659a1063cc9e4aa7d8745d (patch) | |
| tree | 18a50d2486bee6fd2e3bda55dbad92c846a3da24 /internal | |
| parent | 17f72f39cfde04f04ec2f76dc109d99ff2de80f6 (diff) | |
test(yousearch): add unit tests; allow base URL override for tests
Amp-Thread-ID: https://ampcode.com/threads/T-019e45ff-4976-750c-b2e6-121d0e5991ef
Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/llm/yousearch.go | 14 | ||||
| -rw-r--r-- | internal/llm/yousearch_test.go | 179 |
2 files changed, 191 insertions, 2 deletions
diff --git a/internal/llm/yousearch.go b/internal/llm/yousearch.go index d38a6e2..7539e8d 100644 --- a/internal/llm/yousearch.go +++ b/internal/llm/yousearch.go @@ -20,6 +20,7 @@ const youSearchResearchURL = "https://api.you.com/v1/research" type youSearchClient struct { httpClient *http.Client apiKey string + baseURL string // research endpoint URL (overridable for tests) researchEffort string // lite|standard|deep|exhaustive chatLogger logging.ChatLogger } @@ -56,11 +57,19 @@ func youSearchProviderFactory(cfg Config, keys ProviderKeys) (Client, error) { return youSearchClient{ httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: strings.TrimSpace(keys.YouSearchAPIKey), + baseURL: youSearchResearchURL, researchEffort: strings.TrimSpace(cfg.YouSearchResearchEffort), chatLogger: logging.NewChatLogger("yousearch"), }, nil } +func (c youSearchClient) endpoint() string { + if c.baseURL != "" { + return c.baseURL + } + return youSearchResearchURL +} + func (c youSearchClient) Name() string { return "yousearch" } func (c youSearchClient) DefaultModel() string { return c.effectiveEffort() } @@ -89,14 +98,15 @@ func (c youSearchClient) Chat(ctx context.Context, messages []Message, opts ...R return "", err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, youSearchResearchURL, bytes.NewReader(payload)) + url := c.endpoint() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return "", err } req.Header.Set("X-API-Key", c.apiKey) req.Header.Set("Content-Type", "application/json") - logging.Logf("llm/yousearch", "POST %s effort=%s", youSearchResearchURL, c.effectiveEffort()) + logging.Logf("llm/yousearch", "POST %s effort=%s", url, c.effectiveEffort()) resp, err := c.httpClient.Do(req) if err != nil { logging.Logf("llm/yousearch", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) diff --git a/internal/llm/yousearch_test.go b/internal/llm/yousearch_test.go new file mode 100644 index 0000000..ca3f7e9 --- /dev/null +++ b/internal/llm/yousearch_test.go @@ -0,0 +1,179 @@ +package llm + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// unsetYouSearchEnv ensures no real YOU_API_KEY / HEXAI_YOUSEARCH_API_KEY in the +// environment can influence factory tests that should fail when no key is passed. +// We restore the previous values when the test ends. +func unsetYouSearchEnv(t *testing.T) { + t.Helper() + for _, k := range []string{"YOU_API_KEY", "HEXAI_YOUSEARCH_API_KEY", "HEXAI_YOUSEARCH_RESEARCH_EFFORT"} { + t.Setenv(k, "") + } +} + +func TestYouSearchFactory_MissingKey(t *testing.T) { + unsetYouSearchEnv(t) + if _, err := NewFromConfig(Config{Provider: "yousearch"}, "", "", "", "", ""); err == nil { + t.Fatalf("expected error when YouSearch API key is missing") + } else if !strings.Contains(err.Error(), "HEXAI_YOUSEARCH_API_KEY") || !strings.Contains(err.Error(), "YOU_API_KEY") { + t.Fatalf("expected actionable API key hint, got %q", err.Error()) + } +} + +func TestYouSearchFactory_Success(t *testing.T) { + unsetYouSearchEnv(t) + c, err := NewFromConfig(Config{Provider: "yousearch", YouSearchResearchEffort: "deep"}, "", "", "", "", "ys-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.Name() != "yousearch" { + t.Fatalf("expected name yousearch, got %s", c.Name()) + } + if c.DefaultModel() != "deep" { + t.Fatalf("expected default model 'deep', got %s", c.DefaultModel()) + } +} + +func TestYouSearchClient_DefaultModel_Empty(t *testing.T) { + c := youSearchClient{} + if got := c.DefaultModel(); got != "standard" { + t.Fatalf("expected 'standard', got %s", got) + } +} + +func TestYouSearchChat_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.Header.Get("X-API-Key") != "ys-key" { + t.Fatalf("expected X-API-Key header to be ys-key, got %q", r.Header.Get("X-API-Key")) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("expected application/json content type") + } + body, _ := io.ReadAll(r.Body) + var req youSearchRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Input != "what is foo?" { + t.Fatalf("expected input 'what is foo?', got %q", req.Input) + } + if req.ResearchEffort != "lite" { + t.Fatalf("expected research_effort lite, got %q", req.ResearchEffort) + } + resp := map[string]any{ + "output": map[string]any{ + "content": "Foo is a placeholder.", + "content_type": "text/markdown", + "sources": []map[string]any{ + {"url": "https://example.com/a", "title": "A"}, + {"url": "https://example.com/b", "title": ""}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c := youSearchClient{ + httpClient: srv.Client(), + apiKey: "ys-key", + baseURL: srv.URL, + researchEffort: "lite", + } + + out, err := c.Chat(context.Background(), []Message{ + {Role: "system", Content: "ignored"}, + {Role: "user", Content: "what is foo?"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "Foo is a placeholder.") { + t.Fatalf("expected content in output, got %q", out) + } + if !strings.Contains(out, "**Sources:**") { + t.Fatalf("expected sources section, got %q", out) + } + if !strings.Contains(out, "[A](https://example.com/a)") { + t.Fatalf("expected first source linked, got %q", out) + } + if !strings.Contains(out, "[https://example.com/b](https://example.com/b)") { + t.Fatalf("expected fallback title when title is empty, got %q", out) + } +} + +func TestYouSearchChat_NoUserMessage(t *testing.T) { + c := youSearchClient{apiKey: "ys-key", baseURL: "http://unused.invalid"} + _, err := c.Chat(context.Background(), []Message{{Role: "system", Content: "hi"}}) + if err == nil || !strings.Contains(err.Error(), "no user message") { + t.Fatalf("expected no-user-message error, got %v", err) + } +} + +func TestYouSearchChat_Non2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"nope"}`)) + })) + defer srv.Close() + + c := youSearchClient{httpClient: srv.Client(), apiKey: "ys-key", baseURL: srv.URL} + _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "q"}}) + if err == nil || !strings.Contains(err.Error(), "status 401") { + t.Fatalf("expected status 401 error, got %v", err) + } +} + +func TestYouSearchChat_EmptyResponse(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(`{"output":{"content":"","sources":[]}}`)) + })) + defer srv.Close() + + c := youSearchClient{httpClient: srv.Client(), apiKey: "ys-key", baseURL: srv.URL} + _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "q"}}) + if err == nil || !strings.Contains(err.Error(), "empty response") { + t.Fatalf("expected empty-response error, got %v", err) + } +} + +func TestLastUserMessage(t *testing.T) { + got := lastUserMessage([]Message{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: " first "}, + {Role: "assistant", Content: "a"}, + {Role: "USER", Content: " second "}, + }) + if got != "second" { + t.Fatalf("expected 'second', got %q", got) + } + if got := lastUserMessage(nil); got != "" { + t.Fatalf("expected empty for nil messages, got %q", got) + } + if got := lastUserMessage([]Message{{Role: "assistant", Content: "a"}}); got != "" { + t.Fatalf("expected empty when no user messages, got %q", got) + } +} + +func TestFormatYouSearchContent_NonStringContent(t *testing.T) { + var r youSearchResponse + r.Output.Content = map[string]any{"k": "v"} + got := formatYouSearchContent(r) + if !strings.Contains(got, `"k"`) || !strings.Contains(got, `"v"`) { + t.Fatalf("expected marshalled map in output, got %q", got) + } +} |
