The CURRENT saved-card SCA contract (Square card.tokenize(verificationDetails, cardId)) returns a one-time tokenize-result that must be sent as the charge SOURCE (source_id), not a separate verification_token. - square_dev.go: the mock validates the WIRE BODY (mockPaymentWireBody — an independently assembled copy of buildCreatePaymentBody) so it accepts exactly the request shape the real client emits. SimulateSavedCardVerificationRequired now demands SCA on every saved-card charge in both wire shapes: (a) a genuine tokenize-result (cnon:sca-... — isSCATokenizeResultSource) as source_id + customer_id is ACCEPTED (the token IS the buyer verification); a RAW card.tokenize() nonce in the tokenize-result slot is REJECTED CARD_DECLINED_VERIFICATION_REQUIRED (money-F2 — the mock is the enforcement point that stops the forged shape); (b) legacy ccof: + verification_token is kept for backward-compat. - square_http_client.go: byte-identical body assembly shared with the mock, so TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical pins the mock and the real client emit identical CreatePayment bodies (a wire drift fails the test before reaching prod).
2093 lines
91 KiB
Go
2093 lines
91 KiB
Go
//go:build test
|
|
|
|
package square
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// 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))
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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=") {
|
|
t.Errorf("expected begin_time in 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, "begin_time=") {
|
|
t.Errorf("expected begin_time in 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"])
|
|
}
|
|
}
|