forgejo-mcp/operation/branchprotection/resources_branchprotection_test.go
Christoph Görn 0826172c23
feat: ✨ branch protection management — CRUD tools + forgejo:// resources (uc6)
Implements the branch-protection OpenSpec change (#195). New
operation/branchprotection/ package:

- Tools: list/get/create/edit/delete_branch_protection over
  /repos/{owner}/{repo}/branch_protections.
- list_branch_protections is output-bounded (page/limit; echoes page/limit
  for resumability).
- create/edit round-trip status_check_contexts (comma-separated). edit uses
  PATCH pointer semantics (pkg/ptr.To): only caller-passed fields change;
  unpassed fields serialize as null (leave-unchanged), never false.
- Resources: bounded collection forgejo://repo/{o}/{r}/branch_protections
  (EmbeddedListCap sentinel -> list_branch_protections) and single
  forgejo://repo/{o}/{r}/branch_protection/{rule} (rule may contain slashes).
- ParseBranchProtection(s) added to operation/resource; wired into
  operation.go + cmd/cli.go domain grouping; README tool table updated.
- httptest coverage: list, get (ok/404), create (contexts round-trip;
  missing branch_name -> no call), edit (only-passed-fields null-safety;
  contexts), delete; parser + truncation + error-mapping tests.

Refines the edit scenario in the change spec to match SDK marshaling
(null = leave-unchanged). tasks.md complete.

Closes forgejo-mcp-uc6.
2026-06-02 16:22:24 +02:00

155 lines
5.1 KiB
Go

package branchprotection
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/goern/forgejo-mcp/v2/operation/resource"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
)
func setupBPResourceServer(t *testing.T, status int, body interface{}) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}))
client, err := forgejo_sdk.NewClient(srv.URL, forgejo_sdk.SetForgejoVersion("7.0.0"))
if err != nil {
t.Fatalf("creating test client: %v", err)
}
forgejo.SetClientForTesting(client)
return srv
}
func readResourceReq(uri string) mcp.ReadResourceRequest {
return mcp.ReadResourceRequest{Params: mcp.ReadResourceParams{URI: uri}}
}
func resourceText(t *testing.T, contents []mcp.ResourceContents) string {
t.Helper()
if len(contents) != 1 {
t.Fatalf("expected 1 content block, got %d", len(contents))
}
tc, ok := contents[0].(mcp.TextResourceContents)
if !ok {
t.Fatalf("expected TextResourceContents, got %T", contents[0])
}
return tc.Text
}
func TestBranchProtectionsResource_HappyPath(t *testing.T) {
srv := setupBPResourceServer(t, http.StatusOK, []map[string]interface{}{
{"rule_name": "main", "branch_name": "main", "required_approvals": 1},
{"rule_name": "release/*", "branch_name": "release/*"},
})
defer srv.Close()
contents, err := branchProtectionsResourceHandler(context.Background(),
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protections"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var payload bpCollectionPayload
if err := json.Unmarshal([]byte(resourceText(t, contents)), &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload.TotalCount != 2 || len(payload.BranchProtections) != 2 {
t.Errorf("expected 2 rules, got total=%d shown=%d", payload.TotalCount, len(payload.BranchProtections))
}
if payload.Truncated {
t.Error("did not expect truncation for 2 rules")
}
}
func TestBranchProtectionsResource_Truncation(t *testing.T) {
rules := make([]map[string]interface{}, resource.EmbeddedListCap+1)
for i := range rules {
rules[i] = map[string]interface{}{"rule_name": fmt.Sprintf("rule-%d", i), "branch_name": fmt.Sprintf("b-%d", i)}
}
srv := setupBPResourceServer(t, http.StatusOK, rules)
defer srv.Close()
contents, err := branchProtectionsResourceHandler(context.Background(),
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protections"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var payload bpCollectionPayload
if err := json.Unmarshal([]byte(resourceText(t, contents)), &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if !payload.Truncated {
t.Error("expected truncation when over EmbeddedListCap")
}
if len(payload.BranchProtections) != resource.EmbeddedListCap {
t.Errorf("expected %d shown rules, got %d", resource.EmbeddedListCap, len(payload.BranchProtections))
}
if payload.ListTool != "list_branch_protections" {
t.Errorf("expected list_tool sentinel, got %q", payload.ListTool)
}
}
func TestBranchProtectionsResource_NotFound(t *testing.T) {
srv := setupBPResourceServer(t, http.StatusNotFound, map[string]string{"message": "Not Found"})
defer srv.Close()
_, err := branchProtectionsResourceHandler(context.Background(),
readResourceReq("forgejo://repo/goern/missing/branch_protections"))
if err == nil {
t.Fatal("expected error for 404")
}
re, ok := err.(*resource.ResourceError)
if !ok {
t.Fatalf("expected *resource.ResourceError, got %T", err)
}
if re.Code != -32003 {
t.Errorf("expected code -32003 (not found), got %d", re.Code)
}
}
func TestBranchProtectionResource_HappyPath(t *testing.T) {
srv := setupBPResourceServer(t, http.StatusOK, map[string]interface{}{
"rule_name": "main", "branch_name": "main",
"status_check_contexts": []string{"ci/build"}, "required_approvals": 2,
})
defer srv.Close()
contents, err := branchProtectionResourceHandler(context.Background(),
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protection/main"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var bp forgejo_sdk.BranchProtection
if err := json.Unmarshal([]byte(resourceText(t, contents)), &bp); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if bp.RuleName != "main" || bp.RequiredApprovals != 2 || len(bp.StatusCheckContexts) != 1 {
t.Errorf("unexpected protection state: %+v", bp)
}
}
func TestBranchProtectionResource_MalformedURI(t *testing.T) {
// No server needed: parse fails before any client call.
_, err := branchProtectionResourceHandler(context.Background(),
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protection"))
if err == nil {
t.Fatal("expected error for malformed URI (missing rule)")
}
re, ok := err.(*resource.ResourceError)
if !ok {
t.Fatalf("expected *resource.ResourceError, got %T", err)
}
if re.Code != -32602 {
t.Errorf("expected code -32602 (invalid params), got %d", re.Code)
}
}