// client_test.go 用 httptest 伺服器驗證用戶端行為:分頁(limit=50)、 // 認證表頭、逾時與錯誤映射(連線失敗、401、5xx → *ErrAPI)。 package gitea import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) // newTestClient 對 srv 建立用戶端。 func newTestClient(srvURL, token string, timeout time.Duration) (*Client, error) { return New(srvURL, token, timeout) } func TestNewRejectsInvalidBaseURL(t *testing.T) { for _, bad := range []string{"", "not a url", "http://"} { if _, err := New(bad, "", 0); err == nil { t.Errorf("New(%q) should fail", bad) } } } func TestClientSendsTokenHeader(t *testing.T) { var gotAuth atomic.Value srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth.Store(r.Header.Get("Authorization")) fmt.Fprint(w, `{"login":"max"}`) })) defer srv.Close() c, err := newTestClient(srv.URL, "secret-token", time.Second) if err != nil { t.Fatal(err) } var out map[string]any if err := c.GetJSON(context.Background(), "/user", &out); err != nil { t.Fatal(err) } if got := gotAuth.Load(); got != "token secret-token" { t.Fatalf("Authorization header = %v, want token secret-token", got) } } func TestErrAPIMessageHasNoToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() c, err := newTestClient(srv.URL, "secret-token", time.Second) if err != nil { t.Fatal(err) } err = c.GetJSON(context.Background(), "/user", nil) var apiErr *ErrAPI if !errors.As(err, &apiErr) { t.Fatalf("want *ErrAPI, got %v", err) } if apiErr.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", apiErr.StatusCode) } if got := apiErr.Error(); got == "" || contains(got, "secret-token") { t.Fatalf("error message must not leak token, got %q", got) } } func TestListAllPaginatesWithLimitFifty(t *testing.T) { var calls int32 var limits []int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) limit := int32(0) fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit) limits = append(limits, limit) page := 1 fmt.Sscanf(r.URL.Query().Get("page"), "%d", &page) // 第 1、2 頁各滿 50 筆,第 3 頁 10 筆後結束。 total := 110 start := (page - 1) * 50 end := start + 50 if end > total { end = total } w.Header().Set("Content-Type", "application/json") if start >= total { fmt.Fprint(w, "[]") return } items := make([]int, 0, end-start) for i := start; i < end; i++ { items = append(items, i) } _ = json.NewEncoder(w).Encode(items) })) defer srv.Close() c, err := newTestClient(srv.URL, "", time.Second) if err != nil { t.Fatal(err) } var got []int if err := c.ListAll(context.Background(), "/things", ListOptions{}, &got); err != nil { t.Fatal(err) } if len(got) != 110 { t.Fatalf("want 110 items, got %d", len(got)) } for i, v := range got { if v != i { t.Fatalf("item %d = %d, want %d", i, v, i) } } if n := atomic.LoadInt32(&calls); n != 3 { t.Fatalf("want 3 page requests, got %d", n) } for i, l := range limits { if l != 50 { t.Fatalf("page %d limit = %d, want 50", i+1, l) } } } func TestListAllRejectsBadOut(t *testing.T) { c, err := newTestClient("https://gitea.alterminal.com", "", time.Second) if err != nil { t.Fatal(err) } var notSlice int if err := c.ListAll(context.Background(), "/x", ListOptions{}, ¬Slice); err == nil { t.Fatal("non-slice out should error") } preloaded := []int{1} if err := c.ListAll(context.Background(), "/x", ListOptions{}, &preloaded); err == nil { t.Fatal("non-empty out should error") } } func TestClientTimeoutSurfacesAsErrAPI(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(200 * time.Millisecond) fmt.Fprint(w, `{}`) })) defer srv.Close() c, err := newTestClient(srv.URL, "", 50*time.Millisecond) if err != nil { t.Fatal(err) } err = c.GetJSON(context.Background(), "/user", nil) var apiErr *ErrAPI if !errors.As(err, &apiErr) { t.Fatalf("timeout should surface as *ErrAPI, got %v", err) } if apiErr.StatusCode != 0 { t.Fatalf(" StatusCode should be 0 for transport error, got %d", apiErr.StatusCode) } } func TestClientServerFiveHundred(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() c, err := newTestClient(srv.URL, "", time.Second) if err != nil { t.Fatal(err) } err = c.GetJSON(context.Background(), "/user", nil) var apiErr *ErrAPI if !errors.As(err, &apiErr) || apiErr.StatusCode != 500 { t.Fatalf("want *ErrAPI with 500, got %v", err) } } // contains 是簡單子字串檢查(避免只為此引入 strings 到測試以外的地方)。 func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) } func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 }