//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":"","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 := 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"]) } // 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). 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"]) } 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) } } // 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]) } }