forgejo-mcp/operation/repo/resources_status_test.go
Christoph Görn ee1217d830
fix: 🐛 request EmbeddedListCap+1 statuses to detect truncation
ListStatuses was called with default options, so Forgejo's server
default PageSize (30 == EmbeddedListCap) silently capped responses
at the cap. The >cap truncation check in resource.Bounded never
fired and the truncated/list_tool sentinel never surfaced for SHAs
with more than 30 status contexts.

Request EmbeddedListCap+1 explicitly so Bounded can correctly
distinguish "at-cap" from "over-cap".

Closes forgejo-mcp-827.
Ref PR #172 review https://codeberg.org/goern/forgejo-mcp/pulls/172#issuecomment-16022165
2026-05-28 16:13:45 +02:00

201 lines
6.2 KiB
Go

package repo
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"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 makeStatusResourceRequest(sha string) mcp.ReadResourceRequest {
return mcp.ReadResourceRequest{
Params: mcp.ReadResourceParams{
URI: "forgejo://repo/testowner/testrepo/commit/" + sha + "/status",
},
}
}
func setupStatusMockServer(t *testing.T, statusCode 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(statusCode)
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 makeStatusList(states ...string) []map[string]interface{} {
result := make([]map[string]interface{}, len(states))
for i, s := range states {
result[i] = map[string]interface{}{
"id": i + 1,
"status": s,
"context": fmt.Sprintf("ci/test-%d", i),
"description": "test context " + s,
"target_url": "https://ci.example.com/" + s,
"created_at": time.Now().Format(time.RFC3339),
"updated_at": time.Now().Format(time.RFC3339),
}
}
return result
}
func TestStatusResourceHandler_HappyPath_UnderCap(t *testing.T) {
statuses := makeStatusList("success", "success", "pending")
srv := setupStatusMockServer(t, http.StatusOK, statuses)
defer srv.Close()
req := makeStatusResourceRequest(testSHA)
contents, err := statusResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(contents) != 1 {
t.Fatalf("expected 1 content block, got %d", len(contents))
}
block, ok := contents[0].(mcp.TextResourceContents)
if !ok {
t.Fatal("content block must be TextResourceContents")
}
if block.MIMEType != "application/json" {
t.Errorf("MIME type: got %q, want application/json", block.MIMEType)
}
var payload statusResourcePayload
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if payload.State != "pending" {
t.Errorf("expected aggregate state=pending (has one pending), got %q", payload.State)
}
if payload.TotalCount != 3 {
t.Errorf("expected total_count=3, got %d", payload.TotalCount)
}
if payload.Truncated {
t.Error("expected truncated=false for 3 statuses under cap")
}
if len(payload.Statuses) != 3 {
t.Errorf("expected 3 status items, got %d", len(payload.Statuses))
}
}
func TestStatusResourceHandler_OverCap_Truncated(t *testing.T) {
// Use exactly EmbeddedListCap+1 items. In production, ListStatuses is now
// called with PageSize=EmbeddedListCap+1, so the server can return up to
// cap+1 items when there are more than cap contexts. The mock HTTP server
// does not enforce server-side pagination limits, so it returns all items
// we supply regardless of the PageSize query parameter — but the
// production fix ensures the server is asked for cap+1 and Bounded can
// correctly detect truncation.
capPlus1 := resource.EmbeddedListCap + 1
states := make([]string, capPlus1)
for i := range states {
states[i] = "success"
}
statuses := makeStatusList(states...)
srv := setupStatusMockServer(t, http.StatusOK, statuses)
defer srv.Close()
req := makeStatusResourceRequest(testSHA)
contents, err := statusResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
block := contents[0].(mcp.TextResourceContents)
var payload statusResourcePayload
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if !payload.Truncated {
t.Errorf("expected truncated=true for %d statuses (EmbeddedListCap+1)", capPlus1)
}
if len(payload.Statuses) != resource.EmbeddedListCap {
t.Errorf("expected %d capped statuses, got %d", resource.EmbeddedListCap, len(payload.Statuses))
}
if payload.ListTool != "get_commit_statuses" {
t.Errorf("expected list_tool=get_commit_statuses, got %q", payload.ListTool)
}
if payload.TotalCount != capPlus1 {
t.Errorf("expected total_count=%d, got %d", capPlus1, payload.TotalCount)
}
}
func TestStatusResourceHandler_EmptyStatuses_StateUnknown(t *testing.T) {
srv := setupStatusMockServer(t, http.StatusOK, []map[string]interface{}{})
defer srv.Close()
req := makeStatusResourceRequest(testSHA)
contents, err := statusResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
block := contents[0].(mcp.TextResourceContents)
var payload statusResourcePayload
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if payload.State != "unknown" {
t.Errorf("expected state=unknown for empty statuses, got %q", payload.State)
}
}
func TestStatusResourceHandler_ShortSHA(t *testing.T) {
req := mcp.ReadResourceRequest{
Params: mcp.ReadResourceParams{
URI: "forgejo://repo/testowner/testrepo/commit/abc123/status",
},
}
_, err := statusResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error for short sha")
}
}
func TestStatusResourceHandler_NotFound(t *testing.T) {
srv := setupStatusMockServer(t, http.StatusNotFound, map[string]string{"message": "not found"})
defer srv.Close()
req := makeStatusResourceRequest(testSHA)
_, err := statusResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error for 404 response")
}
}
func TestComputeAggregateState_AllSuccess(t *testing.T) {
s := forgejo_sdk.StatusSuccess
statuses := []*forgejo_sdk.Status{{State: s}, {State: s}}
if got := computeAggregateState(statuses); got != "success" {
t.Errorf("expected success, got %q", got)
}
}
func TestComputeAggregateState_AnyFailure(t *testing.T) {
statuses := []*forgejo_sdk.Status{
{State: forgejo_sdk.StatusSuccess},
{State: forgejo_sdk.StatusFailure},
}
if got := computeAggregateState(statuses); got != "failure" {
t.Errorf("expected failure, got %q", got)
}
}