Mechanical formatting cleanup with no semantic changes. Required so the new gofmt-check step in .tekton/tasks/go-ci.yaml passes on first run.
287 lines
8.1 KiB
Go
287 lines
8.1 KiB
Go
package forgejo
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
|
|
)
|
|
|
|
type capturedReq struct {
|
|
method string
|
|
path string
|
|
auth string
|
|
ua string
|
|
ctype string
|
|
body []byte
|
|
}
|
|
|
|
func newCaptureServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, c *capturedReq)) (*httptest.Server, *capturedReq) {
|
|
t.Helper()
|
|
c := &capturedReq{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
c.method = r.Method
|
|
c.path = r.URL.Path
|
|
c.auth = r.Header.Get("Authorization")
|
|
c.ua = r.Header.Get("User-Agent")
|
|
c.ctype = r.Header.Get("Content-Type")
|
|
c.body, _ = io.ReadAll(r.Body)
|
|
handler(w, r, c)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
flag.URL = srv.URL
|
|
flag.Token = "test-token"
|
|
flag.UserAgent = "forgejo-mcp-test/0.0.1"
|
|
return srv, c
|
|
}
|
|
|
|
func TestDoJSON_GetSuccess_HeadersAndDecode(t *testing.T) {
|
|
_, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":42,"name":"hello"}`))
|
|
})
|
|
|
|
var out struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := DoJSON(context.Background(), http.MethodGet, "/repos/o/r/issues/1/assets", nil, &out); err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if out.ID != 42 || out.Name != "hello" {
|
|
t.Fatalf("decode mismatch: %+v", out)
|
|
}
|
|
if c.path != "/api/v1/repos/o/r/issues/1/assets" {
|
|
t.Fatalf("path: got %q", c.path)
|
|
}
|
|
if c.auth != "token test-token" {
|
|
t.Fatalf("auth header: got %q", c.auth)
|
|
}
|
|
if c.ua != "forgejo-mcp-test/0.0.1" {
|
|
t.Fatalf("user-agent: got %q", c.ua)
|
|
}
|
|
}
|
|
|
|
func TestDoJSON_PostBodySerialised(t *testing.T) {
|
|
_, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{}`))
|
|
})
|
|
|
|
body := map[string]string{"name": "renamed.txt"}
|
|
if err := DoJSON(context.Background(), http.MethodPatch, "/x", body, nil); err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if c.method != http.MethodPatch {
|
|
t.Fatalf("method: %s", c.method)
|
|
}
|
|
if c.ctype != "application/json" {
|
|
t.Fatalf("content-type: %s", c.ctype)
|
|
}
|
|
var got map[string]string
|
|
if err := json.Unmarshal(c.body, &got); err != nil {
|
|
t.Fatalf("body not JSON: %v body=%s", err, c.body)
|
|
}
|
|
if got["name"] != "renamed.txt" {
|
|
t.Fatalf("body: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestDoJSON_204NoContent(t *testing.T) {
|
|
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
if err := DoJSON(context.Background(), http.MethodDelete, "/x/1", nil, nil); err != nil {
|
|
t.Fatalf("expected no error on 204, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDoJSON_4xxErrorTypes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
status int
|
|
want error
|
|
}{
|
|
{"401", http.StatusUnauthorized, ErrUnauthorized},
|
|
{"403", http.StatusForbidden, ErrUnauthorized},
|
|
{"404", http.StatusNotFound, ErrNotFound},
|
|
{"500", http.StatusInternalServerError, nil},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.WriteHeader(tc.status)
|
|
_, _ = w.Write([]byte(`{"message":"nope"}`))
|
|
})
|
|
err := DoJSON(context.Background(), http.MethodGet, "/x", nil, nil)
|
|
if err == nil {
|
|
t.Fatalf("expected error for %d", tc.status)
|
|
}
|
|
var he *HTTPError
|
|
if !errors.As(err, &he) {
|
|
t.Fatalf("expected *HTTPError, got %T", err)
|
|
}
|
|
if he.StatusCode != tc.status {
|
|
t.Fatalf("status: got %d want %d", he.StatusCode, tc.status)
|
|
}
|
|
if tc.want != nil && !errors.Is(err, tc.want) {
|
|
t.Fatalf("expected wrapped %v, got %v", tc.want, err)
|
|
}
|
|
if !strings.Contains(he.Body, "nope") {
|
|
t.Fatalf("body snippet missing payload: %q", he.Body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDoJSONList_404IsEmpty(t *testing.T) {
|
|
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
})
|
|
var out []map[string]any
|
|
if err := DoJSONList(context.Background(), http.MethodGet, "/x", &out); err != nil {
|
|
t.Fatalf("404 on list should be nil err, got: %v", err)
|
|
}
|
|
if len(out) != 0 {
|
|
t.Fatalf("expected empty list, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
func TestDoMultipart_RoundTrip(t *testing.T) {
|
|
srv, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"id":7,"name":"f.txt"}`))
|
|
})
|
|
_ = srv
|
|
|
|
var out struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
const payload = "hello world"
|
|
err := DoMultipart(context.Background(), http.MethodPost, "/repos/o/r/issues/1/assets",
|
|
"attachment", "f.txt", "text/plain", strings.NewReader(payload), &out)
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if out.ID != 7 || out.Name != "f.txt" {
|
|
t.Fatalf("decoded: %+v", out)
|
|
}
|
|
|
|
mt, ps, err := mime.ParseMediaType(c.ctype)
|
|
if err != nil {
|
|
t.Fatalf("parse media type: %v", err)
|
|
}
|
|
if mt != "multipart/form-data" {
|
|
t.Fatalf("media type: %s", mt)
|
|
}
|
|
mr := multipart.NewReader(strings.NewReader(string(c.body)), ps["boundary"])
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
t.Fatalf("next part: %v", err)
|
|
}
|
|
if part.FormName() != "attachment" {
|
|
t.Fatalf("form field name: %s", part.FormName())
|
|
}
|
|
if part.FileName() != "f.txt" {
|
|
t.Fatalf("filename: %s", part.FileName())
|
|
}
|
|
if got := part.Header.Get("Content-Type"); got != "text/plain" {
|
|
t.Fatalf("part content-type: %s", got)
|
|
}
|
|
got, _ := io.ReadAll(part)
|
|
if string(got) != payload {
|
|
t.Fatalf("payload mismatch: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDoMultipart_DefaultMimeOctetStream(t *testing.T) {
|
|
_, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{}`))
|
|
})
|
|
if err := DoMultipart(context.Background(), http.MethodPost, "/x", "attachment", "x.bin", "", strings.NewReader("a"), nil); err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
_, ps, _ := mime.ParseMediaType(c.ctype)
|
|
mr := multipart.NewReader(strings.NewReader(string(c.body)), ps["boundary"])
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
t.Fatalf("next part: %v", err)
|
|
}
|
|
if got := part.Header.Get("Content-Type"); got != "application/octet-stream" {
|
|
t.Fatalf("expected default octet-stream, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestDoRaw_SuccessUnderCap(t *testing.T) {
|
|
srv, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.Header().Set("Content-Type", "application/pdf")
|
|
_, _ = w.Write([]byte("PDFBYTES"))
|
|
})
|
|
_ = srv
|
|
body, ct, err := DoRaw(context.Background(), "/attachments/uuid-1")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if string(body) != "PDFBYTES" {
|
|
t.Fatalf("body: %q", body)
|
|
}
|
|
if ct != "application/pdf" {
|
|
t.Fatalf("content-type: %s", ct)
|
|
}
|
|
// DoRaw must NOT prepend /api/v1.
|
|
if c.path != "/attachments/uuid-1" {
|
|
t.Fatalf("path: %s", c.path)
|
|
}
|
|
if c.auth != "token test-token" {
|
|
t.Fatalf("auth header missing on download: %s", c.auth)
|
|
}
|
|
}
|
|
|
|
func TestDoRaw_ExceedsCap(t *testing.T) {
|
|
bigPayload := make([]byte, MaxInlineDownloadBytes+1024)
|
|
for i := range bigPayload {
|
|
bigPayload[i] = 'a'
|
|
}
|
|
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(bigPayload)
|
|
})
|
|
_, _, err := DoRaw(context.Background(), "/attachments/big")
|
|
if !errors.Is(err, ErrPayloadTooLarge) {
|
|
t.Fatalf("expected ErrPayloadTooLarge, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDoRaw_AbsoluteURLPassThrough(t *testing.T) {
|
|
srv, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte("hi"))
|
|
})
|
|
body, _, err := DoRaw(context.Background(), srv.URL+"/attachments/abs-uuid")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if string(body) != "hi" {
|
|
t.Fatalf("body: %s", body)
|
|
}
|
|
if c.path != "/attachments/abs-uuid" {
|
|
t.Fatalf("path: %s", c.path)
|
|
}
|
|
}
|
|
|
|
func TestResolveURL_BaseRequired(t *testing.T) {
|
|
flag.URL = ""
|
|
defer func() { flag.URL = "http://x" }()
|
|
if _, err := resolveURL("/x"); err == nil {
|
|
t.Fatalf("expected error when flag.URL empty")
|
|
}
|
|
}
|