diff --git a/CLAUDE.md b/CLAUDE.md index 882481a..b3f56a2 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, depends on `go-io` and `go-log`) ## Build & Test @@ -51,6 +51,8 @@ The library is a flat package (`package forge`) with a layered design: - All methods accept `context.Context` as first parameter - Errors wrapped as `*APIError` with StatusCode, Message, URL; use `IsNotFound()`, `IsForbidden()`, `IsConflict()` helpers +- Internal errors use `coreerr.E()` from `go-log` (imported as `coreerr "forge.lthn.ai/core/go-log"`), never `fmt.Errorf` or `errors.New` +- File I/O uses `go-io` (imported as `coreio "forge.lthn.ai/core/go-io"`), never `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..1c67351 100644 --- a/client_test.go +++ b/client_test.go @@ -136,3 +136,96 @@ func TestClient_Good_Options(t *testing.T) { t.Errorf("got user agent=%q", c.userAgent) } } + +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("expected custom HTTP client to be set") + } +} + +func TestAPIError_Good_Error(t *testing.T) { + e := &APIError{StatusCode: 404, Message: "not found", URL: "/api/v1/repos/x/y"} + got := e.Error() + want := "forge: /api/v1/repos/x/y 404: not found" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestIsConflict_Good(t *testing.T) { + err := &APIError{StatusCode: http.StatusConflict, Message: "conflict", URL: "/test"} + if !IsConflict(err) { + t.Error("expected IsConflict to return true for 409") + } +} + +func TestIsConflict_Bad_NotConflict(t *testing.T) { + err := &APIError{StatusCode: http.StatusNotFound, Message: "not found", URL: "/test"} + if IsConflict(err) { + t.Error("expected IsConflict to return false for 404") + } +} + +func TestIsForbidden_Bad_NotForbidden(t *testing.T) { + err := &APIError{StatusCode: http.StatusNotFound, Message: "not found", URL: "/test"} + if IsForbidden(err) { + t.Error("expected IsForbidden to return false for 404") + } +} + +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_Forbidden(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_Conflict(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", map[string]string{"name": "dup"}, nil) + if !IsConflict(err) { + t.Fatalf("expected conflict, got %v", err) + } +} diff --git a/forge_test.go b/forge_test.go index 1d3304e..6ea3631 100644 --- a/forge_test.go +++ b/forge_test.go @@ -20,6 +20,17 @@ func TestForge_Good_NewForge(t *testing.T) { } } +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) + } +} + func TestRepoService_Good_List(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Total-Count", "1") diff --git a/resource_test.go b/resource_test.go index 3f37dd7..0b00b81 100644 --- a/resource_test.go +++ b/resource_test.go @@ -153,3 +153,81 @@ 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 collected []testItem + for item, err := range res.Iter(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + collected = append(collected, item) + } + if len(collected) != 3 { + t.Errorf("got %d items, want 3", len(collected)) + } + if collected[2].Name != "c" { + t.Errorf("got last item name=%q, want \"c\"", collected[2].Name) + } +} + +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": "server error"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos") + + var gotErr error + for _, err := range res.Iter(context.Background(), nil) { + if err != nil { + gotErr = err + break + } + } + if gotErr == nil { + t.Fatal("expected error from Iter on server error") + } +} + +func TestResource_Good_IterBreakEarly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Total-Count", "100") + json.NewEncoder(w).Encode([]testItem{{1, "a"}, {2, "b"}, {3, "c"}}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok") + res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos") + + count := 0 + for _, err := range res.Iter(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + count++ + if count == 1 { + break + } + } + if count != 1 { + t.Errorf("expected to break after 1 item, got %d", count) + } +}