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
This commit is contained in:
@@ -279,7 +279,10 @@ type sqDisableCardResponse struct {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
hc := newHTTPClient()
|
||||
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
||||
}
|
||||
|
||||
func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *httpClient) (*PaymentResult, error) {
|
||||
body := sqCreatePaymentRequest{
|
||||
SourceID: req.SourceID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
@@ -327,7 +330,10 @@ func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc
|
||||
}
|
||||
|
||||
func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
hc := newHTTPClient()
|
||||
return getCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
|
||||
}
|
||||
|
||||
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
|
||||
var tcResp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
|
||||
return nil, err
|
||||
@@ -374,7 +380,10 @@ var definitiveRefundCodes = map[string]bool{
|
||||
}
|
||||
|
||||
func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
hc := newHTTPClient()
|
||||
return refundPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
||||
}
|
||||
|
||||
func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *httpClient) (*RefundResult, error) {
|
||||
body := sqRefundPaymentRequest{
|
||||
PaymentID: req.PaymentID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
@@ -396,7 +405,10 @@ func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult
|
||||
}
|
||||
|
||||
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
hc := newHTTPClient()
|
||||
return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient())
|
||||
}
|
||||
|
||||
func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime time.Time, hc *httpClient) ([]RefundResult, error) {
|
||||
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
|
||||
path := base
|
||||
results := []RefundResult{}
|
||||
@@ -420,7 +432,10 @@ func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time)
|
||||
}
|
||||
|
||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
hc := newHTTPClient()
|
||||
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, newHTTPClient())
|
||||
}
|
||||
|
||||
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken string, hc *httpClient) (*CardOnFile, error) {
|
||||
|
||||
// Deterministic idempotency key derived from user + card (not time-based)
|
||||
// so that retries with the same details don't create duplicate cards.
|
||||
|
||||
@@ -4,10 +4,15 @@ 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) {
|
||||
@@ -100,3 +105,470 @@ func TestCreateCheckoutHTTP_DeviceOptionsWireShape(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user