package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestNewClientDefaults(t *testing.T) {
c := NewClient()
if c.baseURL != defaultBaseURL {
t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL)
}
if c.userAgent != defaultUserAgent {
t.Errorf("userAgent = %q, want %q", c.userAgent, defaultUserAgent)
}
if c.apiKey != "" {
t.Errorf("apiKey = %q, want empty", c.apiKey)
}
if c.maxRetries != defaultMaxRetries {
t.Errorf("maxRetries = %d, want %d", c.maxRetries, defaultMaxRetries)
}
}
func TestNewClientOptions(t *testing.T) {
hc := &http.Client{Timeout: 5 * time.Second}
c := NewClient(
WithBaseURL("https://custom.example.com"),
WithAPIKey("test-key-123"),
WithUserAgent("my-app/1.0"),
WithHTTPClient(hc),
WithRetry(5, 2*time.Second, 60*time.Second),
)
if c.baseURL != "https://custom.example.com" {
t.Errorf("baseURL = %q", c.baseURL)
}
if c.apiKey != "test-key-123" {
t.Errorf("apiKey = %q", c.apiKey)
}
if c.userAgent != "my-app/1.0" {
t.Errorf("userAgent = %q", c.userAgent)
}
if c.httpClient != hc {
t.Error("httpClient not set")
}
if c.maxRetries != 5 {
t.Errorf("maxRetries = %d, want 5", c.maxRetries)
}
}
func TestWithTimeout(t *testing.T) {
c := NewClient(WithTimeout(30 * time.Second))
if c.httpClient.Timeout != 30*time.Second {
t.Errorf("timeout = %v, want 30s", c.httpClient.Timeout)
}
}
func TestSetHeaders(t *testing.T) {
tests := []struct {
name string
apiKey string
want map[string]string
}{
{
name: "without API key",
apiKey: "",
want: map[string]string{
headerUserAgent: defaultUserAgent,
headerSearchSource: searchSource,
},
},
{
name: "with API key",
apiKey: "my-key",
want: map[string]string{
headerUserAgent: defaultUserAgent,
headerSearchSource: searchSource,
headerAPIKey: "my-key",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewClient(WithAPIKey(tt.apiKey))
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
c.setHeaders(req)
for key, want := range tt.want {
got := req.Header.Get(key)
if got != want {
t.Errorf("header %q = %q, want %q", key, got, want)
}
}
if tt.apiKey == "" && req.Header.Get(headerAPIKey) != "" {
t.Error("API key header should not be set when apiKey is empty")
}
})
}
}
func TestDoJSON_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get(headerUserAgent); got != defaultUserAgent {
t.Errorf("User-Agent = %q, want %q", got, defaultUserAgent)
}
if got := r.Header.Get(headerSearchSource); got != searchSource {
t.Errorf("X-Search-Source = %q, want %q", got, searchSource)
}
if got := r.Header.Get("Accept"); got != "application/json" {
t.Errorf("Accept = %q, want application/json", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"total": 42})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL))
var dst struct{ Total int }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dst.Total != 42 {
t.Errorf("Total = %d, want 42", dst.Total)
}
}
func TestDoJSON_APIError(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
wantBody bool
}{
{name: "400 bad request", statusCode: 400, body: "invalid query", wantBody: true},
{name: "404 not found", statusCode: 404, body: "not found", wantBody: true},
{name: "500 server error", statusCode: 500, body: "internal details", wantBody: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.body))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != tt.statusCode {
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.statusCode)
}
if tt.wantBody && apiErr.Body == "" {
t.Error("expected Body to be non-empty for 4xx")
}
if !tt.wantBody && apiErr.Body != "" {
t.Errorf("expected empty Body for 5xx, got %q", apiErr.Body)
}
})
}
}
func TestDoJSON_RetryOn429(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 3 {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{ Status string }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dst.Status != "ok" {
t.Errorf("Status = %q, want ok", dst.Status)
}
if attempts != 3 {
t.Errorf("attempts = %d, want 3", attempts)
}
}
func TestDoJSON_RetryExhausted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error after retries exhausted")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 429 {
t.Errorf("StatusCode = %d, want 429", apiErr.StatusCode)
}
}
func TestDoJSON_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
ctx, cancel := context.WithCancel(context.Background())
cancel()
var dst struct{}
err := c.doJSON(ctx, "/test", nil, &dst)
if err == nil {
t.Fatal("expected error for canceled context")
}
}
func TestBackoffDuration(t *testing.T) {
c := NewClient(WithRetry(5, 1*time.Second, 30*time.Second))
tests := []struct {
attempt int
want time.Duration
}{
{0, 1 * time.Second},
{1, 2 * time.Second},
{2, 4 * time.Second},
{3, 8 * time.Second},
{4, 16 * time.Second},
{5, 30 * time.Second}, // capped
}
for _, tt := range tests {
got := c.backoffDuration(tt.attempt)
if got != tt.want {
t.Errorf("backoffDuration(%d) = %v, want %v", tt.attempt, got, tt.want)
}
}
}
func TestAPIKeyHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get(headerAPIKey)
if got != "secret-key" {
t.Errorf("X-API-Key = %q, want %q", got, "secret-key")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithAPIKey("secret-key"))
var dst struct{ OK bool }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDoJSON_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("not json"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "decode") {
t.Errorf("error = %q, want to contain 'decode'", err.Error())
}
}
func TestDoJSON_ContextCanceledDuringBackoff(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(5, 1*time.Second, 10*time.Second), // long backoff
)
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
var dst struct{}
err := c.doJSON(ctx, "/test", nil, &dst)
if err == nil {
t.Fatal("expected error")
}
if err != context.Canceled {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDoJSON_RetryOn502(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{ Ok string }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoJSON_RetryOn503(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{ Ok string }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoJSON_NoRetryOn401(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("unauthorized"))
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error")
}
if attempts != 1 {
t.Errorf("attempts = %d, want 1 (no retries for 401)", attempts)
}
}
func TestDoJSON_WithQueryParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("foo"); got != "bar" {
t.Errorf("foo = %q, want bar", got)
}
if got := r.URL.Query().Get("num"); got != "42" {
t.Errorf("num = %q, want 42", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
q := url.Values{}
q.Set("foo", "bar")
q.Set("num", "42")
var dst struct{ Ok bool }
err := c.doJSON(context.Background(), "/test", q, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDoJSON_InvalidBaseURL(t *testing.T) {
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error for invalid base URL")
}
if !strings.Contains(err.Error(), "invalid base URL") {
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
}
}
func TestDoRaw_InvalidBaseURL(t *testing.T) {
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
_, err := c.doRaw(context.Background(), "/test", nil)
if err == nil {
t.Fatal("expected error for invalid base URL")
}
if !strings.Contains(err.Error(), "invalid base URL") {
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
}
}
func TestAddIntParam(t *testing.T) {
q := url.Values{}
addIntParam(q, "zero", 0)
if q.Has("zero") {
t.Error("zero value should not be added")
}
addIntParam(q, "val", 42)
if q.Get("val") != "42" {
t.Errorf("val = %q, want 42", q.Get("val"))
}
}
func TestAddFloatParam(t *testing.T) {
q := url.Values{}
addFloatParam(q, "zero", 0)
if q.Has("zero") {
t.Error("zero value should not be added")
}
addFloatParam(q, "rating", 7.5)
if q.Get("rating") != "7.5" {
t.Errorf("rating = %q, want 7.5", q.Get("rating"))
}
}
func TestAddStringParam(t *testing.T) {
q := url.Values{}
addStringParam(q, "empty", "")
if q.Has("empty") {
t.Error("empty value should not be added")
}
addStringParam(q, "key", "value")
if q.Get("key") != "value" {
t.Errorf("key = %q, want value", q.Get("key"))
}
}
func TestVersion(t *testing.T) {
if Version == "" {
t.Error("Version should not be empty")
}
}
func TestDoRaw_ContextCanceledDuringBackoff(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(5, 1*time.Second, 10*time.Second),
)
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := c.doRaw(ctx, "/test", nil)
if err == nil {
t.Fatal("expected error")
}
if err != context.Canceled {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestWithTimeout_NilHTTPClient(t *testing.T) {
// Simulate the edge case where httpClient is nil when WithTimeout is applied.
c := &Client{}
opt := WithTimeout(10 * time.Second)
opt(c)
if c.httpClient == nil {
t.Fatal("httpClient should be initialized")
}
if c.httpClient.Timeout != 10*time.Second {
t.Errorf("timeout = %v, want 10s", c.httpClient.Timeout)
}
}
func TestWithBearerToken(t *testing.T) {
c := NewClient(WithBearerToken("my-token"))
if c.bearerToken != "my-token" {
t.Errorf("bearerToken = %q, want my-token", c.bearerToken)
}
}
func TestSetHeaders_BearerToken(t *testing.T) {
c := NewClient(WithBearerToken("tok123"))
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
c.setHeaders(req)
if got := req.Header.Get(headerAuthorization); got != "Bearer tok123" {
t.Errorf("Authorization = %q, want %q", got, "Bearer tok123")
}
if got := req.Header.Get(headerAPIKey); got != "" {
t.Errorf("X-API-Key should be empty when bearer token is set, got %q", got)
}
}
func TestSetHeaders_BearerTokenPrecedence(t *testing.T) {
c := NewClient(WithAPIKey("api-key"), WithBearerToken("bearer-tok"))
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
c.setHeaders(req)
if got := req.Header.Get(headerAuthorization); got != "Bearer bearer-tok" {
t.Errorf("Authorization = %q, want Bearer bearer-tok", got)
}
if got := req.Header.Get(headerAPIKey); got != "" {
t.Errorf("X-API-Key should be empty when bearer token takes precedence, got %q", got)
}
}
func TestAddBoolParam(t *testing.T) {
q := url.Values{}
addBoolParam(q, "disabled", false)
if q.Has("disabled") {
t.Error("false value should not be added")
}
addBoolParam(q, "enabled", true)
if q.Get("enabled") != "true" {
t.Errorf("enabled = %q, want true", q.Get("enabled"))
}
}
func TestDoPost_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
if got := r.Header.Get("Accept"); got != "application/json" {
t.Errorf("Accept = %q, want application/json", got)
}
if got := r.Header.Get("X-Custom"); got != "val" {
t.Errorf("X-Custom = %q, want val", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"result":"ok"}`))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{ Result string }
err := c.doPost(context.Background(), "/test", map[string]string{"key": "value"}, &dst, map[string]string{"X-Custom": "val"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dst.Result != "ok" {
t.Errorf("Result = %q, want ok", dst.Result)
}
}
func TestDoPost_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("bad request"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{}
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 400 {
t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode)
}
}
func TestDoPost_RetryOnTransient(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(2, 1*time.Millisecond, 10*time.Millisecond))
var dst struct{ Ok bool }
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoPost_InvalidBaseURL(t *testing.T) {
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
var dst struct{}
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
if err == nil {
t.Fatal("expected error for invalid base URL")
}
if !strings.Contains(err.Error(), "invalid base URL") {
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
}
}
func TestDoRaw_WithQueryParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("t"); got != "caps" {
t.Errorf("t = %q, want caps", got)
}
w.Write([]byte(""))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
q := url.Values{}
q.Set("t", "caps")
data, err := c.doRaw(context.Background(), "/test", q)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "" {
t.Errorf("data = %q, want ", string(data))
}
}