package httputil_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/libnovel/backend/internal/httputil" ) // ── RetryGet ────────────────────────────────────────────────────────────────── func TestRetryGet_ImmediateSuccess(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello")) })) defer srv.Close() body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond) if err != nil { t.Fatalf("unexpected error: %v", err) } if body != "hello" { t.Errorf("want hello, got %q", body) } } func TestRetryGet_RetriesOn5xx(t *testing.T) { calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if calls < 3 { w.WriteHeader(http.StatusServiceUnavailable) return } w.Write([]byte("ok")) })) defer srv.Close() body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond) if err != nil { t.Fatalf("unexpected error: %v", err) } if body != "ok" { t.Errorf("want ok, got %q", body) } if calls != 3 { t.Errorf("want 3 calls, got %d", calls) } } func TestRetryGet_MaxAttemptsExceeded(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() _, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond) if err == nil { t.Fatal("expected error, got nil") } } func TestRetryGet_ContextCancelDuringBackoff(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) defer srv.Close() ctx, cancel := context.WithCancel(context.Background()) // Cancel after first failed attempt hits the backoff wait. go func() { time.Sleep(5 * time.Millisecond); cancel() }() _, err := httputil.RetryGet(ctx, srv.Client(), srv.URL, 10, 500*time.Millisecond) if err == nil { t.Fatal("expected context cancellation error") } } func TestRetryGet_NoRetryOn4xx(t *testing.T) { calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.WriteHeader(http.StatusNotFound) })) defer srv.Close() _, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond) if err == nil { t.Fatal("expected error for 404") } // 4xx is NOT retried — should be exactly 1 call. if calls != 1 { t.Errorf("want 1 call for 4xx, got %d", calls) } } // ── WriteJSON ───────────────────────────────────────────────────────────────── func TestWriteJSON_SetsHeadersAndStatus(t *testing.T) { rr := httptest.NewRecorder() httputil.WriteJSON(rr, http.StatusCreated, map[string]string{"key": "val"}) if rr.Code != http.StatusCreated { t.Errorf("status: want 201, got %d", rr.Code) } if ct := rr.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type: want application/json, got %q", ct) } var got map[string]string if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { t.Fatalf("decode body: %v", err) } if got["key"] != "val" { t.Errorf("body key: want val, got %q", got["key"]) } } // ── WriteError ──────────────────────────────────────────────────────────────── func TestWriteError_Format(t *testing.T) { rr := httptest.NewRecorder() httputil.WriteError(rr, http.StatusBadRequest, "bad input") if rr.Code != http.StatusBadRequest { t.Errorf("status: want 400, got %d", rr.Code) } var got map[string]string json.NewDecoder(rr.Body).Decode(&got) if got["error"] != "bad input" { t.Errorf("error field: want bad input, got %q", got["error"]) } } // ── DecodeJSON ──────────────────────────────────────────────────────────────── func TestDecodeJSON_HappyPath(t *testing.T) { body := `{"name":"test","value":42}` req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") var payload struct { Name string `json:"name"` Value int `json:"value"` } if err := httputil.DecodeJSON(req, &payload); err != nil { t.Fatalf("unexpected error: %v", err) } if payload.Name != "test" || payload.Value != 42 { t.Errorf("unexpected payload: %+v", payload) } } func TestDecodeJSON_UnknownFieldReturnsError(t *testing.T) { body := `{"name":"test","unknown_field":"boom"}` req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) var payload struct { Name string `json:"name"` } if err := httputil.DecodeJSON(req, &payload); err == nil { t.Fatal("expected error for unknown field, got nil") } } func TestDecodeJSON_BodyTooLarge(t *testing.T) { // Build a body > 1 MiB. big := bytes.Repeat([]byte("a"), 2<<20) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(big)) var payload map[string]any if err := httputil.DecodeJSON(req, &payload); err == nil { t.Fatal("expected error for oversized body, got nil") } }