package tools import ( "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/mark3labs/mcp-go/mcp" mcpgo "github.com/mark3labs/mcp-go/server" "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" ) const repoJSON = `{"id":7,"forge_remote_id":"101","name":"demo","owner":"me","full_name":"me/demo","default_branch":"main","private":false,"trusted":true,"active":true,"config_file":".woodpecker.yml","timeout":60,"forge_id":1}` const pipelineJSON = `{ "id":1,"number":42,"status":"failure","event":"push", "commit":"abc123","branch":"main","ref":"refs/heads/main", "message":"fix: nope","author":"me","author_email":"me@example.com", "created_at":10,"updated_at":11,"started_at":12,"finished_at":13, "workflows":[ {"id":1,"name":"build","state":"failure","start_time":12,"end_time":13,"pipeline_id":1, "children":[ {"id":11,"name":"build","uuid":"u1","type":"commands","state":"failure","error":"exit code 1","exit_code":1,"start_time":12,"end_time":13,"pipeline_id":1}, {"id":12,"name":"publish","uuid":"u2","type":"plugin","state":"skipped","pipeline_id":1} ]} ] }` // newFakeClient spins up a fake Woodpecker API backed by mux and returns a // client pointed at it. func newFakeClient(t *testing.T, mux *http.ServeMux) *woodpecker.Client { t.Helper() srv := httptest.NewServer(mux) t.Cleanup(srv.Close) c, err := woodpecker.NewClient(srv.URL+"/api", "test-token", woodpecker.WithHTTPClient(srv.Client())) if err != nil { t.Fatal(err) } return c } // call invokes a tool handler with the given arguments. func call(t *testing.T, h mcpgo.ToolHandlerFunc, args map[string]any) *mcp.CallToolResult { t.Helper() res, err := h(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: args}}) if err != nil { t.Fatalf("handler returned error: %v", err) } return res } func resultText(t *testing.T, res *mcp.CallToolResult) string { t.Helper() if res == nil || len(res.Content) == 0 { t.Fatal("result has no content") } tc, ok := res.Content[0].(mcp.TextContent) if !ok { t.Fatalf("unexpected content type %T", res.Content[0]) } return tc.Text } func decodeResult[T any](t *testing.T, res *mcp.CallToolResult) T { t.Helper() var out T if err := json.Unmarshal([]byte(resultText(t, res)), &out); err != nil { t.Fatalf("result is not valid JSON: %v\n%s", err, resultText(t, res)) } return out } func assertQuery(t *testing.T, r *http.Request, key, want string) { t.Helper() if got := r.URL.Query().Get(key); got != want { t.Errorf("query %q = %q, want %q", key, got, want) } } // --- search_repository --- func TestSearchRepositorySuccess(t *testing.T) { var gotPath string mux := http.NewServeMux() mux.HandleFunc("/api/repos/lookup/", func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.EscapedPath() _, _ = w.Write([]byte(repoJSON)) }) c := newFakeClient(t, mux) _, h := searchRepositoryTool(c) res := call(t, h, map[string]any{"name": "me/demo"}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } if gotPath != "/api/repos/lookup/me%2Fdemo" { t.Errorf("path = %q", gotPath) } out := decodeResult[map[string]any](t, res) if out["name"] != "me/demo" { t.Errorf("name = %v", out["name"]) } repo, _ := out["repository"].(map[string]any) if repo == nil { t.Fatal("missing repository field") } if repo["full_name"] != "me/demo" || repo["id"] != float64(7) { t.Errorf("repository = %v", repo) } if repo["default_branch"] != "main" { t.Errorf("default_branch = %v", repo["default_branch"]) } } func TestSearchRepositoryInvalidName(t *testing.T) { c := newFakeClient(t, http.NewServeMux()) _, h := searchRepositoryTool(c) for _, name := range []string{"no-slash", "owner/repo/extra", "space name/also", ""} { res := call(t, h, map[string]any{"name": name}) if !res.IsError { t.Errorf("name %q: expected error result", name) } text := resultText(t, res) if !strings.Contains(text, "Invalid repository name format") { t.Errorf("name %q: error text %q", name, text) } } } func TestSearchRepositoryNotFound(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/api/repos/lookup/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"repository not found"}`)) }) c := newFakeClient(t, mux) _, h := searchRepositoryTool(c) res := call(t, h, map[string]any{"name": "me/missing"}) if !res.IsError { t.Fatal("expected error result") } text := resultText(t, res) if !strings.Contains(text, `"error":"NOT_FOUND"`) { t.Errorf("expected NOT_FOUND context, got %s", text) } if !strings.Contains(text, "repository lookup for me/missing") { t.Errorf("expected operation context, got %s", text) } } // --- get_repository_by_id --- func TestGetRepositoryByID(t *testing.T) { var gotPath string mux := http.NewServeMux() mux.HandleFunc("/api/repos/", func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path _, _ = w.Write([]byte(repoJSON)) }) c := newFakeClient(t, mux) _, h := getRepositoryByIDTool(c) res := call(t, h, map[string]any{"repoId": float64(7)}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } if gotPath != "/api/repos/7" { t.Errorf("path = %q", gotPath) } out := decodeResult[map[string]any](t, res) if out["repoId"] != float64(7) { t.Errorf("repoId = %v", out["repoId"]) } } func TestGetRepositoryByIDInvalid(t *testing.T) { c := newFakeClient(t, http.NewServeMux()) _, h := getRepositoryByIDTool(c) res := call(t, h, map[string]any{"repoId": float64(0)}) if !res.IsError { t.Fatal("expected error result for repoId 0") } } // --- list_repositories --- func TestListRepositoriesParamsAndCount(t *testing.T) { var req *http.Request mux := http.NewServeMux() mux.HandleFunc("/api/repos", func(w http.ResponseWriter, r *http.Request) { req = r.Clone(r.Context()) _, _ = w.Write([]byte("[" + repoJSON + "," + repoJSON + "]")) }) c := newFakeClient(t, mux) _, h := listRepositoriesTool(c) res := call(t, h, map[string]any{ "page": float64(2), "perPage": float64(10), "active": true, }) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } assertQuery(t, req, "page", "2") assertQuery(t, req, "perPage", "10") assertQuery(t, req, "active", "true") out := decodeResult[map[string]any](t, res) if out["count"] != float64(2) { t.Errorf("count = %v", out["count"]) } } func TestListRepositoriesDefaults(t *testing.T) { var req *http.Request mux := http.NewServeMux() mux.HandleFunc("/api/repos", func(w http.ResponseWriter, r *http.Request) { req = r.Clone(r.Context()) _, _ = w.Write([]byte("[]")) }) c := newFakeClient(t, mux) _, h := listRepositoriesTool(c) res := call(t, h, map[string]any{}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } assertQuery(t, req, "page", "1") assertQuery(t, req, "perPage", "50") if _, ok := req.URL.Query()["active"]; ok { t.Error("active should be omitted when not provided") } out := decodeResult[map[string]any](t, res) if out["count"] != float64(0) { t.Errorf("count = %v", out["count"]) } } func TestListRepositoriesRejectsBadPerPage(t *testing.T) { c := newFakeClient(t, http.NewServeMux()) _, h := listRepositoriesTool(c) res := call(t, h, map[string]any{"perPage": float64(200)}) if !res.IsError { t.Fatal("expected error result for perPage 200") } if !strings.Contains(resultText(t, res), "between") { t.Errorf("error text %q", resultText(t, res)) } } // --- list_pipelines --- func TestListPipelines(t *testing.T) { var req *http.Request mux := http.NewServeMux() mux.HandleFunc("/api/repos/1/pipelines", func(w http.ResponseWriter, r *http.Request) { req = r.Clone(r.Context()) _, _ = w.Write([]byte("[" + pipelineJSON + "]")) }) c := newFakeClient(t, mux) _, h := listPipelinesTool(c) res := call(t, h, map[string]any{ "repoId": float64(1), "before": "2023-12-01T10:00:00Z", "after": "2023-11-01T10:00:00Z", "perPage": float64(5), }) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } assertQuery(t, req, "page", "1") assertQuery(t, req, "perPage", "5") assertQuery(t, req, "before", "2023-12-01T10:00:00Z") assertQuery(t, req, "after", "2023-11-01T10:00:00Z") out := decodeResult[map[string]any](t, res) if out["repoId"] != float64(1) || out["count"] != float64(1) { t.Errorf("out = %v", out) } pipelines, _ := out["pipelines"].([]any) first, _ := pipelines[0].(map[string]any) if first["number"] != float64(42) || first["status"] != "failure" { t.Errorf("first pipeline = %v", first) } } // --- get_pipeline --- func TestGetPipelineWorkflowsAndSteps(t *testing.T) { var gotPath string mux := http.NewServeMux() mux.HandleFunc("/api/repos/1/pipelines/42", func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path _, _ = w.Write([]byte(pipelineJSON)) }) c := newFakeClient(t, mux) _, h := getPipelineTool(c) res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42)}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } if gotPath != "/api/repos/1/pipelines/42" { t.Errorf("path = %q", gotPath) } out := decodeResult[map[string]any](t, res) if out["pipelineNumber"] != float64(42) { t.Errorf("pipelineNumber = %v", out["pipelineNumber"]) } p, _ := out["pipeline"].(map[string]any) if p["email"] != "me@example.com" { t.Errorf("email = %v", p["email"]) } workflows, _ := p["workflows"].([]any) wf, _ := workflows[0].(map[string]any) if wf["name"] != "build" { t.Errorf("workflow = %v", wf) } children, _ := wf["children"].([]any) step, _ := children[0].(map[string]any) if step["exitCode"] != float64(1) || step["error"] != "exit code 1" { t.Errorf("step = %v", step) } if _, hasError := wf["error"]; hasError { t.Error("error should be omitted when null in the API response") } } func TestGetPipelineMissingNumber(t *testing.T) { c := newFakeClient(t, http.NewServeMux()) _, h := getPipelineTool(c) res := call(t, h, map[string]any{"repoId": float64(1)}) if !res.IsError { t.Fatal("expected error result") } } // --- get_pipeline_config --- func TestGetPipelineConfigDecodesBase64(t *testing.T) { configData := base64.StdEncoding.EncodeToString([]byte("pipeline:\n build:\n image: alpine")) payload := fmt.Sprintf(`[{"name":"ci/.woodpecker.yml","data":%q,"hash":"abc123"}]`, configData) mux := http.NewServeMux() mux.HandleFunc("/api/repos/1/pipelines/42/config", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(payload)) }) c := newFakeClient(t, mux) _, h := getPipelineConfigTool(c) res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42)}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } out := decodeResult[map[string]any](t, res) if out["count"] != float64(1) { t.Errorf("count = %v", out["count"]) } configs, _ := out["configs"].([]any) cfg, _ := configs[0].(map[string]any) if cfg["content"] != "pipeline:\n build:\n image: alpine" { t.Errorf("content = %q", cfg["content"]) } if cfg["hash"] != "abc123" || cfg["name"] != "ci/.woodpecker.yml" { t.Errorf("cfg = %v", cfg) } } func TestGetPipelineConfigInvalidBase64FallsBack(t *testing.T) { payload := `[{"name":"bad.woodpecker.yml","data":"not!base64!","hash":"h"}]` mux := http.NewServeMux() mux.HandleFunc("/api/repos/1/pipelines/1/config", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(payload)) }) c := newFakeClient(t, mux) _, h := getPipelineConfigTool(c) res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(1)}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } out := decodeResult[map[string]any](t, res) configs, _ := out["configs"].([]any) cfg, _ := configs[0].(map[string]any) if cfg["content"] != "not!base64!" { t.Errorf("content = %q, want raw fallback", cfg["content"]) } } // --- get_step_logs --- func TestGetStepLogsDecodesAndJoins(t *testing.T) { lineOne := base64.StdEncoding.EncodeToString([]byte("line one")) lineThree := base64.StdEncoding.EncodeToString([]byte("line three")) payload := fmt.Sprintf( `[{"id":1,"step_id":11,"time":1,"line":1,"data":%q,"type":0},`+ `{"id":2,"step_id":11,"time":2,"line":2,"data":null,"type":0},`+ `{"id":3,"step_id":11,"time":3,"line":3,"data":%q,"type":1}]`, lineOne, lineThree, ) mux := http.NewServeMux() mux.HandleFunc("/api/repos/1/logs/42/11", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(payload)) }) c := newFakeClient(t, mux) _, h := getStepLogsTool(c) res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42), "stepId": float64(11)}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } out := decodeResult[map[string]any](t, res) if out["stepId"] != float64(11) { t.Errorf("stepId = %v", out["stepId"]) } if out["logs"] != "line one\n\nline three" { t.Errorf("logs = %q", out["logs"]) } } func TestGetStepLogsNullBody(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/api/repos/1/logs/42/11", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("null")) }) c := newFakeClient(t, mux) _, h := getStepLogsTool(c) res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42), "stepId": float64(11)}) if res.IsError { t.Fatalf("unexpected error: %s", resultText(t, res)) } out := decodeResult[map[string]any](t, res) if out["logs"] != noLogsMessage { t.Errorf("logs = %q, want no-logs message", out["logs"]) } }