package tools_test import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/tools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents // for a set of repos — the real endpoints code_search's underlying SearchCode // now uses (Gitea's REST API has no code-content-search endpoint; see // internal/gitea/code_search.go's doc comment). type multiRepoSearchFake struct { owner string repos []string // ListRepos response, in this order files map[string]map[string]string // repo -> path -> content fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure) } func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") p := r.URL.Path for _, repo := range f.repos { base := "/api/v1/repos/" + f.owner + "/" + repo switch { case p == base && f.fail[repo]: w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message":"internal error"}`)) return case p == base: _, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo) return case strings.HasPrefix(p, base+"/git/trees/"): var entries []string for path := range f.files[repo] { entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path)) } _, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ",")) return case strings.HasPrefix(p, base+"/contents/"): path := strings.TrimPrefix(p, base+"/contents/") content, ok := f.files[repo][path] if !ok { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"not found"}`)) return } enc := base64.StdEncoding.EncodeToString([]byte(content)) _, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc) return } } if p == "/api/v1/users/"+f.owner+"/repos" { var entries []string for _, repo := range f.repos { entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)) } _, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ",")) return } t.Errorf("unexpected request: %s %s", r.Method, p) w.WriteHeader(http.StatusNotFound) } } func TestCodeSearchSingleRepo(t *testing.T) { f := &multiRepoSearchFake{ owner: "mathias", repos: []string{"infra"}, files: map[string]map[string]string{ "infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"}, }, } srv := httptest.NewServer(f.handler(t)) defer srv.Close() tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"ListRepos","owner":"mathias","repo":"infra"}`)) require.NoError(t, err) var result struct { Results []struct { Repo string `json:"repo"` Path string `json:"path"` Snippet string `json:"snippet"` Score float64 `json:"score"` } `json:"results"` } require.NoError(t, json.Unmarshal(out, &result)) require.Len(t, result.Results, 1) assert.Equal(t, "mathias/infra", result.Results[0].Repo) assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path) assert.Contains(t, result.Results[0].Snippet, "ListRepos") } func TestCodeSearchAllowlistRejects(t *testing.T) { tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) _, err := tool.Call(context.Background(), json.RawMessage(`{"q":"foo","owner":"evil","repo":"infra"}`)) require.Error(t, err) } func TestCodeSearchRequiresQ(t *testing.T) { tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`)) require.Error(t, err) assert.True(t, errors.Is(err, gitea.ErrValidation)) } func TestCodeSearchFanOutHappyPath(t *testing.T) { f := &multiRepoSearchFake{ owner: "mathias", repos: []string{"infra", "gitea-mcp"}, files: map[string]map[string]string{ "infra": {"main.go": "this is an infra hit\n"}, "gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"}, }, } srv := httptest.NewServer(f.handler(t)) defer srv.Close() tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`)) require.NoError(t, err) var result struct { Results []struct { Repo string `json:"repo"` Path string `json:"path"` Snippet string `json:"snippet"` } `json:"results"` Partial bool `json:"partial"` } require.NoError(t, json.Unmarshal(out, &result)) assert.False(t, result.Partial) require.Len(t, result.Results, 2) repos := make([]string, 0, 2) for _, r := range result.Results { repos = append(repos, r.Repo) } assert.Contains(t, repos, "mathias/infra") assert.Contains(t, repos, "mathias/gitea-mcp") } func TestCodeSearchFanOutPartialFailure(t *testing.T) { f := &multiRepoSearchFake{ owner: "mathias", repos: []string{"infra", "broken"}, files: map[string]map[string]string{ "infra": {"main.go": "this is an infra hit\n"}, }, fail: map[string]bool{"broken": true}, } srv := httptest.NewServer(f.handler(t)) defer srv.Close() tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`)) require.NoError(t, err) var result struct { Results []struct { Repo string `json:"repo"` } `json:"results"` Partial bool `json:"partial"` PartialRepos []string `json:"partial_repos"` } require.NoError(t, json.Unmarshal(out, &result)) assert.True(t, result.Partial) require.Len(t, result.PartialRepos, 1) assert.Equal(t, "mathias/broken", result.PartialRepos[0]) require.Len(t, result.Results, 1) assert.Equal(t, "mathias/infra", result.Results[0].Repo) } func TestCodeSearchFanOutSortsByScore(t *testing.T) { f := &multiRepoSearchFake{ owner: "mathias", repos: []string{"alpha", "beta"}, files: map[string]map[string]string{ "alpha": {"a.go": "one high here"}, // 1 occurrence => score 1 "beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5 }, } srv := httptest.NewServer(f.handler(t)) defer srv.Close() tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`)) require.NoError(t, err) var result struct { Results []struct { Repo string `json:"repo"` Snippet string `json:"snippet"` Score float64 `json:"score"` } `json:"results"` } require.NoError(t, json.Unmarshal(out, &result)) require.Len(t, result.Results, 2) assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first") assert.True(t, result.Results[0].Score > result.Results[1].Score, "expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score) }