diff --git a/CLAUDE.md b/CLAUDE.md index 882481a..0d39f1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Full-coverage Go client for the Forgejo API (~450 endpoints). Uses a generic `Resource[T,C,U]` pattern for CRUD operations and types generated from `swagger.v1.json`. -**Module:** `forge.lthn.ai/core/go-forge` (Go 1.26, zero dependencies) +**Module:** `forge.lthn.ai/core/go-forge` (Go 1.26, deps: `go-io`, `go-log`) ## Build & Test @@ -50,7 +50,9 @@ The library is a flat package (`package forge`) with a layered design: ## Coding Standards - All methods accept `context.Context` as first parameter -- Errors wrapped as `*APIError` with StatusCode, Message, URL; use `IsNotFound()`, `IsForbidden()`, `IsConflict()` helpers +- **HTTP errors** wrapped as `*APIError` with StatusCode, Message, URL; use `IsNotFound()`, `IsForbidden()`, `IsConflict()` helpers +- **Internal errors** must use `coreerr.E()` from `go-log` (aliased as `coreerr`), never `fmt.Errorf` or `errors.New` +- **File I/O** must use `go-io` (aliased as `coreio`), not `os.ReadFile`/`os.WriteFile`/`os.MkdirAll` - UK English in comments (organisation, colour, etc.) - `Co-Authored-By: Virgil ` in commits - `Client` uses functional options pattern (`WithHTTPClient`, `WithUserAgent`) diff --git a/client_test.go b/client_test.go index e0d581b..9064fcc 100644 --- a/client_test.go +++ b/client_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -136,3 +137,128 @@ func TestClient_Good_Options(t *testing.T) { t.Errorf("got user agent=%q", c.userAgent) } } + +func TestAPIError_Good_Error(t *testing.T) { + err := &APIError{StatusCode: 404, Message: "not found", URL: "/api/v1/repos/x/y"} + got := err.Error() + if !strings.Contains(got, "404") || !strings.Contains(got, "not found") || !strings.Contains(got, "/api/v1/repos/x/y") { + t.Errorf("Error() = %q, want status code, message, and URL", got) + } +} + +func TestClient_Good_IsConflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"message": "already exists"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + err := c.Post(context.Background(), "/api/v1/repos", nil, nil) + if !IsConflict(err) { + t.Fatalf("expected conflict, got %v", err) + } +} + +func TestClient_Good_WithHTTPClient(t *testing.T) { + custom := &http.Client{} + c := NewClient("https://forge.lthn.ai", "tok", WithHTTPClient(custom)) + if c.httpClient != custom { + t.Error("WithHTTPClient did not set custom client") + } +} + +func TestClient_Good_RateLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Limit", "100") + w.Header().Set("X-RateLimit-Remaining", "99") + w.Header().Set("X-RateLimit-Reset", "1700000000") + json.NewEncoder(w).Encode(map[string]string{"login": "test"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + var out map[string]string + if err := c.Get(context.Background(), "/api/v1/user", &out); err != nil { + t.Fatal(err) + } + + rl := c.RateLimit() + if rl.Limit != 100 { + t.Errorf("got Limit=%d, want 100", rl.Limit) + } + if rl.Remaining != 99 { + t.Errorf("got Remaining=%d, want 99", rl.Remaining) + } + if rl.Reset != 1700000000 { + t.Errorf("got Reset=%d, want 1700000000", rl.Reset) + } +} + +func TestClient_Bad_IsForbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"message": "forbidden"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + err := c.Get(context.Background(), "/api/v1/admin", nil) + if !IsForbidden(err) { + t.Fatalf("expected forbidden, got %v", err) + } +} + +func TestClient_Bad_ParseErrorPlainText(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte("bad gateway")) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + err := c.Get(context.Background(), "/api/v1/user", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 502 { + t.Errorf("got status=%d, want 502", apiErr.StatusCode) + } + if apiErr.Message != "bad gateway" { + t.Errorf("got message=%q, want %q", apiErr.Message, "bad gateway") + } +} + +func TestClient_Bad_ParseErrorEmptyBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + err := c.Get(context.Background(), "/api/v1/user", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 503 { + t.Errorf("got status=%d, want 503", apiErr.StatusCode) + } + // Empty body falls back to http.StatusText + if apiErr.Message != "Service Unavailable" { + t.Errorf("got message=%q, want %q", apiErr.Message, "Service Unavailable") + } +} + +func TestClient_Bad_IsNotFoundNonAPIError(t *testing.T) { + if IsNotFound(errors.New("random error")) { + t.Error("IsNotFound should return false for non-APIError") + } + if IsForbidden(errors.New("random error")) { + t.Error("IsForbidden should return false for non-APIError") + } + if IsConflict(errors.New("random error")) { + t.Error("IsConflict should return false for non-APIError") + } +} diff --git a/forge_test.go b/forge_test.go index 1d3304e..60aec7f 100644 --- a/forge_test.go +++ b/forge_test.go @@ -72,3 +72,14 @@ func TestRepoService_Good_Fork(t *testing.T) { t.Error("expected fork=true") } } + +func TestForge_Good_Client(t *testing.T) { + f := NewForge("https://forge.lthn.ai", "tok") + c := f.Client() + if c == nil { + t.Fatal("Client() returned nil") + } + if c.baseURL != "https://forge.lthn.ai" { + t.Errorf("got baseURL=%q", c.baseURL) + } +} diff --git a/resource_test.go b/resource_test.go index 3f37dd7..44e58d9 100644 --- a/resource_test.go +++ b/resource_test.go @@ -153,3 +153,50 @@ func TestResource_Good_ListAll(t *testing.T) { t.Errorf("got %d items, want 3", len(items)) } } + +func TestResource_Good_Iter(t *testing.T) { + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page++ + w.Header().Set("X-Total-Count", "3") + if page == 1 { + json.NewEncoder(w).Encode([]testItem{{1, "a"}, {2, "b"}}) + } else { + json.NewEncoder(w).Encode([]testItem{{3, "c"}}) + } + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos") + + var items []testItem + for item, err := range res.Iter(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + items = append(items, item) + } + if len(items) != 3 { + t.Errorf("got %d items, want 3", len(items)) + } +} + +func TestResource_Bad_IterError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"message": "fail"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos") + + for _, err := range res.Iter(context.Background(), nil) { + if err == nil { + t.Fatal("expected error from iterator") + } + return // got expected error + } + t.Fatal("iterator yielded nothing") +}