Files
Crussell/backend/internal/square/square_http_client_test.go
T
popertots 53ca89603d Fix payment review round: till integrity, HTTP client tests, concurrency tests, card-selection consolidation
Addresses the payment review (all 10 blocking + 2 minor findings):

Till money-integrity (CreateTillSale):
- Add pg_advisory_lock on the idempotency key (concurrent same-key double-funding race)
- Guard amount on pending-reuse retry (mirrors tip/gift-card guards)
- Explicitly complete the row for cash/on_the_house pending-reuse
- Reject method-switch on a live card-machine checkout (double-charge guard)
- 3 regression tests (amount-mismatch, cash-completes-row, method-switch)

BookingFlow:
- Fetch saved cards at the deposit step (was dead code)
- Charge the server-computed deposit_amount, not the client estimate

HTTP client tests (was untested): doJSON error parsing, refund sentinel
classification, payment/refund/card wire shapes, checkout polling states,
list-refunds pagination + 20-page guard, sha256 card idempotency key

Concurrency regression tests: real two-goroutine races for BuyGiftCard,
tip, and booking-payment locks asserting exactly-one record each

Frontend:
- Fix CRIT-1: zero-saved-card users blocked (all flows now handle it)
- Consolidate tip/deposit/Buy-Gift-Card card UI onto CardSelection
- Explicit save-card consent checkbox (was silent/inconsistent)
- Fix stale saved-card field names in BookingFlow (last4 -> last_4)
- Unique instance ids (crypto.randomUUID) in CardSelection/SquareCardInput
- UserPaymentModal: keep card form mounted on error + Try Again button

Health/docs: /api/health reports square state (mock/ok, was not_implemented),
close P1 backlog, correct stale webhook and env-var claims
2026-08-22 00:34:49 +01:00

575 lines
24 KiB
Go

//go:build test
package square
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
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",
},
},
}
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)
}
}
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)
}
}
// 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)
}
})
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 map to ErrRefundDeclined, PAYMENT_ALREADY_REFUNDED maps to
// ErrRefundAlreadyProcessed, and ambiguous errors pass through unwrapped.
func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
cases := []struct {
name string
code string
wantErrIs error // nil = no sentinel expected
wantErrNil bool
}{
{name: "refund_declined", code: "REFUND_DECLINED", wantErrIs: ErrRefundDeclined},
{name: "amount_exceeded", code: "PAYMENT_REFUND_AMOUNT_EXCEEDED", wantErrIs: ErrRefundDeclined},
{name: "invalid_payment_id", code: "INVALID_PAYMENT_ID", wantErrIs: ErrRefundDeclined},
{name: "already_refunded", code: "PAYMENT_ALREADY_REFUNDED", 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")
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_triggers_after_20_pages", func(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(`{"refunds":[],"cursor":"next"}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, err := listRefundsHTTPWithClient(context.Background(), "pay_x", time.Now(), hc)
if err == nil || !strings.Contains(err.Error(), "exceeded 20 pages") {
t.Fatalf("expected 20-page guard error, got %v", err)
}
if calls != 20 {
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
}
})
}
// 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":"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 := fmt.Sprintf("create-card-%x", sum)
if captured["idempotency_key"] != wantIK {
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
}
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"])
}
if card["customer_id"] != "user_1" {
t.Errorf("expected card.customer_id user_1, 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)
}
}