package coverart import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) func TestHTTPClient_GetJSON_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"name":"alice"}`)) })) defer srv.Close() c := newHTTPClient(httpClientOptions{Name: "test"}) var got struct{ Name string } if err := c.getJSON(context.Background(), srv.URL, &got); err != nil { t.Fatalf("unexpected err: %v", err) } if got.Name != "alice" { t.Errorf("Name = %q, want alice", got.Name) } } func TestHTTPClient_GetJSON_404IsNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer srv.Close() c := newHTTPClient(httpClientOptions{Name: "test"}) var out any err := c.getJSON(context.Background(), srv.URL, &out) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } } func TestHTTPClient_GetJSON_RetryOn429ThenSuccess(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { n := calls.Add(1) if n < 3 { w.WriteHeader(http.StatusTooManyRequests) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() c := newHTTPClient(httpClientOptions{ Name: "test", MaxRetries: 3, BaseBackoff: 1 * time.Millisecond, // tiny so the test is fast }) var got struct{ OK bool } if err := c.getJSON(context.Background(), srv.URL, &got); err != nil { t.Fatalf("unexpected err: %v", err) } if calls.Load() != 3 { t.Errorf("calls = %d, want 3", calls.Load()) } if !got.OK { t.Errorf("OK = false, want true") } } func TestHTTPClient_GetJSON_RetryOn5xxThenExhausted(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) defer srv.Close() c := newHTTPClient(httpClientOptions{ Name: "test", MaxRetries: 2, BaseBackoff: 1 * time.Millisecond, }) var out any err := c.getJSON(context.Background(), srv.URL, &out) if !errors.Is(err, ErrTransient) { t.Errorf("err = %v, want ErrTransient", err) } } func TestHTTPClient_GetJSON_AuthAsTransient(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() c := newHTTPClient(httpClientOptions{ Name: "test", TreatAuthAsTransient: true, }) var out any err := c.getJSON(context.Background(), srv.URL, &out) if !errors.Is(err, ErrTransient) { t.Errorf("err = %v, want ErrTransient", err) } } func TestHTTPClient_GetJSON_AuthPassthroughByDefault(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() c := newHTTPClient(httpClientOptions{Name: "test"}) var out any err := c.getJSON(context.Background(), srv.URL, &out) if !errors.Is(err, ErrTransient) { // 401 is non-2xx so it falls into the generic transient bucket. t.Errorf("err = %v, want ErrTransient (generic non-2xx)", err) } } func TestHTTPClient_GetJSON_MalformedBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) })) defer srv.Close() c := newHTTPClient(httpClientOptions{Name: "test"}) var got struct{ Name string } err := c.getJSON(context.Background(), srv.URL, &got) if !errors.Is(err, ErrTransient) { t.Errorf("err = %v, want ErrTransient", err) } } func TestHTTPClient_GetJSON_ContextCancel(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) defer srv.Close() c := newHTTPClient(httpClientOptions{ Name: "test", MaxRetries: 5, BaseBackoff: 200 * time.Millisecond, }) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() var out any err := c.getJSON(ctx, srv.URL, &out) if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("err = %v, want context.DeadlineExceeded", err) } } func TestHTTPClient_GetImage_Success(t *testing.T) { want := []byte("\x89PNG\x0d\x0a\x1a\x0a-fake-png-bytes") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/png") _, _ = w.Write(want) })) defer srv.Close() c := newHTTPClient(httpClientOptions{Name: "test"}) got, err := c.getImage(context.Background(), srv.URL) if err != nil { t.Fatalf("unexpected err: %v", err) } if string(got) != string(want) { t.Errorf("body mismatch") } } func TestHTTPClient_GetImage_404IsNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer srv.Close() c := newHTTPClient(httpClientOptions{Name: "test"}) _, err := c.getImage(context.Background(), srv.URL) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } } func TestHTTPClient_RateLimit(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(struct{ OK bool }{true}) })) defer srv.Close() c := newHTTPClient(httpClientOptions{ Name: "test", MinInterval: 50 * time.Millisecond, }) var out struct{ OK bool } t0 := time.Now() if err := c.getJSON(context.Background(), srv.URL, &out); err != nil { t.Fatalf("first call: %v", err) } if err := c.getJSON(context.Background(), srv.URL, &out); err != nil { t.Fatalf("second call: %v", err) } elapsed := time.Since(t0) if elapsed < 50*time.Millisecond { t.Errorf("elapsed %v, want >= 50ms (rate limit)", elapsed) } }