Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors, nitpicks), then close the post-implementation re-review items, then align card-form typography and roll out the Square trust badge. Backend - Square API alignment: - tip_settings.allow_tipping nested under device_options (was top-level: terminal tips were silently lost in prod) - CreateCardOnFile now accepts customerID and sends card.customer_id; saved-card (ccof:) charges forward square_customer_id as CustomerID - New SquareClient methods GetPayment, CreateCustomer, CancelCheckout - SCA verification_token accepted + forwarded in all charge paths - ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/ ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts emails, ForceRefundPending hook Backend - money safety: - sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id instead of stranding them forever - SweepStalePendingPayments reconciles at Square before failing (tri-state: leave pending on transport error, rescue completed, fail definitively) - GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution; SweepStaleTerminalCheckouts covers terminal_checkouts table - till gift-card clawback on definitive failure incl. retry path + INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT - cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id)) - customer provisioning (lazy, save-only); one-off/guest mint no customer - discount preview/apply unified in discounts.go (global-milestone visible in preview, N+1 eliminated, redemption counter preserved on failures) - webhook event_id dedup; refund loop dedup; stale comment fixes - test-isolation t.Cleanup on committed sweep tests Frontend: - SCA tokenizeWithVerification across all charge flows (amount as major-units decimal), 5-min token-expiry re-tokenize, verification_token in request bodies - PaymentModal synchronous double-click + zero/negative-amount guards - till online-card UI wired to /api/admin/till/sale - policyPopover generalised; new /privacy-policy route; consent checkbox copy + Square privacy link - Square card iframe styled to app typography (Inter 14px, oklch tokens); mock form md:text-sm parity - 'Secure payment powered by Square' badge on all 8 card-payment flows Schema/docs: terminal_checkouts + square_customer_id + per-user card constraint in init-script.sql; README migrations; P14 plan + backlog + Technical Manual updated. Includes 39 modified/new test files; full backend suite (25 pkgs), -race on payments+square, and frontend build are green.
This commit is contained in:
@@ -506,23 +506,29 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page_guard_triggers_after_20_pages", func(t *testing.T) {
|
||||
t.Run("page_guard_returns_partial_results", func(t *testing.T) {
|
||||
// The 20-page guard must not discard what was already collected: it
|
||||
// logs a truncation warning and returns the partial results instead
|
||||
// of failing the reconcile with an error.
|
||||
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"}`))
|
||||
_, _ = 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()}
|
||||
_, 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)
|
||||
refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc)
|
||||
if err != nil {
|
||||
t.Fatalf("expected partial results (nil error), got %v", err)
|
||||
}
|
||||
if calls != 20 {
|
||||
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
|
||||
}
|
||||
if len(refunds) != 20 {
|
||||
t.Errorf("expected 20 refunds collected across pages (one per page), got %d", len(refunds))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -548,7 +554,7 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
||||
res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", hc)
|
||||
res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
||||
}
|
||||
@@ -570,14 +576,14 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected card object, got %v", captured["card"])
|
||||
}
|
||||
// The local user ID goes in reference_id (free-form), NOT customer_id —
|
||||
// the app has no Square customer provisioning, and customer_id would be
|
||||
// rejected by the real Cards API (P1 regression guard).
|
||||
// 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 (local IDs must not go in customer_id), got %v", card["customer_id"])
|
||||
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)
|
||||
@@ -587,6 +593,48 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -630,3 +678,378 @@ func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) {
|
||||
t.Errorf("unexpected cards: %+v %+v", cards[0], cards[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user