//go:build test package square import ( "bytes" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "strings" "testing" "time" "unicode/utf8" "github.com/stretchr/testify/require" ) // TestReplayPaymentByKeyHTTP_IdenticalBody verifies the replay-by-key sends the // FULL ORIGINAL request body (identical-body replay): a snapshot carrying // customer_id, reference_id, note and buyer_email_address — the fields the // original charge sends that a key+source+amount reconstruction would DROP — // must reach Square verbatim. Square's idempotency dedup compares the whole // request, so a partial replay body returns IDEMPOTENCY_KEY_REUSED for a // retained key and the row stays pending forever. func TestReplayPaymentByKeyHTTP_IdenticalBody(t *testing.T) { var capturedRaw []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/payments" { t.Errorf("expected /v2/payments, got %s", r.URL.Path) } var err error capturedRaw, err = io.ReadAll(r.Body) if err != nil { t.Errorf("failed to read request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"payment":{"id":"pay_orig","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:original-source", IdempotencyKey: "ik-replay", ReferenceID: "booking-123", Note: "deposit", CustomerID: "cus_123", VerificationToken: "verify-token-abc", BuyerEmail: "buyer@example.com", } snapshot, err := json.Marshal(req) if err != nil { t.Fatalf("failed to build snapshot: %v", err) } res, err := replayPaymentByKeyHTTPWithClient(context.Background(), snapshot, hc) if err != nil { t.Fatalf("replayPaymentByKeyHTTP failed: %v", err) } // The wire body must be BYTE-IDENTICAL to the original charge's body // (both go through buildCreatePaymentBody from the same CreatePaymentReq). expectedWire, err := json.Marshal(buildCreatePaymentBody(req, hc)) if err != nil { t.Fatalf("failed to marshal expected wire body: %v", err) } if !bytes.Equal(capturedRaw, expectedWire) { t.Errorf("replay body is not byte-identical to the original charge body:\n got %s\n want %s", capturedRaw, expectedWire) } var captured map[string]any if err := json.Unmarshal(capturedRaw, &captured); err != nil { t.Fatalf("failed to decode request body: %v", err) } if captured["source_id"] != "cnon:original-source" { t.Errorf("expected the ORIGINAL source_id in the replay body, got %v", captured["source_id"]) } if captured["idempotency_key"] != "ik-replay" { t.Errorf("expected idempotency_key ik-replay, got %v", captured["idempotency_key"]) } // The extra fields the original charge sent must survive the replay — // dropping them would make Square return IDEMPOTENCY_KEY_REUSED. if captured["customer_id"] != "cus_123" { t.Errorf("expected customer_id cus_123 in the replay body, got %v", captured["customer_id"]) } if captured["reference_id"] != "booking-123" { t.Errorf("expected reference_id booking-123 in the replay body, got %v", captured["reference_id"]) } if captured["note"] != "deposit" { t.Errorf("expected note deposit in the replay body, got %v", captured["note"]) } if captured["buyer_email_address"] != "buyer@example.com" { t.Errorf("expected buyer_email_address buyer@example.com in the replay body, got %v", captured["buyer_email_address"]) } if captured["verification_token"] != "verify-token-abc" { t.Errorf("expected verification_token verify-token-abc in the replay body, got %v", captured["verification_token"]) } amt, ok := captured["amount_money"].(map[string]any) if !ok || amt["amount"] != float64(5000) || amt["currency"] != "GBP" { t.Errorf("expected amount_money {5000 GBP} (identical to the original charge), got %v", captured["amount_money"]) } if res.ID != "pay_orig" { t.Errorf("expected the original payment returned, got %+v", res) } } // TestReplayErrorProvesNoCharge_Classification locks the identical-body replay // error classification: a definitive 4xx (minus 401/403/429) proves the charge // never happened; IDEMPOTENCY_KEY_REUSED is AMBIGUOUS (a data bug, never proof // of no charge); 401/403/429/5xx/transport are ambiguous. func TestReplayErrorProvesNoCharge_Classification(t *testing.T) { badRequest := func(code string) error { return &squareAPIError{Code: code, StatusCode: http.StatusBadRequest, err: errors.New("square: boom")} } cases := []struct { name string err error want bool }{ {name: "card_declined_4xx_proves_no_charge", err: badRequest("CARD_DECLINED"), want: true}, {name: "invalid_request_4xx_proves_no_charge", err: badRequest("INVALID_REQUEST_ERROR"), want: true}, {name: "plain_400_proves_no_charge", err: &squareAPIError{StatusCode: http.StatusBadRequest, err: errors.New("square: HTTP 400")}, want: true}, {name: "idempotency_key_reused_is_ambiguous", err: badRequest("IDEMPOTENCY_KEY_REUSED"), want: false}, {name: "unauthorized_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusUnauthorized, err: errors.New("square: 401")}, want: false}, {name: "forbidden_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusForbidden, err: errors.New("square: 403")}, want: false}, {name: "rate_limited_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusTooManyRequests, err: errors.New("square: 429")}, want: false}, {name: "server_error_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusInternalServerError, err: errors.New("square: 500")}, want: false}, {name: "transport_error_is_ambiguous", err: errors.New("network error: connection reset"), want: false}, {name: "nil_is_ambiguous", err: nil, want: false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := replayErrorProvesNoCharge(tc.err); got != tc.want { t.Errorf("replayErrorProvesNoCharge(%v) = %v, want %v", tc.err, got, tc.want) } }) } } func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) { p := &sqPayment{ ID: "pay_1", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}, CardDetails: &sqCardDetails{ Card: sqCard{ ID: "", CardBrand: "VISA", Last4: "4242", ExpMonth: 12, ExpYear: 2030, }, }, } result := paymentFromSquare(p) if result.CardBrand != "VISA" { t.Errorf("expected CardBrand VISA, got %s", result.CardBrand) } if result.CardLast4 != "4242" { t.Errorf("expected CardLast4 4242, got %s", result.CardLast4) } // When the card object is empty (ID == "") the card-details fields must be // left nil consistently — a 0/0 expiry with a nil fingerprint was the old // inconsistent behaviour. if result.ExpMonth != nil { t.Errorf("expected nil ExpMonth when card ID is empty, got %d", *result.ExpMonth) } if result.ExpYear != nil { t.Errorf("expected nil ExpYear when card ID is empty, got %d", *result.ExpYear) } if result.CardFingerprint != "" { t.Errorf("expected empty CardFingerprint when card ID is empty, got %s", result.CardFingerprint) } } func TestPaymentFromSquare_NilCardDetails(t *testing.T) { p := &sqPayment{ ID: "pay_2", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"}, CardDetails: nil, } result := paymentFromSquare(p) if result.CardBrand != "" { t.Errorf("expected empty CardBrand when no card details, got %s", result.CardBrand) } if result.Amount != 2500 { t.Errorf("expected amount 2500, got %d", result.Amount) } } // TestCreateCheckoutHTTP_DeviceOptionsWireShape verifies the terminal checkout // request puts device_id inside checkout.device_options (Square's required // shape), not at the top level. A top-level device_id is rejected with 400 by // Square's real API. func TestCreateCheckoutHTTP_DeviceOptionsWireShape(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/terminals/checkouts" { t.Errorf("expected /v2/terminals/checkouts, got %s", r.URL.Path) } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"checkout":{"id":"chk_test","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()} _, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-1", DeviceID: "dvc_test", }, hc) if err != nil { t.Fatalf("createCheckoutHTTP failed: %v", err) } checkout, ok := captured["checkout"].(map[string]any) if !ok { t.Fatalf("expected checkout object in body, got %v", captured) } // device_id must NOT be at the top level if _, hasTopLevel := captured["device_id"]; hasTopLevel { t.Errorf("device_id must not be top-level in terminal checkout request: %v", captured) } // device_id must live under checkout.device_options devOpts, ok := checkout["device_options"].(map[string]any) if !ok { t.Fatalf("expected checkout.device_options in body, got %v", checkout) } if devOpts["device_id"] != "dvc_test" { t.Errorf("expected checkout.device_options.device_id = dvc_test, got %v", devOpts) } } // TestCreateCheckoutHTTP_DeadlineWireShape verifies the deadline_duration // contract on BOTH sides of the wire. The terminal checkout REQUEST omits // deadline_duration entirely (Square defaults it to 5 minutes; the struct has // no request-side field), while the RESPONSE's deadline_duration ("PT5M") is // parsed through checkoutFromSquare into CheckoutResult.Deadline — so callers // see Square's live deadline instead of a stale default. func TestCreateCheckoutHTTP_DeadlineWireShape(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") // The real API echoes the checkout with a LIVE deadline_duration. _, _ = w.Write([]byte(`{"checkout":{"id":"chk_deadline","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"deadline_duration":"PT5M","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()} res, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-1", DeviceID: "dvc_test", }, hc) if err != nil { t.Fatalf("createCheckoutHTTP failed: %v", err) } // The request must NOT send deadline_duration (the code relies on Square's // 5-minute default; there is no request-side deadline field). checkout, ok := captured["checkout"].(map[string]any) if !ok { t.Fatalf("expected checkout object in body, got %v", captured) } if _, hasDeadline := checkout["deadline_duration"]; hasDeadline { t.Errorf("expected request to omit deadline_duration (Square default applies), got %v", captured) } // The response's deadline_duration is copied through to CheckoutResult. if res.Deadline != "PT5M" { t.Errorf("expected CheckoutResult.Deadline = PT5M, got %q", res.Deadline) } } func TestCheckoutFromSquare_Deadline(t *testing.T) { res := checkoutFromSquare(&sqTerminalCheckout{ ID: "chk_1", Status: "PENDING", AmountMoney: sqMoney{Amount: 5000, Currency: "GBP"}, Deadline: "PT5M", }) if res.Deadline != "PT5M" { t.Errorf("expected Deadline PT5M, got %q", res.Deadline) } // No deadline in the wire response → empty Deadline (Square may omit it). resEmpty := checkoutFromSquare(&sqTerminalCheckout{ID: "chk_2", Status: "PENDING"}) if resEmpty.Deadline != "" { t.Errorf("expected empty Deadline when wire omits deadline_duration, got %q", resEmpty.Deadline) } } // TestDoJSON_ErrorParsing covers the doJSON error branches: structured Square // errors become *squareAPIError (with Code/Detail preserved), while non-JSON // error bodies fall back to a plain error (so refund classification treats the // failure as ambiguous). func TestDoJSON_ErrorParsing(t *testing.T) { t.Run("structured_square_error_becomes_squareAPIError", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"REFUND_DECLINED","detail":"The refund was declined","field":"payment_id"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()} err := hc.doJSON(context.Background(), http.MethodPost, "/v2/refunds", nil, nil) if err == nil { t.Fatal("expected error") } var sqErr *squareAPIError if !errors.As(err, &sqErr) { t.Fatalf("expected *squareAPIError, got %T", err) } if sqErr.Code != "REFUND_DECLINED" { t.Errorf("expected code REFUND_DECLINED, got %s", sqErr.Code) } if sqErr.Detail != "The refund was declined" { t.Errorf("expected detail, got %q", sqErr.Detail) } // The HTTP status, category, and field must be captured from the wire // error so callers can distinguish 400/401/429/500 structurally. if sqErr.StatusCode != http.StatusBadRequest { t.Errorf("expected StatusCode 400, got %d", sqErr.StatusCode) } if sqErr.Category != "PAYMENT_METHOD_ERROR" { t.Errorf("expected Category PAYMENT_METHOD_ERROR, got %q", sqErr.Category) } if sqErr.Field != "payment_id" { t.Errorf("expected Field payment_id, got %q", sqErr.Field) } // Exported accessors surface the same values through wrapped errors. wrapped := fmt.Errorf("wrap: %w", err) if got := ErrorStatusCode(wrapped); got != http.StatusBadRequest { t.Errorf("expected ErrorStatusCode 400 through wrap, got %d", got) } if got := ErrorCategory(wrapped); got != "PAYMENT_METHOD_ERROR" { t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR through wrap, got %q", got) } if got := ErrorField(wrapped); got != "payment_id" { t.Errorf("expected ErrorField payment_id through wrap, got %q", got) } }) t.Run("non_json_error_body_is_plain_error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("upstream blew up")) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()} err := hc.doJSON(context.Background(), http.MethodPost, "/v2/refunds", nil, nil) if err == nil { t.Fatal("expected error") } var sqErr *squareAPIError if errors.As(err, &sqErr) { t.Fatalf("expected plain error, got *squareAPIError with code %s", sqErr.Code) } if !strings.Contains(err.Error(), "HTTP 500") { t.Errorf("expected HTTP 500 in error, got %q", err.Error()) } }) t.Run("http_3xx_is_treated_as_error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPermanentRedirect) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()} if err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil); err == nil { t.Fatal("expected error for 3xx status") } }) t.Run("empty_token_returns_error_before_http", func(t *testing.T) { hc := &httpClient{baseURL: "http://unused", token: "", http: &http.Client{}} err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil) if err == nil || !strings.Contains(err.Error(), "SQUARE_ACCESS_TOKEN is not set") { t.Fatalf("expected token error, got %v", err) } }) } // TestCreatePaymentHTTP_WireShape verifies the exact request body and headers // sent to POST /v2/payments: source_id, idempotency_key, amount_money, // location_id fallback, tip_money, and the auth/Square-Version headers. func TestCreatePaymentHTTP_WireShape(t *testing.T) { var captured map[string]any var gotAuth, gotVersion, gotContentType string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/payments" { t.Errorf("expected /v2/payments, got %s", r.URL.Path) } gotAuth = r.Header.Get("Authorization") gotVersion = r.Header.Get("Square-Version") gotContentType = r.Header.Get("Content-Type") if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"ON_FILE"},"location_id":"loc_env","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "secret-token", locationID: "loc_env", http: srv.Client()} tip := int64(500) res, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-payment-1", ReferenceID: "bk_123", Note: "deposit", TipMoney: &tip, BuyerEmail: "buyer@example.com", }, hc) if err != nil { t.Fatalf("createPaymentHTTP failed: %v", err) } if gotAuth != "Bearer secret-token" { t.Errorf("expected Authorization 'Bearer secret-token', got %q", gotAuth) } if gotVersion != squareAPIVersion { t.Errorf("expected Square-Version %q, got %q", squareAPIVersion, gotVersion) } if gotContentType != "application/json" { t.Errorf("expected Content-Type application/json, got %q", gotContentType) } if captured["source_id"] != "cnon:test-card" { t.Errorf("expected source_id cnon:test-card, got %v", captured["source_id"]) } if captured["idempotency_key"] != "ik-payment-1" { t.Errorf("expected idempotency_key ik-payment-1, got %v", captured["idempotency_key"]) } if captured["location_id"] != "loc_env" { t.Errorf("expected location_id loc_env (env fallback), got %v", captured["location_id"]) } amt, ok := captured["amount_money"].(map[string]any) if !ok { t.Fatalf("expected amount_money object, got %v", captured["amount_money"]) } if amt["amount"] != float64(5000) || amt["currency"] != "GBP" { t.Errorf("expected amount_money {5000 GBP}, got %v", amt) } tipMoney, ok := captured["tip_money"].(map[string]any) if !ok { t.Fatalf("expected tip_money object, got %v", captured["tip_money"]) } if tipMoney["amount"] != float64(500) { t.Errorf("expected tip_money amount 500, got %v", tipMoney["amount"]) } if res.ID != "pay_1" || res.Amount != 5000 || res.CardBrand != "VISA" || res.CardLast4 != "4242" { t.Errorf("unexpected payment result: %+v", res) } } // TestCreatePaymentHTTP_TipMoneyAbsentWhenNil verifies tip_money is omitted // when not set (it's omitempty in the wire struct). func TestCreatePaymentHTTP_TipMoneyAbsentWhenNil(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"payment":{"id":"pay_2","status":"COMPLETED","total_money":{"amount":1000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"MASTERCARD","last_4":"4444"},"entry_method":"ON_FILE"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 1000, Currency: "GBP", SourceID: "ccof:existing", IdempotencyKey: "ik-2", }, hc) if err != nil { t.Fatalf("createPaymentHTTP failed: %v", err) } if _, present := captured["tip_money"]; present { t.Errorf("expected tip_money to be absent when nil, got %v", captured["tip_money"]) } } // TestRefundPaymentHTTP_CodeClassification verifies definitive refund rejection // codes (Square's documented list: REFUND_DECLINED, REFUND_AMOUNT_INVALID, // PAYMENT_NOT_REFUNDABLE) map to ErrRefundDeclined, the money-in-flight codes // (PAYMENT_ALREADY_REFUNDED, REFUND_ALREADY_PENDING) map to // ErrRefundAlreadyProcessed, and ambiguous errors pass through unwrapped. // REFUND_AMOUNT_INVALID is special: Square returns it BOTH for a genuinely // invalid amount and for an already-refunded payment, so the client reconciles // via PaymentWasRefunded (GET /v2/refunds) — no existing refund → declined, // an existing COMPLETED/APPROVED/PENDING refund → already processed. func TestRefundPaymentHTTP_CodeClassification(t *testing.T) { cases := []struct { name string code string refundList string // GET /v2/refunds body served to the PaymentWasRefunded reconciliation wantErrIs error // nil = no sentinel expected wantErrNil bool }{ {name: "refund_declined", code: "REFUND_DECLINED", wantErrIs: ErrRefundDeclined}, {name: "amount_invalid_no_refund_reconciles_to_declined", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined}, {name: "amount_invalid_existing_refund_reconciles_to_already_processed", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","location_id":"loc","reason":"cancellation","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed}, {name: "payment_not_refundable", code: "PAYMENT_NOT_REFUNDABLE", wantErrIs: ErrRefundDeclined}, {name: "already_refunded", code: "PAYMENT_ALREADY_REFUNDED", wantErrIs: ErrRefundAlreadyProcessed}, {name: "already_pending", code: "REFUND_ALREADY_PENDING", wantErrIs: ErrRefundAlreadyProcessed}, {name: "ambiguous_code", code: "INTERNAL_SERVER_ERROR", wantErrIs: nil}, {name: "ambiguous_non_json", code: "", wantErrIs: nil}, // raw text body } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == http.MethodGet { // The PaymentWasRefunded reconciliation call (GET /v2/refunds) // must be answered with the configured refund list so the // REFUND_AMOUNT_INVALID branch runs end to end. body := tc.refundList if body == "" { body = `{"refunds":[]}` } _, _ = w.Write([]byte(body)) return } w.WriteHeader(http.StatusBadRequest) if tc.code == "" { _, _ = w.Write([]byte("plain text failure")) } else { _, _ = w.Write([]byte(fmt.Sprintf(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":%q,"detail":"boom"}]}`, tc.code))) } })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{ PaymentID: "pay_1", Amount: 1000, IdempotencyKey: "ik-refund", }, hc) if tc.wantErrNil { if err != nil { t.Fatalf("expected nil error, got %v", err) } return } if err == nil { t.Fatal("expected error") } if tc.wantErrIs == nil { if errors.Is(err, ErrRefundDeclined) || errors.Is(err, ErrRefundAlreadyProcessed) { t.Fatalf("expected NO sentinel for ambiguous error, got %v", err) } return } if !errors.Is(err, tc.wantErrIs) { t.Errorf("expected errors.Is(%v), got %v", tc.wantErrIs, err) } }) } } // TestRefundPaymentHTTP_WireShape verifies the refund request body and success // response parsing. func TestRefundPaymentHTTP_WireShape(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/refunds" { t.Errorf("expected /v2/refunds, got %s", r.URL.Path) } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"refund":{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","location_id":"loc","reason":"cancellation","created_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} res, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{ PaymentID: "pay_1", Amount: 1000, IdempotencyKey: "ik-refund", Reason: "cancellation", }, hc) if err != nil { t.Fatalf("refundPaymentHTTP failed: %v", err) } if captured["payment_id"] != "pay_1" { t.Errorf("expected payment_id pay_1, got %v", captured["payment_id"]) } if captured["idempotency_key"] != "ik-refund" { t.Errorf("expected idempotency_key ik-refund, got %v", captured["idempotency_key"]) } if res.ID != "ref_1" || res.Status != "COMPLETED" || res.Amount != 1000 || res.PaymentID != "pay_1" { t.Errorf("unexpected refund result: %+v", res) } } // TestGetCheckoutHTTP_StatusBranches covers the checkout polling state machine: // PENDING and IN_PROGRESS → ErrCheckoutPending, CANCELED → generic error, // COMPLETED without payment IDs → generic error, COMPLETED with payment IDs → // fetches the payment. func TestGetCheckoutHTTP_StatusBranches(t *testing.T) { t.Run("pending_returns_ErrCheckoutPending", func(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(`{"checkout":{"id":"chk_1","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := getCheckoutHTTPWithClient(context.Background(), "chk_1", hc) if !errors.Is(err, ErrCheckoutPending) { t.Fatalf("expected ErrCheckoutPending, got %v", err) } }) t.Run("in_progress_returns_ErrCheckoutPending", func(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(`{"checkout":{"id":"chk_2","status":"IN_PROGRESS","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := getCheckoutHTTPWithClient(context.Background(), "chk_2", hc) if !errors.Is(err, ErrCheckoutPending) { t.Fatalf("expected ErrCheckoutPending, got %v", err) } }) t.Run("cancelled_is_generic_error", func(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(`{"checkout":{"id":"chk_3","status":"CANCELED","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := getCheckoutHTTPWithClient(context.Background(), "chk_3", hc) if err == nil || errors.Is(err, ErrCheckoutPending) { t.Fatalf("expected generic non-pending error, got %v", err) } if !strings.Contains(err.Error(), "CANCELED") { t.Errorf("expected status in error, got %q", err.Error()) } }) t.Run("completed_without_payment_ids_is_error", func(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(`{"checkout":{"id":"chk_4","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := getCheckoutHTTPWithClient(context.Background(), "chk_4", hc) if err == nil || !strings.Contains(err.Error(), "no payment IDs") { t.Fatalf("expected 'no payment IDs' error, got %v", err) } }) t.Run("completed_fetches_payment", func(t *testing.T) { var checkoutPath, paymentPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.URL.Path == "/v2/terminals/checkouts/chk_5": checkoutPath = r.URL.Path _, _ = w.Write([]byte(`{"checkout":{"id":"chk_5","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"payment_ids":["pay_5"],"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) case r.URL.Path == "/v2/payments/pay_5": paymentPath = r.URL.Path _, _ = w.Write([]byte(`{"payment":{"id":"pay_5","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"EMV"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) default: t.Errorf("unexpected path %s", r.URL.Path) } })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} res, err := getCheckoutHTTPWithClient(context.Background(), "chk_5", hc) if err != nil { t.Fatalf("getCheckoutHTTP failed: %v", err) } if checkoutPath == "" || paymentPath == "" { t.Fatal("expected both checkout and payment fetches") } if res.ID != "pay_5" || res.Amount != 5000 || res.EntryMethod != "EMV" { t.Errorf("unexpected payment result: %+v", res) } }) } // TestListRefundsHTTP_Pagination verifies cursor-based pagination: multiple // pages are fetched, filtered by paymentID, and the 20-page guard triggers. func TestListRefundsHTTP_Pagination(t *testing.T) { t.Run("two_pages_combined_and_filtered", func(t *testing.T) { var paths []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.String()) w.Header().Set("Content-Type", "application/json") if strings.Contains(r.URL.RawQuery, "cursor=page2") { _, _ = w.Write([]byte(`{"refunds":[{"id":"ref_3","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_target","created_at":"2026-07-31T00:00:00Z"},{"id":"ref_4","status":"COMPLETED","amount_money":{"amount":2000,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}],"cursor":""}`)) return } _, _ = w.Write([]byte(`{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":500,"currency":"GBP"},"payment_id":"pay_target","created_at":"2026-07-31T00:00:00Z"},{"id":"ref_2","status":"COMPLETED","amount_money":{"amount":900,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}],"cursor":"page2"}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_target", begin, hc) if err != nil { t.Fatalf("listRefundsHTTP failed: %v", err) } if len(paths) != 2 { t.Fatalf("expected 2 pages, got %d: %v", len(paths), paths) } if !strings.Contains(paths[0], "begin_time=") { t.Errorf("expected begin_time in first request, got %q", paths[0]) } if !strings.Contains(paths[0], "limit=100") { t.Errorf("expected limit=100 in first request, got %q", paths[0]) } // Only pay_target refunds survive the client-side filter. if len(refunds) != 2 { t.Fatalf("expected 2 filtered refunds (ref_1 + ref_3), got %d: %+v", len(refunds), refunds) } }) t.Run("page_guard_errors_instead_of_partial", func(t *testing.T) { // The 20-page guard must ERROR rather than return partial results: a // refund in the truncated tail would otherwise look like "no COMPLETED // refund exists", letting the reconcile mark rows failed and over-refund. // The reconcile caller treats any error as "leave rows pending, retry". calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"refunds":[{"id":"ref_x","status":"COMPLETED","amount_money":{"amount":100,"currency":"GBP"},"payment_id":"pay_partial","created_at":"2026-07-31T00:00:00Z"}],"cursor":"next"}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc) if err == nil { t.Fatalf("expected a truncation error after 20 pages, got %d partial refunds with nil error", len(refunds)) } if !strings.Contains(err.Error(), "exceeded 20 pages") { t.Errorf("expected error to name the 20-page guard, got %v", err) } if calls != 20 { t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls) } if len(refunds) != 0 { t.Errorf("expected no partial results on truncation error, got %d", len(refunds)) } }) } // TestListRefundsHTTP_ZeroBeginTime_OmitsParam locks the FIX 6 parity: a ZERO // begin_time must OMIT the begin_time query param entirely — formatting the // zero time would send Square a year-1 timestamp (0001-01-01T00:00:00Z) it may // reject. The reconciliation callers (PaymentWasRefunded / // paymentRefundedExactlyWithClient) pass time.Time{} deliberately to list ALL // refunds, so the param must be absent, not "before year 1". A non-zero // begin_time still emits begin_time as before. func TestListRefundsHTTP_ZeroBeginTime_OmitsParam(t *testing.T) { var queries []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { queries = append(queries, r.URL.RawQuery) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"refunds":[],"cursor":""}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} // Zero time (the reconciliation shape) → begin_time must be absent. _, err := listRefundsHTTPWithClient(context.Background(), "pay_zero", time.Time{}, hc) if err != nil { t.Fatalf("listRefundsHTTPWithClient failed: %v", err) } if len(queries) != 1 { t.Fatalf("expected 1 request, got %d", len(queries)) } if strings.Contains(queries[0], "begin_time=") { t.Errorf("expected NO begin_time for a zero begin_time, got %q", queries[0]) } if !strings.Contains(queries[0], "limit=100") { t.Errorf("expected limit=100 in the zero-time query, got %q", queries[0]) } // Non-zero time → begin_time is emitted with the RFC3339 timestamp. queries = nil begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) _, err = listRefundsHTTPWithClient(context.Background(), "pay_begin", begin, hc) if err != nil { t.Fatalf("listRefundsHTTPWithClient failed: %v", err) } if len(queries) != 1 { t.Fatalf("expected 1 request, got %d", len(queries)) } if !strings.Contains(queries[0], "begin_time="+url.QueryEscape("2026-07-01T00:00:00Z")) { t.Errorf("expected begin_time=2026-07-01T00:00:00Z in query, got %q", queries[0]) } } // TestCreateCardOnFileHTTP_IdempotencyKey verifies the deterministic SHA-256 // idempotency key derivation and the request wire shape (source_id + card). func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) { var captured map[string]any var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/cards" { t.Errorf("expected /v2/cards, got %s", r.URL.Path) } gotAuth = r.Header.Get("Authorization") if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()} res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "", hc) if err != nil { t.Fatalf("createCardOnFileHTTP failed: %v", err) } sum := sha256.Sum256([]byte("user_1|cnon:test-card")) wantIK := "card-" + fmt.Sprintf("%x", sum)[:38] if captured["idempotency_key"] != wantIK { t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"]) } // Square's documented idempotency-key limit for /v2/cards is 45 chars — // the truncated key must never exceed it (C-1 regression guard). if len(wantIK) > 45 { t.Errorf("idempotency_key %q is %d chars, exceeds Square's 45-char limit", wantIK, len(wantIK)) } if captured["source_id"] != "cnon:test-card" { t.Errorf("expected source_id cnon:test-card, got %v", captured["source_id"]) } card, ok := captured["card"].(map[string]any) if !ok { t.Fatalf("expected card object, got %v", captured["card"]) } // The local user ID goes in reference_id (free-form); customer_id is // emitted only when the app has provisioned a Square customer for the user // (empty customerID → omitted via omitempty). if card["reference_id"] != "user_1" { t.Errorf("expected card.reference_id user_1, got %v", card["reference_id"]) } if _, present := card["customer_id"]; present { t.Errorf("expected card.customer_id to be ABSENT when customerID is empty, got %v", card["customer_id"]) } if gotAuth != "Bearer secret" { t.Errorf("expected Authorization 'Bearer secret', got %q", gotAuth) } if res.CardID != "ccof_x" || res.Brand != "VISA" || res.Last4 != "4242" { t.Errorf("unexpected card result: %+v", res) } } // TestCreateCardOnFileHTTP_CustomerIDEmitted verifies card.customer_id is sent // when the app has provisioned a Square customer for the user (Square marks // customer_id Required on the Card object for saved-card flows). func TestCreateCardOnFileHTTP_CustomerIDEmitted(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/cards" { t.Errorf("expected /v2/cards, got %s", r.URL.Path) } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"cus_1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()} _, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "cus_1", hc) if err != nil { t.Fatalf("createCardOnFileHTTP failed: %v", err) } card, ok := captured["card"].(map[string]any) if !ok { t.Fatalf("expected card object, got %v", captured["card"]) } if card["customer_id"] != "cus_1" { t.Errorf("expected card.customer_id cus_1, got %v", card["customer_id"]) } // The idempotency key is derived solely from user|card, so it is identical // whether or not a customer_id accompanies the request. sum := sha256.Sum256([]byte("user_1|cnon:test-card")) wantIK := "card-" + fmt.Sprintf("%x", sum)[:38] if captured["idempotency_key"] != wantIK { t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"]) } } // TestGetCardsOnFileHTTP_ReferenceIDFilter verifies the List Cards request uses // the native reference_id filter (the local user ID) — not the invalid // customer_id — and that cards are returned unfiltered server-side. func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) { var gotRawQuery string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } if r.URL.Path != "/v2/cards" { t.Errorf("expected /v2/cards, got %s", r.URL.Path) } gotRawQuery = r.URL.RawQuery w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"cards":[{"id":"ccof_1","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"},{"id":"ccof_2","card_brand":"MASTERCARD","last_4":"1111","exp_month":6,"exp_year":2029,"fingerprint":"fp2","reference_id":"user_other","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_1", hc) if err != nil { t.Fatalf("getCardsOnFileHTTP failed: %v", err) } // Native reference_id filter — never the invalid customer_id, never a // limit that Square's List Cards API doesn't support. if !strings.Contains(gotRawQuery, "reference_id=user_1") { t.Errorf("expected reference_id=user_1 in query, got %q", gotRawQuery) } if strings.Contains(gotRawQuery, "customer_id") { t.Errorf("expected NO customer_id in query (local IDs are not Square customers), got %q", gotRawQuery) } if strings.Contains(gotRawQuery, "limit") { t.Errorf("expected NO limit param (List Cards has no limit; server filters by reference_id), got %q", gotRawQuery) } if len(cards) != 2 { t.Fatalf("expected 2 cards returned, got %d", len(cards)) } if cards[0].ReferenceID != "user_1" || cards[1].ReferenceID != "user_other" { t.Errorf("unexpected cards: %+v %+v", cards[0], cards[1]) } } // TestGetCardsOnFileHTTP_PageGuard_ReturnsPartial verifies the 20-page guard // keeps returning partial results (nil error) for card listing, unlike // listRefunds which errors: GetCardsOnFile has no money-sensitive caller, and // erroring would break a "show my cards" feature for a user with >500 saved // cards. The truncation is surfaced in the log, not by an error. func TestGetCardsOnFileHTTP_PageGuard_ReturnsPartial(t *testing.T) { calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"cards":[{"id":"ccof_t","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp_t","reference_id":"user_big","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}],"cursor":"next"}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_big", hc) if err != nil { t.Fatalf("expected partial cards with nil error, got %v", err) } if calls != 20 { t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls) } if len(cards) != 20 { t.Errorf("expected 20 cards collected across pages (one per page), got %d", len(cards)) } } // TestCreateCheckoutHTTP_TipSettings verifies AllowTipping is emitted as // checkout.device_options.tip_settings.allow_tipping (Square's wire shape for // enabling terminal tips) and omitted entirely when not set. func TestCreateCheckoutHTTP_TipSettings(t *testing.T) { t.Run("allow_tipping_true_emits_tip_settings", func(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"checkout":{"id":"chk_tip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-tip", DeviceID: "dvc_1", AllowTipping: true, }, hc) if err != nil { t.Fatalf("createCheckoutHTTP failed: %v", err) } checkout, ok := captured["checkout"].(map[string]any) if !ok { t.Fatalf("expected checkout object, got %v", captured) } // tip_settings must NOT be at the checkout top level — a top-level // tip_settings is silently ignored by Square (terminal tip loss). if _, hasTopLevel := checkout["tip_settings"]; hasTopLevel { t.Errorf("tip_settings must not be top-level in terminal checkout request: %v", checkout) } // tip_settings must live under checkout.device_options devOpts, ok := checkout["device_options"].(map[string]any) if !ok { t.Fatalf("expected checkout.device_options in body, got %v", checkout) } tipSettings, ok := devOpts["tip_settings"].(map[string]any) if !ok { t.Fatalf("expected device_options.tip_settings when AllowTipping is true, got %v", devOpts) } if tipSettings["allow_tipping"] != true { t.Errorf("expected tip_settings.allow_tipping=true, got %v", tipSettings) } }) t.Run("allow_tipping_false_omits_tip_settings", func(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"checkout":{"id":"chk_notip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-notip", DeviceID: "dvc_1", AllowTipping: false, }, hc) if err != nil { t.Fatalf("createCheckoutHTTP failed: %v", err) } checkout, ok := captured["checkout"].(map[string]any) if !ok { t.Fatalf("expected checkout object, got %v", captured) } // device_options is always present (device_id is required); only the // tip_settings sub-object must be absent. devOpts, ok := checkout["device_options"].(map[string]any) if !ok { t.Fatalf("expected checkout.device_options in body, got %v", checkout) } if _, present := devOpts["tip_settings"]; present { t.Errorf("expected device_options.tip_settings ABSENT when AllowTipping is false, got %v", devOpts["tip_settings"]) } }) } // TestGetPaymentHTTP verifies GET /v2/payments/{id} maps via paymentFromSquare // and that an empty payment ID errors before any HTTP call. func TestGetPaymentHTTP_WireShape(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } if r.URL.Path != "/v2/payments/pay_1" { t.Errorf("expected /v2/payments/pay_1, got %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"EMV"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} res, err := getPaymentHTTPWithClient(context.Background(), "pay_1", hc) if err != nil { t.Fatalf("getPaymentHTTP failed: %v", err) } if res.ID != "pay_1" || res.Amount != 5000 || res.EntryMethod != "EMV" { t.Errorf("unexpected payment result: %+v", res) } _, err = getPaymentHTTPWithClient(context.Background(), "", hc) if err == nil || !strings.Contains(err.Error(), "invalid payment id") { t.Fatalf("expected empty-ID error, got %v", err) } } func TestGetPaymentHTTP_NotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Payment not found"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := getPaymentHTTPWithClient(context.Background(), "pay_missing", hc) if err == nil { t.Fatal("expected error for not-found payment") } } // TestCreateCustomerHTTP_WireShape verifies the CreateCustomer request body: // deterministic "customer-" + sha256(email) idempotency key (≤45 chars), // email_address, and given_name. func TestCreateCustomerHTTP_WireShape(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/customers" { t.Errorf("expected /v2/customers, got %s", r.URL.Path) } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"customer":{"id":"cus_1","email_address":"jane@example.com","given_name":"Jane","created_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} res, err := createCustomerHTTPWithClient(context.Background(), "Jane", "jane@example.com", hc) if err != nil { t.Fatalf("createCustomerHTTP failed: %v", err) } sum := sha256.Sum256([]byte("jane@example.com")) wantIK := "customer-" + fmt.Sprintf("%x", sum)[:35] if captured["idempotency_key"] != wantIK { t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"]) } // Square's documented idempotency-key limit is 45 chars — the truncated // key must never exceed it. if len(wantIK) > 45 { t.Errorf("idempotency_key %q is %d chars, exceeds Square's 45-char limit", wantIK, len(wantIK)) } if captured["email_address"] != "jane@example.com" { t.Errorf("expected email_address jane@example.com, got %v", captured["email_address"]) } if captured["given_name"] != "Jane" { t.Errorf("expected given_name Jane, got %v", captured["given_name"]) } if res.ID != "cus_1" || res.Email != "jane@example.com" || res.CreatedAt == "" { t.Errorf("unexpected customer result: %+v", res) } } // TestCancelCheckoutHTTP_NonFatalErrors verifies CancelCheckout treats // already-completed/unknown checkouts as a no-op: structured NOT_FOUND, plain // HTTP 404, and success all return nil. Genuine failures propagate. func TestCancelCheckoutHTTP_NonFatalErrors(t *testing.T) { t.Run("success_is_nil", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/v2/terminals/checkouts/chk_1/cancel" { t.Errorf("expected cancel path, got %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"checkout":{"id":"chk_1","status":"CANCELED","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_1", hc); err != nil { t.Fatalf("expected nil for successful cancel, got %v", err) } }) t.Run("structured_not_found_is_nil", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Checkout not found or already completed"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_missing", hc); err != nil { t.Fatalf("expected nil for NOT_FOUND (already completed is a no-op), got %v", err) } }) t.Run("plain_404_is_nil", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("checkout not found")) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_404", hc); err != nil { t.Fatalf("expected nil for plain HTTP 404, got %v", err) } }) t.Run("other_error_propagates", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INVALID_VALUE","detail":"bad"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_bad", hc); err == nil { t.Fatal("expected non-nil error for genuine failure") } }) t.Run("noop_code_is_error", func(t *testing.T) { // "NOOP" is NOT a confirmed Square error code, so it must propagate as // an error — only NOT_FOUND is treated as an idempotent no-op. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOOP","detail":"nothing to cancel"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} err := cancelCheckoutHTTPWithClient(context.Background(), "chk_noop", hc) if err == nil { t.Fatal("expected NOOP code to propagate as an error (NOOP is not a confirmed Square code)") } if code := ErrorCode(err); code != "NOOP" { t.Errorf("expected NOOP code on error, got %q", code) } }) } // TestPaymentFromSquare_ExpiryPointers verifies exp_month/exp_year are set as // pointers when card details are present and left nil when absent. func TestPaymentFromSquare_ExpiryPointers(t *testing.T) { t.Run("card_details_sets_pointers", func(t *testing.T) { p := &sqPayment{ ID: "pay_exp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}, CardDetails: &sqCardDetails{ Card: sqCard{ID: "ccof_x", CardBrand: "VISA", Last4: "4242", ExpMonth: 12, ExpYear: 2030}, }, } result := paymentFromSquare(p) if result.ExpMonth == nil || result.ExpYear == nil { t.Fatalf("expected non-nil expiry pointers, got %v/%v", result.ExpMonth, result.ExpYear) } if *result.ExpMonth != 12 || *result.ExpYear != 2030 { t.Errorf("expected exp 12/2030, got %d/%d", *result.ExpMonth, *result.ExpYear) } }) t.Run("no_card_details_leaves_nil", func(t *testing.T) { p := &sqPayment{ID: "pay_noexp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"}} result := paymentFromSquare(p) if result.ExpMonth != nil || result.ExpYear != nil { t.Errorf("expected nil expiry without card details, got %v/%v", result.ExpMonth, result.ExpYear) } }) } // TestValidSquareID covers the URL path-segment safety check: Square IDs are // alphanumeric plus '_' and '-' and at most 64 chars. Empty, over-long, and // any character outside that set is rejected before it can reach a URL path. func TestValidSquareID(t *testing.T) { valid := []string{ "pay_123", "P1-abc", "chk_1", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-", // exactly 64 chars } invalid := []string{ "", "has space", "bad/char", "bad.char", "traversal/../..", strings.Repeat("a", 65), } for _, id := range valid { if !validSquareID(id) { t.Errorf("expected %q to be a valid Square ID", id) } } for _, id := range invalid { if validSquareID(id) { t.Errorf("expected %q to be rejected", id) } } } // TestIDValidation_RejectsBeforeHTTP verifies getPayment/getCheckout/cancelCheckout // reject malformed IDs before building the request URL. The client points at an // unused host: a request that slipped past validation would fail with a network // error instead of an "invalid ... id" error, so the assertion is meaningful. func TestIDValidation_RejectsBeforeHTTP(t *testing.T) { hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}} ctx := context.Background() _, err := getPaymentHTTPWithClient(ctx, "bad/id", hc) if err == nil || !strings.Contains(err.Error(), "invalid payment id") { t.Fatalf("expected invalid payment id error, got %v", err) } _, err = getCheckoutHTTPWithClient(ctx, "bad/id", hc) if err == nil || !strings.Contains(err.Error(), "invalid checkout id") { t.Fatalf("expected invalid checkout id error, got %v", err) } err = cancelCheckoutHTTPWithClient(ctx, "bad/id", hc) if err == nil || !strings.Contains(err.Error(), "invalid checkout id") { t.Fatalf("expected invalid checkout id error, got %v", err) } } // TestErrorCode_ErrorDetail verifies the exported accessors surface the // structured Square error Code/Detail for direct and wrapped *squareAPIError // values, and return "" for non-Square errors (so handlers can classify charge // failures structurally instead of substring-matching). func TestErrorCode_ErrorDetail(t *testing.T) { t.Run("direct", func(t *testing.T) { base := &squareAPIError{Code: "INVALID_VALUE", Detail: "bad thing", err: errors.New("square: boom")} if got := ErrorCode(base); got != "INVALID_VALUE" { t.Errorf("expected INVALID_VALUE, got %q", got) } if got := ErrorDetail(base); got != "bad thing" { t.Errorf("expected detail 'bad thing', got %q", got) } }) t.Run("wrapped", func(t *testing.T) { base := &squareAPIError{Code: "CARD_DECLINED", Detail: "card declined", err: errors.New("square: boom")} wrapped := fmt.Errorf("wrap: %w", base) if got := ErrorCode(wrapped); got != "CARD_DECLINED" { t.Errorf("expected CARD_DECLINED through wrap, got %q", got) } if got := ErrorDetail(wrapped); got != "card declined" { t.Errorf("expected detail through wrap, got %q", got) } }) t.Run("non_square_error", func(t *testing.T) { if got := ErrorCode(errors.New("plain")); got != "" { t.Errorf("expected \"\", got %q", got) } if got := ErrorDetail(errors.New("plain")); got != "" { t.Errorf("expected \"\", got %q", got) } if got := ErrorCode(nil); got != "" { t.Errorf("expected \"\" for nil, got %q", got) } if got := ErrorDetail(nil); got != "" { t.Errorf("expected \"\" for nil, got %q", got) } }) } // TestIsNotFound verifies the structured not-found check: the NOT_FOUND code, // an HTTP 404 status, and a plain non-JSON 404 body all count; 400/500 errors // and nil do not. func TestIsNotFound(t *testing.T) { notFound := &squareAPIError{Code: "NOT_FOUND", Detail: "nope", StatusCode: http.StatusNotFound, err: errors.New("square: boom")} if !IsNotFound(notFound) { t.Error("expected structured NOT_FOUND code to be IsNotFound") } status404 := &squareAPIError{Code: "OTHER_CODE", Detail: "nope", StatusCode: http.StatusNotFound, err: errors.New("square: boom")} if !IsNotFound(status404) { t.Error("expected HTTP 404 status to be IsNotFound regardless of code") } wrapped := fmt.Errorf("wrap: %w", notFound) if !IsNotFound(wrapped) { t.Error("expected IsNotFound to work through a wrapped error") } badRequest := &squareAPIError{Code: "INVALID_VALUE", Detail: "bad", StatusCode: http.StatusBadRequest, err: errors.New("square: boom")} if IsNotFound(badRequest) { t.Error("expected HTTP 400 NOT to be IsNotFound") } if IsNotFound(errors.New("square: GET /v2/x: HTTP 500: boom")) { t.Error("expected HTTP 500 message NOT to be IsNotFound") } if IsNotFound(nil) { t.Error("expected nil NOT to be IsNotFound") } // Plain non-JSON 404 body (doJSON's fallback error) still counts. if !IsNotFound(errors.New("square: POST /v2/x: HTTP 404: plain body")) { t.Error("expected plain HTTP 404 message to be IsNotFound") } } // TestCreatePaymentHTTP_RejectsRawPAN verifies the real HTTP client refuses a // raw PAN before forwarding to Square (PCI-DSS parity with the dev mock). func TestCreatePaymentHTTP_RejectsRawPAN(t *testing.T) { hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}} _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "4111111111111111", IdempotencyKey: "ik-raw-pan", }, hc) if err == nil { t.Fatal("expected raw PAN to be rejected before any HTTP call") } if !strings.Contains(err.Error(), "invalid card token") { t.Errorf("expected 'invalid card token' error, got %q", err.Error()) } } // TestCreateCardOnFileHTTP_RejectsRawPAN verifies the real HTTP client refuses // a raw PAN card token before forwarding to Square (PCI-DSS parity). func TestCreateCardOnFileHTTP_RejectsRawPAN(t *testing.T) { hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}} _, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "4111111111111111", "", hc) if err == nil { t.Fatal("expected raw PAN to be rejected before any HTTP call") } if !strings.Contains(err.Error(), "invalid card token") { t.Errorf("expected 'invalid card token' error, got %q", err.Error()) } } // TestIsTokenLike covers the token validation single source of truth: cnon: // nonces and ccof: card IDs are token-like, raw PANs and empty/malformed // values are not. A bare "cnon:"/ccof:" prefix IS accepted — HasPrefix only // checks the prefix and Square rejects empty payloads with its own 400. func TestIsTokenLike(t *testing.T) { valid := []string{ "cnon:abc", "cnon:test-card", "ccof:abc", "ccof:mock_card_123", "cnon:", "ccof:", } invalid := []string{ "", "4111111111111111", "visa", "cnon_abc", "abc:cnon:", } for _, tok := range valid { if !isTokenLike(tok) { t.Errorf("expected %q to be token-like", tok) } } for _, tok := range invalid { if isTokenLike(tok) { t.Errorf("expected %q to NOT be token-like", tok) } } } // TestTokenPrefix_Redacts verifies the PCI-safe abbreviation: the full token // never appears, only the first 8 chars plus the length. func TestTokenPrefix_Redacts(t *testing.T) { token := "ccof:secret_token_abc123" redacted := TokenPrefix(token) if strings.Contains(redacted, "ccof:secret") { t.Errorf("TokenPrefix leaked more than the first 8 chars: %q", redacted) } if !strings.HasPrefix(redacted, "ccof:sec") { t.Errorf("expected first 8 chars prefix, got %q", redacted) } if !strings.Contains(redacted, "len 24") { t.Errorf("expected total length in abbreviation, got %q", redacted) } } // TestDoJSON_OversizedResponseTruncated covers the doJSON response-body limit: // an oversized body is cut at maxResponseBody and errors surface truncation // instead of embedding garbage or an unbounded body. func TestDoJSON_OversizedResponseTruncated(t *testing.T) { t.Run("oversized_non_json_error_is_capped", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(strings.Repeat("A", maxResponseBody+100))) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil) if err == nil { t.Fatal("expected error") } msg := err.Error() if !strings.Contains(msg, "HTTP 500") { t.Errorf("expected HTTP 500 in error, got %q", msg) } if !strings.Contains(msg, "truncated") { t.Errorf("expected truncation note in error, got %q", msg) } // The embedded body snippet must be capped well below the full body. if len(msg) > maxErrorBody*3 { t.Errorf("error message embeds too much of the response body (%d bytes)", len(msg)) } }) t.Run("oversized_success_json_unmarshal_fails_with_truncation", func(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(`{"payment":{"id":"` + strings.Repeat("x", maxResponseBody) + `"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} var target sqCreatePaymentResponse err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, &target) if err == nil { t.Fatal("expected error for truncated oversized body") } if !strings.Contains(err.Error(), "truncated") { t.Errorf("expected truncation error, got %q", err.Error()) } }) t.Run("valid_json_prefix_is_still_truncation_error", func(t *testing.T) { // A 2xx body over maxResponseBody whose first 1 MiB prefix is itself // valid JSON (e.g. {"ok":true} padded to the cap, then a huge trailing // JSON object) must return a truncation error, NEVER a silent partial // success. Unmarshal would succeed on the prefix alone, so the // truncation check must fire independently of the unmarshal result. prefix := `{"ok":true}` padding := strings.Repeat(" ", maxResponseBody-len(prefix)) trailer := `{"pad":"` + strings.Repeat("x", maxResponseBody) + `"}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(prefix + padding + trailer)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} var target sqCreatePaymentResponse err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, &target) if err == nil { t.Fatal("expected truncation error for oversized body whose prefix is valid JSON") } if !strings.Contains(err.Error(), "truncated") { t.Errorf("expected truncation error, got %q", err.Error()) } }) t.Run("structured_error_detail_capped_in_message_only", func(t *testing.T) { fullDetail := "echoed-input-" + strings.Repeat("A", 600) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"REFUND_DECLINED","detail":` + fmt.Sprintf("%q", fullDetail) + `,"field":"payment_id"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} err := hc.doJSON(context.Background(), http.MethodPost, "/v2/refunds", nil, nil) if err == nil { t.Fatal("expected error") } var sqErr *squareAPIError if !errors.As(err, &sqErr) { t.Fatalf("expected *squareAPIError, got %T", err) } // The message (what handlers log) must be capped... if len(sqErr.err.Error()) > maxErrorBody*3 { t.Errorf("error message embeds too much of the detail (%d bytes)", len(sqErr.err.Error())) } // ...while the structured Detail stays intact for ErrorDetail() callers. if sqErr.Detail != fullDetail { t.Errorf("expected full Detail preserved for ErrorDetail, got %d bytes", len(sqErr.Detail)) } }) } // TestCapBody_RuneSafeTruncation verifies capBody never splits a multi-byte // UTF-8 rune at the maxErrorBody cut: the result must always be valid UTF-8 // even when the byte cut lands mid-rune (handlers log these messages verbatim, // and a half-encoded rune would mangle logs feeding UTF-8-sensitive tooling). func TestCapBody_RuneSafeTruncation(t *testing.T) { t.Run("cut_lands_mid_rune", func(t *testing.T) { // 499 ASCII bytes + a 3-byte rune (€) starting at byte 499: a raw // s[:maxErrorBody] cut would slice the rune in half. s := strings.Repeat("a", maxErrorBody-1) + "€" + strings.Repeat("b", maxErrorBody) got := capBody(s) if !utf8.ValidString(got) { t.Errorf("capBody result is invalid UTF-8: %q", got) } if !strings.HasSuffix(got, "... (truncated)") { t.Errorf("expected truncation marker, got %q", got) } prefix := strings.TrimSuffix(got, "... (truncated)") if len(prefix) > maxErrorBody { t.Errorf("capped prefix is %d bytes, exceeds cap %d", len(prefix), maxErrorBody) } // The split rune must be dropped at the cut, not mangled. if strings.Contains(got, "€") { t.Errorf("expected the split rune to be dropped, got %q", got) } }) t.Run("cut_on_clean_rune_edge_is_preserved", func(t *testing.T) { s := strings.Repeat("a", maxErrorBody) + "rest" got := capBody(s) if got != strings.Repeat("a", maxErrorBody)+"... (truncated)" { t.Errorf("expected exact %d-byte prefix preserved, got %q", maxErrorBody, got) } }) t.Run("within_cap_is_unchanged", func(t *testing.T) { s := "short body with €" if got := capBody(s); got != s { t.Errorf("expected short input unchanged, got %q", got) } }) } // TestDeleteCardOnFileHTTP_RejectsInvalidID verifies the deleteCardOnFileHTTP // path-segment guard: an invalid card id errors before any HTTP call, and the // error redacts the full ID (it is a DB-stored ccof: token) rather than // echoing it verbatim. func TestDeleteCardOnFileHTTP_RejectsInvalidID(t *testing.T) { invalidID := "bad/card/id/path/traversal" // long enough that tokenPrefix must truncate it err := deleteCardOnFileHTTP(context.Background(), invalidID) if err == nil || !strings.Contains(err.Error(), "invalid card id") { t.Fatalf("expected invalid card id error, got %v", err) } if strings.Contains(err.Error(), invalidID) { t.Errorf("error must not embed the full invalid card id: %q", err.Error()) } } // TestValidCardID covers the ccof:-aware card-id check used by // deleteCardOnFileHTTP: the ccof: prefix is stripped before the standard // charset rule, so legitimate card IDs pass while path-traversal rejects. func TestValidCardID(t *testing.T) { valid := []string{ "ccof:abc_123", "ccof:ABC-def", "plain_id_1", } invalid := []string{ "", "ccof:", "ccof:bad/id", "ccof:bad..id", "bad/id", "bad?id", strings.Repeat("a", 65), } for _, id := range valid { if !validCardID(id) { t.Errorf("expected %q to be a valid card ID", id) } } for _, id := range invalid { if validCardID(id) { t.Errorf("expected %q to be rejected", id) } } } // TestDeleteCustomerHTTP covers the DELETE /v2/customers/{id} endpoint: // success, idempotent NOT_FOUND no-ops, propagated failures, and the // invalid-id guard. func TestDeleteCustomerHTTP(t *testing.T) { t.Run("success_deletes_customer", func(t *testing.T) { var gotMethod, gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"customer":{"id":"cus_1","email_address":"jane@example.com","given_name":"Jane","created_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := deleteCustomerHTTPWithClient(context.Background(), "cus_1", hc); err != nil { t.Fatalf("deleteCustomerHTTP failed: %v", err) } if gotMethod != http.MethodDelete { t.Errorf("expected DELETE, got %s", gotMethod) } if gotPath != "/v2/customers/cus_1" { t.Errorf("expected /v2/customers/cus_1, got %s", gotPath) } }) t.Run("structured_not_found_is_noop", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Customer not found"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := deleteCustomerHTTPWithClient(context.Background(), "cus_missing", hc); err != nil { t.Fatalf("expected nil for NOT_FOUND (idempotent re-deletion), got %v", err) } }) t.Run("plain_404_is_noop", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("customer not found")) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := deleteCustomerHTTPWithClient(context.Background(), "cus_404", hc); err != nil { t.Fatalf("expected nil for plain HTTP 404, got %v", err) } }) t.Run("other_error_propagates", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INVALID_VALUE","detail":"bad"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} if err := deleteCustomerHTTPWithClient(context.Background(), "cus_bad", hc); err == nil { t.Fatal("expected error for genuine failure") } }) t.Run("invalid_id_rejected_before_http", func(t *testing.T) { hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}} err := deleteCustomerHTTPWithClient(context.Background(), "bad/id", hc) if err == nil || !strings.Contains(err.Error(), "invalid customer id") { t.Fatalf("expected invalid customer id error, got %v", err) } }) } // TestPaymentFromSquare_NegatesProcessingFees locks the processing-fee sign // convention (finding A): Square reports processing_fee amounts as NEGATIVE on // the wire, and paymentFromSquare must surface a POSITIVE PaymentResult.Fees // (the magnitude handlers store as p.fees). A fee of -95 on the wire must // become Fees == 95. func TestPaymentFromSquare_NegatesProcessingFees(t *testing.T) { p := &sqPayment{ ID: "pay_fee", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}, ProcessingFee: []sqFee{ {AmountMoney: sqMoney{Amount: -95, Currency: "GBP"}, Type: "INITIAL"}, {AmountMoney: sqMoney{Amount: -20, Currency: "GBP"}, Type: "SECONDARY"}, }, } result := paymentFromSquare(p) if result.Fees != 115 { t.Errorf("expected Fees 115 (sum of negated Square fees), got %d", result.Fees) } // A zero fee stays zero. zero := paymentFromSquare(&sqPayment{ID: "pay_zero", Status: "COMPLETED", ProcessingFee: []sqFee{{AmountMoney: sqMoney{Amount: 0, Currency: "GBP"}}}}) if zero.Fees != 0 { t.Errorf("expected Fees 0 for a zero fee, got %d", zero.Fees) } } // TestPaymentFromSquare_StatusMapping verifies paymentFromSquare maps every // documented Square payment status FAITHFULLY (finding D): APPROVED, PENDING, // FAILED, CANCELED and COMPLETED all flow through into PaymentResult.Status. // The client never downgrades or drops a non-terminal status. func TestPaymentFromSquare_StatusMapping(t *testing.T) { for _, status := range []string{"APPROVED", "PENDING", "FAILED", "CANCELED", "COMPLETED"} { t.Run(status, func(t *testing.T) { p := &sqPayment{ID: "pay_" + status, Status: status, TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}} result := paymentFromSquare(p) if result.Status != status { t.Errorf("expected Status %q mapped verbatim, got %q", status, result.Status) } }) } } // TestDoJSON_200WithFailedPayment_NotDropped verifies doJSON does NOT silently // drop a 200-with-FAILED payment (finding D): a 2xx body carrying a FAILED // payment is parsed into a PaymentResult with Status "FAILED" and nil error — // the client surfaces the status faithfully instead of erroring, so a // status-blind handler (records 'completed' on nil error alone) is exposed. func TestDoJSON_200WithFailedPayment_NotDropped(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(`{"payment":{"id":"pay_failed_200","status":"FAILED","total_money":{"amount":5000,"currency":"GBP"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} res, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-failed-200", }, hc) if err != nil { t.Fatalf("a 200-with-FAILED payment must NOT error (the client is status-transparent), got %v", err) } if res.Status != "FAILED" { t.Errorf("expected Status FAILED surfaced faithfully, got %q", res.Status) } if res.ID != "pay_failed_200" { t.Errorf("expected the failed payment returned, got %+v", res) } } // TestDoJSON_CardProcessingNotEnabled403 verifies a 403 CARD_PROCESSING_NOT_ENABLED // response surfaces the structured Square error with StatusCode 403 (finding E) // so the handlers agent can special-case it (errors.go, not owned here). func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"CARD_PROCESSING_NOT_ENABLED","detail":"Card processing is not enabled for this account."}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil) if err == nil { t.Fatal("expected error for 403") } if got := ErrorStatusCode(err); got != http.StatusForbidden { t.Errorf("expected ErrorStatusCode 403, got %d", got) } if got := ErrorCode(err); got != "CARD_PROCESSING_NOT_ENABLED" { t.Errorf("expected ErrorCode CARD_PROCESSING_NOT_ENABLED, got %q", got) } if got := ErrorCategory(err); got != "PAYMENT_METHOD_ERROR" { t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got) } } // TestCreatePaymentHTTP_CustomerDetailsWireShape verifies the customer_details // wiring on POST /v2/payments: when CustomerDetails is set // (CustomerInitiated=true — online card entry is always cardholder-initiated), // the request body carries customer_details.customer_initiated=true; when nil, // the field is omitted entirely (Square's default classification applies). func TestCreatePaymentHTTP_CustomerDetailsWireShape(t *testing.T) { t.Run("customer_initiated_true_is_sent", func(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"payment":{"id":"pay_cd","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd", CustomerDetails: &CreateCustomerDetails{CustomerInitiated: true}, }, hc) if err != nil { t.Fatalf("createPaymentHTTP failed: %v", err) } cd, ok := captured["customer_details"].(map[string]any) if !ok { t.Fatalf("expected customer_details object, got %v", captured["customer_details"]) } if cd["customer_initiated"] != true { t.Errorf("expected customer_details.customer_initiated=true, got %v", cd["customer_initiated"]) } }) t.Run("nil_customer_details_is_omitted", func(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"payment":{"id":"pay_cd2","status":"COMPLETED","total_money":{"amount":1000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"MASTERCARD","last_4":"4444"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 1000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd-nil", }, hc) if err != nil { t.Fatalf("createPaymentHTTP failed: %v", err) } if _, present := captured["customer_details"]; present { t.Errorf("expected customer_details to be ABSENT when CustomerDetails is nil, got %v", captured["customer_details"]) } }) } // TestPaymentWasRefunded verifies the exported PaymentWasRefunded reconciliation // helper: an existing COMPLETED/APPROVED/PENDING refund for the payment means // money has moved (true), a FAILED/rejected refund or an empty list means // nothing moved (false), and a server error propagates as an error. func TestPaymentWasRefunded(t *testing.T) { cases := []struct { name string refundList string statusCode int want bool wantErr bool }{ {name: "completed_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_c","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true}, {name: "approved_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_a","status":"APPROVED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true}, {name: "pending_refund_means_money_in_flight", refundList: `{"refunds":[{"id":"ref_p","status":"PENDING","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true}, {name: "failed_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_f","status":"FAILED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false}, {name: "rejected_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_r","status":"REJECTED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false}, {name: "empty_list_means_nothing_moved", refundList: `{"refunds":[]}`, want: false}, {name: "other_payment_refund_is_filtered_out", refundList: `{"refunds":[{"id":"ref_o","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}]}`, want: false}, {name: "server_error_propagates", refundList: `{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INTERNAL_SERVER_ERROR","detail":"boom"}]}`, statusCode: http.StatusInternalServerError, wantErr: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } if r.URL.Path != "/v2/refunds" { t.Errorf("expected /v2/refunds, got %s", r.URL.Path) } if strings.Contains(r.URL.RawQuery, "begin_time=") { // The reconciliation passes a ZERO begin_time (time.Time{}) // to mean "all refunds"; the param must be ABSENT (FIX 6) — // sending begin_time=0001-01-01T00:00:00Z is a year-1 // timestamp Square may reject. t.Errorf("expected NO begin_time in the zero-time reconciliation query, got %q", r.URL.RawQuery) } w.Header().Set("Content-Type", "application/json") if tc.statusCode != 0 { w.WriteHeader(tc.statusCode) } _, _ = w.Write([]byte(tc.refundList)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} got, err := paymentWasRefundedWithClient(context.Background(), "pay_1", hc) if tc.wantErr { if err == nil { t.Fatalf("expected error, got wasRefunded=%v", got) } return } if err != nil { t.Fatalf("paymentWasRefunded failed: %v", err) } if got != tc.want { t.Errorf("paymentWasRefunded = %v, want %v", got, tc.want) } }) } } // TestRefundPaymentHTTP_RefundAmountInvalidReconciliation locks the // REFUND_AMOUNT_INVALID reconciliation end-to-end: Square returns that code BOTH // for a genuinely invalid refund amount AND for an already-refunded payment, so // refundPaymentHTTP re-checks the refund list (GET /v2/refunds) before // classifying — an existing COMPLETED refund → ErrRefundAlreadyProcessed (money // already moved), an empty list → ErrRefundDeclined (mark failed, never retry). func TestRefundPaymentHTTP_RefundAmountInvalidReconciliation(t *testing.T) { cases := []struct { name string refundList string wantErrIs error }{ {name: "existing_exact_amount_completed_refund_reconciles_to_already_processed", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed}, {name: "partial_refund_does_not_cover_requested_amount_reconciles_to_declined", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundDeclined}, {name: "no_refunds_reconciles_to_declined", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var reconcileGETs int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == http.MethodGet { // The PaymentWasRefunded reconciliation call must hit // GET /v2/refunds for the payment. reconcileGETs++ if r.URL.Path != "/v2/refunds" { t.Errorf("expected reconciliation GET /v2/refunds, got %s", r.URL.Path) } if !strings.Contains(r.URL.RawQuery, "limit=100") { t.Errorf("expected limit=100 in reconciliation query, got %q", r.URL.RawQuery) } if strings.Contains(r.URL.RawQuery, "begin_time=") { // The reconciliation passes a ZERO begin_time to mean // "all refunds"; the param must be ABSENT (FIX 6). t.Errorf("expected NO begin_time in the zero-time reconciliation query, got %q", r.URL.RawQuery) } _, _ = w.Write([]byte(tc.refundList)) return } w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"REFUND_AMOUNT_INVALID","detail":"The refunded amount is more than the remaining balance"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{ PaymentID: "pay_rec", Amount: 5000, IdempotencyKey: "ik-rec", }, hc) if err == nil { t.Fatal("expected REFUND_AMOUNT_INVALID rejection error") } if reconcileGETs == 0 { t.Error("expected the client to reconcile against GET /v2/refunds before classifying") } if !errors.Is(err, tc.wantErrIs) { t.Errorf("expected errors.Is(%v), got %v", tc.wantErrIs, err) } }) } } // TestSCACodes_ClassifyAsDefinitivePaymentErrors locks the SCA / buyer-verification // classification: the seven Square verification codes all mean the buyer must // re-verify (3DS/SCA) or re-tokenize the card, NOT that the same request should // be retried — so each must classify as a definitive payment error via // IsDefinitivePaymentError and surface its code through ErrorCode. func TestSCACodes_ClassifyAsDefinitivePaymentErrors(t *testing.T) { scaCodes := []string{ "CARD_DECLINED_VERIFICATION_REQUIRED", "VERIFICATION_TOKEN_EXPIRED", "VERIFICATION_TOKEN_INVALID", "CVV_VERIFICATION_REQUIRED", "ADDRESS_VERIFICATION_REQUIRED", "MISSING_PIN", "MISSING_VERIFICATION_TOKEN", } for _, code := range scaCodes { t.Run(code, func(t *testing.T) { err := &squareAPIError{ Code: code, Category: "PAYMENT_METHOD_ERROR", StatusCode: http.StatusBadRequest, err: errors.New("square: " + code), } if !IsDefinitivePaymentError(err) { t.Errorf("IsDefinitivePaymentError(%s) must be true — SCA codes are definitive", code) } if got := ErrorCode(err); got != code { t.Errorf("expected ErrorCode %s, got %q", code, got) } // Handlers wrap the client error before classifying — the accessor // must see through the wrap. if !IsDefinitivePaymentError(fmt.Errorf("wrap: %w", err)) { t.Errorf("IsDefinitivePaymentError must work through a wrapped error for %s", code) } }) } } // TestInvalidRequestError_IsCategoryNotCode locks the category/code distinction: // INVALID_REQUEST_ERROR is a Square error CATEGORY, never an error CODE — so // ErrorCategory surfaces it while ErrorCode must NOT (ErrorCode returns "" for // a code-less squareAPIError), and it must never classify as definitive. func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) { err := &squareAPIError{ Category: "INVALID_REQUEST_ERROR", StatusCode: http.StatusBadRequest, err: errors.New("square: invalid request"), } if got := ErrorCategory(err); got != "INVALID_REQUEST_ERROR" { t.Errorf("expected ErrorCategory INVALID_REQUEST_ERROR, got %q", got) } if got := ErrorCode(err); got != "" { t.Errorf("expected ErrorCode \"\" for a category-only error, got %q", got) } if IsDefinitivePaymentError(err) { t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code") } } // TestCreatePaymentHTTP_SCASavedCard_WireShape locks the CURRENT saved-card SCA // wire contract on the real client: a card-on-file charge sends source_id = the // card.tokenize(verificationDetails, cardId) tokenize-result (a cnon:-style // one-time token) plus customer_id from the saved card — Square requires // customer_id for a card-on-file source — and emits NO legacy verification_token // (the token IS the buyer verification). The dev mock's saved-card gate accepts // this exact shape, and the byte-identity contract test in square_dev_test.go // pins the mock's wire body identical to this client's. func TestCreatePaymentHTTP_SCASavedCard_WireShape(t *testing.T) { var captured map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Errorf("failed to decode request body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"payment":{"id":"pay_sca","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"ON_FILE"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", locationID: "loc", http: srv.Client()} _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-tokenize-result", CustomerID: "cus_sca_123", IdempotencyKey: "sca-wire-shape-key", ReferenceID: "booking-sca-1", Note: "full", LocationID: "loc", }, hc) if err != nil { t.Fatalf("createPaymentHTTP failed: %v", err) } if captured["source_id"] != "cnon:sca-tokenize-result" { t.Errorf("expected source_id = the tokenize-result token, got %v", captured["source_id"]) } if captured["customer_id"] != "cus_sca_123" { t.Errorf("expected customer_id from the saved card, got %v", captured["customer_id"]) } if _, present := captured["verification_token"]; present { t.Errorf("the SCA tokenize-result path must NOT emit a legacy verification_token, got %v", captured["verification_token"]) } amt, ok := captured["amount_money"].(map[string]any) if !ok { t.Fatalf("expected amount_money object, got %v", captured["amount_money"]) } if amt["amount"] != float64(5000) || amt["currency"] != "GBP" { t.Errorf("expected amount_money {5000 GBP} in integer pence, got %v", amt) } if captured["idempotency_key"] != "sca-wire-shape-key" { t.Errorf("expected idempotency_key, got %v", captured["idempotency_key"]) } if captured["reference_id"] != "booking-sca-1" { t.Errorf("expected reference_id, got %v", captured["reference_id"]) } if captured["location_id"] != "loc" { t.Errorf("expected location_id, got %v", captured["location_id"]) } } // TestNewHTTPClient_BaseURLSelection locks the SQUARE_ENVIRONMENT → base URL // resolution (FIX 3b): an empty SQUARE_ENVIRONMENT is production-ENFORCED // (mirroring payments.IsExplicitDevOrMockEnv's fail-closed interpretation — // an unset env var must never downgrade to the sandbox), so empty resolves to // the production base URL, exactly like "production". Only an explicit // "sandbox" or a dev/mock value resolves to the sandbox base URL. func TestNewHTTPClient_BaseURLSelection(t *testing.T) { cases := []struct { name string env string want string }{ {name: "production_env", env: "production", want: squareProductionURL}, {name: "empty_env_is_production_enforced", env: "", want: squareProductionURL}, {name: "sandbox_env", env: "sandbox", want: squareSandboxURL}, {name: "mock_env", env: "mock", want: squareSandboxURL}, {name: "dev_env", env: "dev", want: squareSandboxURL}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Setenv("SQUARE_ENVIRONMENT", tc.env) if got := newHTTPClient().baseURL; got != tc.want { t.Errorf("SQUARE_ENVIRONMENT=%q: expected base URL %s, got %s", tc.env, tc.want, got) } }) } } // TestValidateCredentials_FatalBranch_Exits covers the fail-closed startup // validation of the REAL Square API credentials (FIX 3a): when the // production-enforced gate in main.go (non-dev/mock SQUARE_ENVIRONMENT) calls // ValidateCredentials and SQUARE_ACCESS_TOKEN or SQUARE_LOCATION_ID is // missing, the process must refuse to start (log.Fatalf → os.Exit). log.Fatalf // cannot run in-process, so the test re-executes the test binary with marker // env vars and asserts the subprocess exits non-zero with a FATAL message // naming the missing credential. func TestValidateCredentials_FatalBranch_Exits(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") == "1" { t.Setenv("SQUARE_ENVIRONMENT", "production") switch os.Getenv("SQUARE_CRED_BRANCH") { case "MISSING_TOKEN": t.Setenv("SQUARE_ACCESS_TOKEN", "") t.Setenv("SQUARE_LOCATION_ID", "L_TEST") case "MISSING_LOCATION": t.Setenv("SQUARE_ACCESS_TOKEN", "tok") t.Setenv("SQUARE_LOCATION_ID", "") } ValidateCredentials() return } for _, tc := range []struct { name string branch string wantIn string }{ {name: "missing_token_exits", branch: "MISSING_TOKEN", wantIn: "SQUARE_ACCESS_TOKEN"}, {name: "missing_location_exits", branch: "MISSING_LOCATION", wantIn: "SQUARE_LOCATION_ID"}, } { t.Run(tc.name, func(t *testing.T) { cmd := exec.Command(os.Args[0], "-test.run=TestValidateCredentials_FatalBranch_Exits") cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "SQUARE_CRED_BRANCH="+tc.branch) out, err := cmd.CombinedOutput() require.Error(t, err, "expected the branch to exit (log.Fatalf); output: %s", out) require.Contains(t, string(out), tc.wantIn, "the fatal log must name the missing credential: %s", out) require.False(t, strings.Contains(string(out), "unexpected argument"), "the helper must not fail on argument parsing: %s", out) }) } }