package httpclient import ( "context" "encoding/json" "errors" "go/ast" "go/parser" "go/token" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "code.nochebuena.dev/einherjar/contracts/logging" corelogz "code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/core/xerrors" ) // --- CT-6: at most one exported TypeSpec per non-test, non-doc file --- func TestAtMostOneExportedTypePerFile(t *testing.T) { fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { name := fi.Name() return !strings.HasSuffix(name, "_test.go") && name != "doc.go" }, 0) if err != nil { t.Fatalf("parse: %v", err) } for _, pkg := range pkgs { for path, file := range pkg.Files { base := filepath.Base(path) count := 0 for _, decl := range file.Decls { gd, ok := decl.(*ast.GenDecl) if !ok { continue } for _, spec := range gd.Specs { ts, ok := spec.(*ast.TypeSpec) if ok && ts.Name.IsExported() { count++ } } } if count > 1 { t.Errorf("%s: %d exported TypeSpecs (max 1)", base, count) } } } } // --- Config defaults (S-4) --- func TestDefaultConfig_OptionalFields(t *testing.T) { cfg := DefaultConfig() if cfg.Name == "" { t.Error("Name must have a default") } if cfg.Timeout == 0 { t.Error("Timeout must have a default") } if cfg.DialTimeout == 0 { t.Error("DialTimeout must have a default") } if cfg.MaxRetries == 0 { t.Error("MaxRetries must have a default") } if cfg.RetryDelay == 0 { t.Error("RetryDelay must have a default") } if cfg.CBThreshold == 0 { t.Error("CBThreshold must have a default") } if cfg.CBTimeout == 0 { t.Error("CBTimeout must have a default") } } // --- New / NewWithDefaults --- func TestNew_NotNil(t *testing.T) { if New(newLogger(), DefaultConfig()) == nil { t.Fatal("New returned nil") } } func TestNewWithDefaults_NotNil(t *testing.T) { if NewWithDefaults(newLogger()) == nil { t.Fatal("NewWithDefaults returned nil") } } // --- Provider.Do --- func TestProvider_Do_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer srv.Close() client := New(newLogger(), testConfig()) req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) resp, err := client.Do(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("want 200, got %d", resp.StatusCode) } } func TestProvider_Do_Retry5xx(t *testing.T) { calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() client := New(newLogger(), Config{ Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second, MaxRetries: 3, RetryDelay: 1 * time.Millisecond, CBThreshold: 100, CBTimeout: time.Minute, }) req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) _, err := client.Do(req) if err == nil { t.Fatal("expected error after retries") } if calls < 2 { t.Errorf("expected multiple calls, got %d", calls) } } func TestProvider_Do_InjectsRequestID(t *testing.T) { var gotHeader string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotHeader = r.Header.Get("X-Request-ID") w.WriteHeader(http.StatusOK) })) defer srv.Close() client := New(newLogger(), testConfig()) ctx := corelogz.WithRequestID(t.Context(), "req-123") req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) _, _ = client.Do(req) if gotHeader != "req-123" { t.Errorf("want X-Request-ID=req-123, got %q", gotHeader) } } func TestProvider_Do_NoRequestID(t *testing.T) { var gotHeader string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotHeader = r.Header.Get("X-Request-ID") w.WriteHeader(http.StatusOK) })) defer srv.Close() client := New(newLogger(), testConfig()) req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) _, _ = client.Do(req) if gotHeader != "" { t.Errorf("expected no X-Request-ID header, got %q", gotHeader) } } func TestProvider_Do_Retry429_WithRetryAfter(t *testing.T) { calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() client := New(newLogger(), Config{ Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second, MaxRetries: 3, RetryDelay: 1 * time.Millisecond, CBThreshold: 100, CBTimeout: time.Minute, }) req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) _, err := client.Do(req) if err == nil { t.Fatal("expected error after retries") } if calls < 2 { t.Errorf("expected retries on 429, got %d calls", calls) } } func TestProvider_Do_Retry429_NoRetryAfter(t *testing.T) { calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() client := New(newLogger(), Config{ Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second, MaxRetries: 3, RetryDelay: 1 * time.Millisecond, CBThreshold: 100, CBTimeout: time.Minute, }) req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) _, err := client.Do(req) if err == nil { t.Fatal("expected error after retries") } if calls < 2 { t.Errorf("expected retries on 429, got %d calls", calls) } } // --- DoJSONRequest --- func TestDoJSONRequest_Success(t *testing.T) { type payload struct{ Name string } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if ct := r.Header.Get("Content-Type"); ct != "application/json" { t.Errorf("want Content-Type application/json, got %q", ct) } var req payload _ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewEncoder(w).Encode(payload{Name: "echo:" + req.Name}) })) defer srv.Close() client := New(newLogger(), testConfig()) result, err := DoJSONRequest[payload, payload](t.Context(), client, http.MethodPost, srv.URL, payload{Name: "alice"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Name != "echo:alice" { t.Errorf("want echo:alice, got %s", result.Name) } } func TestDoJSONRequest_4xx(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer srv.Close() client := New(newLogger(), testConfig()) _, err := DoJSONRequest[struct{}, struct{}](t.Context(), client, http.MethodPost, srv.URL, struct{}{}) if err == nil { t.Fatal("expected error") } assertXCode(t, err, xerrors.ErrNotFound) } // --- DoJSON --- func TestDoJSON_Success(t *testing.T) { type payload struct{ Name string } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(payload{Name: "alice"}) })) defer srv.Close() client := New(newLogger(), testConfig()) req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) result, err := DoJSON[payload](t.Context(), client, req) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Name != "alice" { t.Errorf("want alice, got %s", result.Name) } } func TestDoJSON_4xx(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer srv.Close() client := New(newLogger(), testConfig()) req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) _, err := DoJSON[struct{}](t.Context(), client, req) if err == nil { t.Fatal("expected error") } assertXCode(t, err, xerrors.ErrNotFound) } // --- MapStatusToError --- func TestMapStatusToError_AllCodes(t *testing.T) { cases := []struct { status int code xerrors.Code }{ {http.StatusNotFound, xerrors.ErrNotFound}, {http.StatusBadRequest, xerrors.ErrInvalidInput}, {http.StatusUnauthorized, xerrors.ErrUnauthorized}, {http.StatusForbidden, xerrors.ErrPermissionDenied}, {http.StatusConflict, xerrors.ErrAlreadyExists}, {http.StatusTooManyRequests, xerrors.ErrUnavailable}, {http.StatusInternalServerError, xerrors.ErrInternal}, } for _, tc := range cases { err := MapStatusToError(tc.status, "msg") assertXCode(t, err, tc.code) } } // --- helpers --- func assertXCode(t *testing.T, err error, want xerrors.Code) { t.Helper() var xe *xerrors.Err if !errors.As(err, &xe) { t.Fatalf("expected *xerrors.Err, got %T: %v", err, err) } if xe.Code() != want { t.Errorf("want code %s, got %s", want, xe.Code()) } } type stubLogger struct{} func newLogger() *stubLogger { return &stubLogger{} } func (s *stubLogger) Debug(msg string, args ...any) {} func (s *stubLogger) Info(msg string, args ...any) {} func (s *stubLogger) Warn(msg string, args ...any) {} func (s *stubLogger) Error(msg string, err error, args ...any) {} func (s *stubLogger) With(args ...any) logging.Logger { return s } func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s } func testConfig() Config { return Config{ Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second, MaxRetries: 1, RetryDelay: time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute, } }