woodpecker-mcp/internal/tools/tools_test.go
2026-08-08 01:37:47 +03:00

463 lines
13 KiB
Go

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"])
}
}