[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate... #3
4 changed files with 188 additions and 2 deletions
|
|
@ -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 <virgil@lethean.io>` in commits
|
||||
- `Client` uses functional options pattern (`WithHTTPClient`, `WithUserAgent`)
|
||||
|
|
|
|||
126
client_test.go
126
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue