//go:build test package square import ( "context" "errors" "net" "net/http" "net/http/httptest" "testing" "time" ) // TestHTTPClientTimeout_ClassifiedAmbiguous503 locks the N6 contract: the // production HTTP client's 30s timeout must fire against a stalled upstream and // produce a context-deadline error. chargeFailureStatus // (handlers/payments/errors.go) checks `errors.Is(err, context.DeadlineExceeded)` // FIRST and maps it to 503 (Service Unavailable / ambiguous) — the charge may // or may not have reached Square, so it must never be labelled the definitive // 402 decline a retry would ignore. func TestHTTPClientTimeout_ClassifiedAmbiguous503(t *testing.T) { // Upstream Square stalls LONGER than the production client timeout, so the // client must give up on its own — the handler never hangs. `stop` aborts // the handler at teardown so srv.Close() does not wait out the full delay. delay := defaultHTTPTimeout + 5*time.Second stop := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case <-time.After(delay): w.WriteHeader(http.StatusOK) case <-r.Context().Done(): return case <-stop: return } })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "test-token", http: &http.Client{Timeout: defaultHTTPTimeout}} start := time.Now() _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "timeout-slow-upstream", }, hc) elapsed := time.Since(start) close(stop) if err == nil { t.Fatal("expected a timeout error, got nil") } // The client must have given up near its 30s timeout — not instantly and // not after the 35s server delay. if elapsed < 25*time.Second || elapsed > 33*time.Second { t.Errorf("expected timeout after ~%v, got %v (err: %v)", defaultHTTPTimeout, elapsed, err) } // The context-deadline predicate chargeFailureStatus maps to 503. Go's // http.Client.Timeout wraps *timeoutError whose Is() matches // context.DeadlineExceeded. if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("expected errors.Is(err, context.DeadlineExceeded), got %v", err) } // Standard Go contract: a client timeout surfaces as a net.Error with // Timeout() == true. var netErr net.Error if !errors.As(err, &netErr) || !netErr.Timeout() { t.Errorf("expected a timeout net.Error, got %T: %v", err, err) } }