forgejo-mcp/operation/resource/parse_test.go
Christoph Görn 1aa797ab7a
refactor: 🏗️ classify parse errors via sentinel ErrInvalidParams (-32602)
MapForgejoError previously classified HTTP results by substring
matching. Two failure modes: URIs/error bodies legitimately
containing "404"/"Forbidden" mis-classified, and parser errors of
shape "invalid URI: expected forgejo://..." fell through to -32603
because the existing "kind must be"/"index must be numeric"
substrings did not cover them.

Introduce resource.ErrInvalidParams sentinel; wrap every parser
return path with it (Parse{Owner,Repo,Commit,Issue,PR,Comment,Status},
parseForgejoURI, validateSHA). MapForgejoError now classifies via
errors.Is(err, ErrInvalidParams) before falling back to HTTP
substring matching for 403/404. Substring matching for 403/404
stays as a transition fallback because the SDK still surfaces
HTTP status only via formatted error strings.

Update handler tests that asserted Code == -32603 on parse errors
(set by forgejo-mcp-vzz) to assert -32602.

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

178 lines
4.4 KiB
Go

package resource
import (
"errors"
"testing"
)
func TestParseOwner(t *testing.T) {
p, err := ParseOwner("forgejo://owner/goern")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Owner != "goern" {
t.Errorf("expected owner=goern, got %q", p.Owner)
}
}
func TestParseOwner_WrongHost(t *testing.T) {
_, err := ParseOwner("forgejo://repo/goern")
if err == nil {
t.Fatal("expected error for wrong host")
}
}
func TestParseRepo(t *testing.T) {
p, err := ParseRepo("forgejo://repo/goern/forgejo-mcp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Owner != "goern" || p.Repo != "forgejo-mcp" {
t.Errorf("unexpected: %+v", p)
}
}
func TestParseRepo_TooShort(t *testing.T) {
_, err := ParseRepo("forgejo://repo/goern")
if err == nil {
t.Fatal("expected error for missing repo segment")
}
}
func TestParseCommit_HappyPath(t *testing.T) {
sha := "a3f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9"
uri := "forgejo://repo/goern/forgejo-mcp/commit/" + sha
p, err := ParseCommit(uri)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.SHA != sha {
t.Errorf("expected sha=%q, got %q", sha, p.SHA)
}
}
func TestParseCommit_ShortSHA(t *testing.T) {
_, err := ParseCommit("forgejo://repo/goern/forgejo-mcp/commit/abc123")
if err == nil {
t.Fatal("expected error for short sha")
}
if !errors.Is(err, ErrInvalidParams) {
t.Errorf("expected ErrInvalidParams, got %v", err)
}
}
func TestParseIssue_HappyPath(t *testing.T) {
p, err := ParseIssue("forgejo://repo/goern/forgejo-mcp/issue/42")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Index != 42 {
t.Errorf("expected index=42, got %d", p.Index)
}
}
func TestParseIssue_NonNumericIndex(t *testing.T) {
_, err := ParseIssue("forgejo://repo/goern/forgejo-mcp/issue/abc")
if err == nil {
t.Fatal("expected error for non-numeric index")
}
if !errors.Is(err, ErrInvalidParams) {
t.Errorf("expected ErrInvalidParams, got %v", err)
}
}
func TestParsePR_HappyPath(t *testing.T) {
p, err := ParsePR("forgejo://repo/goern/forgejo-mcp/pr/7")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Index != 7 {
t.Errorf("expected index=7, got %d", p.Index)
}
}
func TestParseComment_HappyPath(t *testing.T) {
p, err := ParseComment("forgejo://repo/goern/forgejo-mcp/issue/42/comment/99")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Kind != "issue" || p.Index != 42 || p.ID != 99 {
t.Errorf("unexpected: %+v", p)
}
}
func TestParseComment_PRKind(t *testing.T) {
p, err := ParseComment("forgejo://repo/goern/forgejo-mcp/pr/7/comment/5")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Kind != "pr" {
t.Errorf("expected kind=pr, got %q", p.Kind)
}
}
func TestParseComment_UnknownKind(t *testing.T) {
_, err := ParseComment("forgejo://repo/goern/forgejo-mcp/wiki/1/comment/5")
if err == nil {
t.Fatal("expected error for unknown kind")
}
if !errors.Is(err, ErrInvalidParams) {
t.Errorf("expected ErrInvalidParams, got %v", err)
}
}
func TestParseStatus_HappyPath(t *testing.T) {
sha := "a3f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9"
p, err := ParseStatus("forgejo://repo/goern/forgejo-mcp/commit/" + sha + "/status")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.SHA != sha {
t.Errorf("expected sha=%q, got %q", sha, p.SHA)
}
}
func TestParseStatus_ShortSHA(t *testing.T) {
_, err := ParseStatus("forgejo://repo/goern/forgejo-mcp/commit/abc/status")
if err == nil {
t.Fatal("expected error for short sha")
}
}
// TestParseForgejoURI_EmptySegments verifies that URIs with empty or
// whitespace-only path segments are rejected by all entity parsers, not
// silently aliased to canonical forms.
func TestParseForgejoURI_EmptySegments(t *testing.T) {
cases := []struct {
name string
uri string
}{
{
name: "double slash mid-path",
uri: "forgejo://repo/goern//forgejo-mcp",
},
{
name: "trailing slash",
uri: "forgejo://repo/goern/forgejo-mcp/",
},
{
name: "percent-encoded whitespace segment",
uri: "forgejo://repo/%20/forgejo-mcp",
},
{
name: "empty owner (single slash after host)",
uri: "forgejo://owner/",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Use ParseRepo as a representative caller; the rejection happens
// inside parseForgejoURI which all parsers invoke first.
_, err := ParseRepo(tc.uri)
if err == nil {
t.Errorf("expected error for URI %q, got nil", tc.uri)
}
})
}
}