//go:build test && dev package square import ( "bytes" "context" "encoding/json" "errors" "fmt" "log" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // replaySnapshotReq marshals a CreatePaymentReq into the stored // square_request_snapshot shape (domain JSON) that ReplayPaymentByKey parses. func replaySnapshotReq(t *testing.T, req CreatePaymentReq) []byte { t.Helper() snap, err := json.Marshal(req) require.NoError(t, err) return snap } func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "test-key-1", ReferenceID: "booking-123", Note: "full", } result, err := client.CreatePayment(ctx, req) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status) assert.Equal(t, int64(5000), result.Amount) assert.Equal(t, "VISA", result.CardBrand) assert.Equal(t, "4242", result.CardLast4) assert.NotZero(t, result.Fees) // The mock must set SquarePayID == ID exactly like the real client // (paymentFromSquare: SquarePayID = sq.ID) — a fabricated "sqp_" ID would // make reconcile/sweep code that resolves a stored square_payment_id via // GetPayment behave differently in mock vs prod. assert.Equal(t, result.ID, result.SquarePayID, "mock SquarePayID must equal the payment ID") got, err := client.GetPayment(ctx, result.SquarePayID) require.NoError(t, err) assert.Equal(t, result.ID, got.ID, "GetPayment must resolve the payment via SquarePayID (reconcile path)") assert.NotEmpty(t, result.CardFingerprint) require.NotNil(t, result.ExpMonth) assert.Equal(t, 12, *result.ExpMonth) require.NotNil(t, result.ExpYear) assert.Equal(t, 2030, *result.ExpYear) assert.Equal(t, "KEYED", result.EntryMethod) assert.Equal(t, "CVV_ACCEPTED", result.CVVStatus) assert.Equal(t, "AVS_ACCEPTED", result.AVSStatus) assert.NotEmpty(t, result.ReceiptNumber) assert.NotEmpty(t, result.CreatedAt) assert.NotEmpty(t, result.LocationID) } func TestDevClient_CreatePayment_RejectsZeroOrNegativeAmount(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() req := CreatePaymentReq{ Amount: 0, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "test-key-zero-amount", ReferenceID: "booking-0", } // Real Square rejects a zero amount_money with 400 INVALID_REQUEST_ERROR — // the mock must mirror that so a £0 charge (e.g. a deposit fully covered by // a campaign discount) can never be masked in dev (finding 8). result, err := client.CreatePayment(ctx, req) require.Error(t, err, "a £0 payment must be rejected exactly like real Square") require.Nil(t, result, "a rejected payment must not be recorded") var apiErr *squareAPIError require.ErrorAs(t, err, &apiErr) assert.Equal(t, "INVALID_REQUEST_ERROR", apiErr.Category) assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode) // Negative amounts are equally invalid. req.Amount = -1 req.IdempotencyKey = "test-key-neg-amount" _, err = client.CreatePayment(ctx, req) require.Error(t, err, "a negative payment must be rejected") // The zero-amount key must NOT have been registered for dedup. client.mu.RLock() defer client.mu.RUnlock() assert.NotContains(t, client.paymentByKey, "test-key-zero-amount", "a rejected payment must not be registered under its idempotency key") } func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() req := CreateCheckoutReq{ Amount: 7500, Currency: "GBP", IdempotencyKey: "checkout-key-1", ReferenceID: "booking-456", AllowTipping: true, DeviceID: "dvc_test", } result, err := client.CreateCheckout(ctx, req) require.NoError(t, err) assert.Equal(t, "PENDING", result.Status) assert.NotEmpty(t, result.ID) assert.Equal(t, int64(7500), result.AmountMoney) assert.Equal(t, "GBP", result.Currency) assert.NotEmpty(t, result.CreatedAt) // Poll until the background goroutine completes using assert.Eventually var completed *PaymentResult assert.Eventually(t, func() bool { var getErr error completed, getErr = client.GetCheckout(ctx, result.ID) return getErr == nil && completed.Status == "COMPLETED" }, 5*time.Second, 100*time.Millisecond, "expected checkout to complete") assert.Equal(t, int64(7500), completed.Amount, "expected amount 7500 (real Square does not add a tip to the checkout amount)") assert.Equal(t, int64(0), completed.TipAmount) // The completed terminal payment's SquarePayID must equal its ID too // (parity with paymentFromSquare), so a terminal-checkout payment recorded // via SquarePayID reconciles identically in mock and prod. assert.Equal(t, completed.ID, completed.SquarePayID, "terminal payment SquarePayID must equal its ID") assert.NotEmpty(t, completed.CardFingerprint) assert.NotEmpty(t, completed.EntryMethod) } // TestDevClient_CreateCheckout_DeadlineDurationFormat verifies the mock emits // Square's deadline_duration wire format — an RFC 3339 duration ("PT5M"), NOT // an absolute RFC3339 timestamp — so dev parity matches the real API. func TestDevClient_CreateCheckout_DeadlineDurationFormat(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "checkout-deadline", ReferenceID: "deadline-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Equal(t, "PT5M", result.Deadline, "deadline_duration must be an RFC 3339 duration, not a timestamp") // Round-trip through checkoutFromSquare: the wire value is copied through // unchanged (it is not parsed/reformatted anywhere in the package). res := checkoutFromSquare(&sqTerminalCheckout{Deadline: result.Deadline}) assert.Equal(t, "PT5M", res.Deadline) } func TestDevClient_CreateCheckout_NoTip(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() req := CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "checkout-key-notip", ReferenceID: "booking-789", AllowTipping: false, DeviceID: "dvc_test", } result, err := client.CreateCheckout(ctx, req) require.NoError(t, err) assert.Equal(t, "PENDING", result.Status) assert.NotEmpty(t, result.ID) assert.Equal(t, int64(5000), result.AmountMoney) assert.Equal(t, "GBP", result.Currency) assert.NotEmpty(t, result.CreatedAt) var completed *PaymentResult assert.Eventually(t, func() bool { var getErr error completed, getErr = client.GetCheckout(ctx, result.ID) return getErr == nil && completed.Status == "COMPLETED" }, 5*time.Second, 100*time.Millisecond, "expected checkout to complete") assert.Equal(t, int64(5000), completed.Amount, "expected amount 5000 (no tip)") assert.Equal(t, int64(0), completed.TipAmount) assert.NotEmpty(t, completed.CardFingerprint) assert.NotEmpty(t, completed.EntryMethod) } func TestDevClient_RefundPayment_ReturnsCompleted(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() paymentReq := CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "payment-for-refund", ReferenceID: "booking-refund", Note: "full", } paymentResult, err := client.CreatePayment(ctx, paymentReq) require.NoError(t, err) refundReq := RefundPaymentReq{ PaymentID: paymentResult.ID, Amount: 5000, IdempotencyKey: "refund-key-1", Reason: "customer request", } refundResult, err := client.RefundPayment(ctx, refundReq) require.NoError(t, err) assert.Equal(t, "COMPLETED", refundResult.Status) assert.Equal(t, int64(5000), refundResult.Amount) assert.NotEmpty(t, refundResult.PaymentID) assert.Equal(t, "customer request", refundResult.Reason) assert.NotEmpty(t, refundResult.CreatedAt) } func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() userID := "user-test-123" card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "cus_test123") require.NoError(t, err) assert.NotEmpty(t, card.ID) assert.Equal(t, "VISA", card.Brand) assert.Equal(t, "4242", card.Last4) assert.True(t, card.IsDefault) assert.True(t, card.Enabled) assert.NotEmpty(t, card.CardholderName) assert.NotEmpty(t, card.CreatedAt) cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) require.Len(t, cards, 1) assert.Equal(t, card.ID, cards[0].ID) } func TestDevClient_CardOnFile_MultipleCards(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() userID := "user-test-multiple" card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1", "cus_test123") require.NoError(t, err) card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2", "cus_test123") require.NoError(t, err) assert.True(t, card1.Enabled) assert.True(t, card2.Enabled) assert.NotEmpty(t, card1.CreatedAt) assert.NotEmpty(t, card2.CreatedAt) cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) require.Len(t, cards, 2) assert.True(t, card1.IsDefault) assert.False(t, card2.IsDefault) } func TestDevClient_CardOnFile_Delete(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() userID := "user-test-delete" card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete", "cus_test123") require.NoError(t, err) err = client.DeleteCardOnFile(ctx, card.ID) require.NoError(t, err) // Square's List Cards API excludes disabled cards by default — a // disabled/deleted card is no longer returned by GetCardsOnFile. cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards") } func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() err := client.DeleteCardOnFile(ctx, "non-existent-card") require.Error(t, err) } func TestDevClient_GetCheckout_NotFound(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.GetCheckout(ctx, "non-existent-checkout") require.Error(t, err) } func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) { // PCI-DSS parity: CreateCardOnFile accepts only token-like source_ids // (cnon:xxx / ccof:xxx). Raw PANs are rejected exactly like real Square. client := NewDevClient().(*MockClient) ctx := context.Background() tests := []struct { name string cardNumber string }{ {"visa", "4111111111111111"}, {"mastercard", "5555555555554444"}, {"amex", "378282246310005"}, {"discover", "6011111111111117"}, {"too short", "123"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { card, err := client.CreateCardOnFile(ctx, "user-raw-"+tt.name, tt.cardNumber, "cus_test123") require.Error(t, err, "raw PAN must be rejected for production parity") assert.Nil(t, card) assert.Contains(t, err.Error(), "invalid source_id") }) } } // TestDevClient_CreateCardOnFile_RequiresCustomerID verifies the mock mirrors // Square's real enforcement: Square's POST /v2/cards rejects a card without // card.customer_id at runtime (confirmed by Square's own SDK maintainer). The // production client omits an empty customer_id via omitempty and every // production caller provisions a customer first, so the mock must reject it too // — the same structured 400 INVALID_REQUEST_ERROR as the ccof: CreatePayment // gate — so sandbox/dev tests exercise the same rejection as production. func TestDevClient_CreateCardOnFile_RequiresCustomerID(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.CreateCardOnFile(ctx, "user-no-customer", "cnon:test-token", "") require.Error(t, err, "card creation without customer_id must be rejected") assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err)) assert.Contains(t, ErrorDetail(err), "customer_id") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) card, err := client.CreateCardOnFile(ctx, "user-with-customer", "cnon:test-token", "cus_test123") require.NoError(t, err) assert.NotEmpty(t, card.ID) assert.Equal(t, "VISA", card.Brand) assert.Equal(t, "4242", card.Last4) } func TestCreatePayment_ShouldFail(t *testing.T) { client := NewDevClient().(*MockClient) client.ShouldFail = true ctx := context.Background() req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "test-key-fail", ReferenceID: "booking-fail", } result, err := client.CreatePayment(ctx, req) require.Error(t, err, "expected error when ShouldFail is true") assert.Nil(t, result) } func TestRefundPayment_ShouldFail(t *testing.T) { client := NewDevClient().(*MockClient) client.ShouldFail = true ctx := context.Background() req := RefundPaymentReq{ PaymentID: "pay_mock_fail", Amount: 5000, IdempotencyKey: "refund-key-fail", Reason: "simulated failure", } result, err := client.RefundPayment(ctx, req) require.Error(t, err, "expected error when ShouldFail is true") assert.Nil(t, result) } func TestDevClient_RefundPayment_PaymentAlreadyRefunded(t *testing.T) { // PAYMENT_ALREADY_REFUNDED means the money already moved at Square, so the // mock must return ErrRefundAlreadyProcessed (never ErrRefundDeclined) and // must not store a refund — the caller resolves the record to 'completed'. client := NewDevClient().(*MockClient) client.FailRefundCode = "PAYMENT_ALREADY_REFUNDED" ctx := context.Background() req := RefundPaymentReq{ PaymentID: "pay_mock_already_refunded", Amount: 5000, IdempotencyKey: "refund-key-already", Reason: "already refunded", } result, err := client.RefundPayment(ctx, req) require.Error(t, err) assert.Nil(t, result) assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "expected ErrRefundAlreadyProcessed, got: %v", err) assert.False(t, errors.Is(err, ErrRefundDeclined), "already-processed refund must not be classified as declined: %v", err) client.mu.RLock() defer client.mu.RUnlock() assert.Len(t, client.refunds, 0, "no refund must be stored when the payment is already refunded") assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when the payment is already refunded") } func TestDevClient_RefundPayment_RefundAlreadyPending(t *testing.T) { // REFUND_ALREADY_PENDING is Square's REAL money-in-flight code (a refund // for this payment is already pending at Square). The real client maps it // to ErrRefundAlreadyProcessed (square_http_client.go:687); the mock must // classify it identically so the concurrent-refund dedup path — where the // caller resolves the row to 'completed' instead of retrying — is // exercisable in dev. client := NewDevClient().(*MockClient) client.FailRefundCode = "REFUND_ALREADY_PENDING" ctx := context.Background() req := RefundPaymentReq{ PaymentID: "pay_mock_already_pending", Amount: 5000, IdempotencyKey: "refund-key-already-pending", Reason: "already pending", } result, err := client.RefundPayment(ctx, req) require.Error(t, err) assert.Nil(t, result) assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "expected ErrRefundAlreadyProcessed, got: %v", err) assert.False(t, errors.Is(err, ErrRefundDeclined), "already-pending refund must not be classified as declined: %v", err) client.mu.RLock() defer client.mu.RUnlock() assert.Len(t, client.refunds, 0, "no refund must be stored when a refund is already pending") assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when a refund is already pending") } func TestDevClient_PaymentWasRefunded_StatusSet(t *testing.T) { // Locks the mock's PaymentWasRefunded status set against the real client's // reconciliation source (COMPLETED/APPROVED/PENDING → true; FAILED/REJECTED // → false): handler-side REFUND_AMOUNT_INVALID reconciliation must behave // identically in dev/mock and production. client := NewDevClient().(*MockClient) ctx := context.Background() now := time.Now().UTC() for _, tc := range []struct { status string want bool }{ {"COMPLETED", true}, {"APPROVED", true}, {"PENDING", true}, {"FAILED", false}, {"REJECTED", false}, } { client.mu.Lock() id := fmt.Sprintf("ref_mock_%d", now.UnixNano()) client.refunds[id] = &RefundResult{ ID: id, Status: tc.status, Amount: 5000, PaymentID: "pay_mock_was_refunded", CreatedAt: now.Format(time.RFC3339), } client.mu.Unlock() got, err := client.PaymentWasRefunded(ctx, "pay_mock_was_refunded") require.NoError(t, err) assert.Equal(t, tc.want, got, "status %s", tc.status) } // A different payment with no refunds reports false. got, err := client.PaymentWasRefunded(ctx, "pay_mock_no_refunds") require.NoError(t, err) assert.False(t, got) } func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) { // Any other code configured via FailRefundCode preserves the prior // ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod). client := NewDevClient().(*MockClient) client.FailRefundCode = "REFUND_DECLINED" ctx := context.Background() req := RefundPaymentReq{ PaymentID: "pay_mock_refund_declined", Amount: 5000, IdempotencyKey: "refund-key-declined", Reason: "declined", } result, err := client.RefundPayment(ctx, req) require.Error(t, err) assert.Nil(t, result) assert.True(t, errors.Is(err, ErrRefundDeclined), "expected ErrRefundDeclined, got: %v", err) assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed), "declined refund must not be classified as already processed: %v", err) } func TestDevClient_ConcurrentPayments(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() var wg sync.WaitGroup results := make(chan *PaymentResult, 10) errors := make(chan error, 10) for i := 0; i < 10; i++ { wg.Add(1) go func(idx int) { defer wg.Done() req := CreatePaymentReq{ Amount: int64(1000 + idx*100), Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: fmt.Sprintf("concurrent-key-%d", idx), ReferenceID: "booking-concurrent", Note: "full", } payResult, payErr := client.CreatePayment(ctx, req) if payErr != nil { errors <- payErr return } results <- payResult }(i) } wg.Wait() close(results) close(errors) errorCount := 0 for range errors { errorCount++ } assert.Zero(t, errorCount, "expected no concurrent errors") resultCount := 0 for payResult := range results { assert.Equal(t, "COMPLETED", payResult.Status) assert.NotEmpty(t, payResult.CreatedAt) resultCount++ } assert.Equal(t, 10, resultCount) } func TestDevClient_CreatePayment_WithTipMoney(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() tip := int64(1000) req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "test-key-tip", ReferenceID: "booking-tip", Note: "full", TipMoney: &tip, } result, err := client.CreatePayment(ctx, req) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status) assert.Equal(t, int64(6000), result.Amount) assert.Equal(t, int64(1000), result.TipAmount) } func TestDevClient_CreatePayment_DedupsOnIdempotencyKey(t *testing.T) { // Real Square dedups on idempotency key: a same-key retry returns the // original payment. The mock must mirror this or dev/testing diverges // from production (and the pending-retry logic can't be exercised). client := NewDevClient().(*MockClient) ctx := context.Background() req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "dedup-key-1", ReferenceID: "booking-dedup", } first, err := client.CreatePayment(ctx, req) require.NoError(t, err) require.NotEmpty(t, first.ID) second, err := client.CreatePayment(ctx, req) require.NoError(t, err) assert.Equal(t, first.ID, second.ID, "same-key retry must return the original payment, not a new one") // Total stored payments for this key must be one (deduped). client.mu.RLock() byKey := client.paymentByKey["dedup-key-1"] client.mu.RUnlock() assert.NotNil(t, byKey) assert.Equal(t, first.ID, byKey.ID) } // TestDevClient_CreatePayment_DedupSourceAware locks the body-aware dedup // parity fix: real Square compares the WHOLE request body on an // idempotency-key hit, so a same-key retry with a DIFFERENT source must return // IDEMPOTENCY_KEY_REUSED (never the original payment) — exactly like // ReplayPaymentByKey and the real API. Only an IDENTICAL body (matching // source) returns the original payment. Before this fix the mock was // body-blind and the gift-card same-key retry (which refreshes square_source_id // with a fresh cnon on pending-reuse) succeeded in dev where prod returns // IDEMPOTENCY_KEY_REUSED and leaves the row pending for the sweep. func TestDevClient_CreatePayment_DedupSourceAware(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() const key = "dedup-source-aware-key" first, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:original-source", IdempotencyKey: key, }) require.NoError(t, err) // Same key + SAME source → the original payment (Square's idempotency // guarantee), never a second charge. same, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:original-source", IdempotencyKey: key, }) require.NoError(t, err) assert.Equal(t, first.ID, same.ID, "same-key + same-source retry must return the original payment") // Same key + DIFFERENT source (the gift-card pending-reuse refresh) → // Square's structured IDEMPOTENCY_KEY_REUSED rejection. _, err = client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:fresh-refreshed-nonce", IdempotencyKey: key, }) require.Error(t, err) assert.Equal(t, "IDEMPOTENCY_KEY_REUSED", ErrorCode(err), "same-key different-source retry must carry IDEMPOTENCY_KEY_REUSED") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "IDEMPOTENCY_KEY_REUSED is NOT proof the charge never happened") // The original payment must still be returned for the IDENTICAL body. got, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:original-source", IdempotencyKey: key, }) require.NoError(t, err) assert.Equal(t, first.ID, got.ID, "the identical-body retry must keep returning the original payment after a rejected mismatch") } func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) { // Real Square dedups on idempotency key: a same-key retry returns the // original refund. The mock must mirror this or the pending-refund resume // path can't be exercised (and a retry could double-refund the customer). client := NewDevClient().(*MockClient) ctx := context.Background() paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "payment-for-refund-dedup", ReferenceID: "booking-refund-dedup", }) require.NoError(t, err) req := RefundPaymentReq{ PaymentID: paymentResult.ID, Amount: 5000, IdempotencyKey: "refund-dedup-key-1", Reason: "customer request", } first, err := client.RefundPayment(ctx, req) require.NoError(t, err) require.NotEmpty(t, first.ID) second, err := client.RefundPayment(ctx, req) require.NoError(t, err) assert.Equal(t, first.ID, second.ID, "same-key retry must return the original refund, not a new one") // Only one refund stored in the mock's refunds map (deduped). client.mu.RLock() defer client.mu.RUnlock() assert.Len(t, client.refunds, 1, "same-key retry must not store a second refund") byKey := client.refundByKey["refund-dedup-key-1"] assert.NotNil(t, byKey) assert.Equal(t, first.ID, byKey.ID) } func TestDevClient_CreatePayment_AutocompleteFalse(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() autocomplete := false req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "test-key-autocomplete", ReferenceID: "booking-autocomplete", Autocomplete: &autocomplete, } result, err := client.CreatePayment(ctx, req) require.NoError(t, err) assert.Equal(t, "APPROVED", result.Status) assert.Equal(t, int64(5000), result.Amount) } func TestDevClient_CreatePayment_WithBuyerEmail(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "test-key-email", ReferenceID: "booking-email", BuyerEmail: "test@example.com", } result, err := client.CreatePayment(ctx, req) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status) assert.Equal(t, "test@example.com", result.BuyerEmail) } func TestDevClient_CreateCardOnFile_WithNewFields(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() userID := "user-new-fields" card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "cus_test123") require.NoError(t, err) assert.True(t, card.Enabled) assert.NotEmpty(t, card.CardholderName) // Local linkage goes in reference_id; the mock does not store the // customer_id (prod sends it on card creation when the app has provisioned // a Square customer for the user). assert.Equal(t, userID, card.ReferenceID) assert.Empty(t, card.CustomerID) assert.Greater(t, card.Version, int64(0)) assert.NotEmpty(t, card.CreatedAt) } func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() userID := "user-soft-delete" card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft", "cus_test123") require.NoError(t, err) err = client.DeleteCardOnFile(ctx, card.ID) require.NoError(t, err) // Square's List Cards API excludes disabled cards by default — the // disabled card is no longer returned by GetCardsOnFile. cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards") } func TestDevClient_GetCardsOnFile_Empty(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() cards, err := client.GetCardsOnFile(ctx, "user-no-cards") require.NoError(t, err) assert.Empty(t, cards) } func TestDevClient_ReplayPaymentByKey_MatchingSource_ReturnsOriginal(t *testing.T) { // Identical-body replay contract: a retained key with the MATCHING stored // source returns the ORIGINAL payment (Square's idempotency guarantee) — // never a second charge and never IDEMPOTENCY_KEY_REUSED. client := NewDevClient().(*MockClient) ctx := context.Background() orig, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "replay-match-key", }) require.NoError(t, err) got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "replay-match-key", })) require.NoError(t, err) assert.Equal(t, orig.ID, got.ID, "identical-body replay must return the original payment") } func TestDevClient_ReplayPaymentByKey_SourceMismatch_ReturnsKeyReused(t *testing.T) { // Identical-body replay contract: reusing a retained key with a DIFFERENT // source is Square's documented IDEMPOTENCY_KEY_REUSED rejection — a data // bug, NOT proof the charge never happened. The mock must carry the // structured code so ErrorCode(err) can read it (the sweep treats it as // ambiguous). client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "replay-mismatch-key", }) require.NoError(t, err) _, err = client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:different", IdempotencyKey: "replay-mismatch-key", })) require.Error(t, err) assert.Equal(t, "IDEMPOTENCY_KEY_REUSED", ErrorCode(err), "source-mismatch replay must carry IDEMPOTENCY_KEY_REUSED") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "IDEMPOTENCY_KEY_REUSED is NOT proof the charge never happened") } func TestDevClient_ReplayPaymentByKey_UnknownKey_NotRetained(t *testing.T) { // Identical-body replay contract: an unknown key makes Square attempt a // real charge with the (expired/used) cnon: nonce, which is rejected with a // 4xx — surfaced as ErrReplayKeyNotRetained (proof the charge never // happened). client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "key-never-seen", })) require.Error(t, err) assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "unknown-key cnon replay must surface ErrReplayKeyNotRetained, got %v", err) } func TestDevClient_ReplayPaymentByKey_CorruptSnapshot_PlainError(t *testing.T) { // Identical-body replay contract (FIX 4): a snapshot missing source_id or // idempotency_key is CORRUPT. Prod (replayPaymentByKeyHTTPWithClient, // square_http_client.go) returns a plain (ambiguous) error for such a // snapshot — the sweep leaves the row PENDING for manual reconciliation. // The mock must mirror that, NOT answer ErrReplayKeyNotRetained (which the // sweep treats as "the charge provably never happened" and definitively // fails the row) — that is the OPPOSITE money decision on a snapshot we // cannot trust. client := NewDevClient().(*MockClient) ctx := context.Background() t.Run("empty_source_is_plain_error", func(t *testing.T) { _, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "", IdempotencyKey: "key-empty-source", })) require.Error(t, err) assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "a corrupt snapshot must NOT be treated as proof the charge never happened, got %v", err) assert.Contains(t, err.Error(), "missing source_id/idempotency_key") assert.Equal(t, "", ErrorCode(err), "a corrupt snapshot is an ambiguous plain error, not a structured Square rejection") }) t.Run("empty_key_is_plain_error", func(t *testing.T) { _, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "", })) require.Error(t, err) assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "a corrupt snapshot must NOT be treated as proof the charge never happened, got %v", err) assert.Contains(t, err.Error(), "missing source_id/idempotency_key") }) t.Run("empty_source_must_not_charge", func(t *testing.T) { // Regression guard for the OLD mock behaviour: with a corrupt snapshot // (empty source) the mock must not fall through to the unknown-key // "attempt a real charge" path at all — a ccof-ish empty source must // never mint a payment. client.mu.RLock() payCount := len(client.payments) client.mu.RUnlock() _, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "", IdempotencyKey: "key-empty-source-2", })) require.Error(t, err) client.mu.RLock() defer client.mu.RUnlock() assert.Equal(t, payCount, len(client.payments), "a corrupt snapshot must never be replayed into a charge") }) } func TestDevClient_ReplayPaymentByKey_UnknownKey_CcofSavedCard_ChargesAndRescues(t *testing.T) { // B3 dev/prod parity: an unknown key with a STILL-VALID ccof: saved-card // token makes real Square attempt a REAL charge that succeeds — the sweep // must RESCUE such rows, never fail them. The mock mirrors this by looking // up the saved card and creating a new COMPLETED payment under the key. client := NewDevClient().(*MockClient) ctx := context.Background() card, err := client.CreateCardOnFile(ctx, "user-replay-rescue", "cnon:test-token", "cus_replay123") require.NoError(t, err) snapshot := replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_replay123", IdempotencyKey: "key-never-seen-ccof", }) got, err := client.ReplayPaymentByKey(ctx, snapshot) require.NoError(t, err) assert.Equal(t, "COMPLETED", got.Status, "a still-valid ccof: source must charge successfully on an unknown key") assert.Equal(t, int64(5000), got.Amount) assert.Equal(t, "ON_FILE", got.EntryMethod) // The charge must be recorded under the key so a later identical replay // returns the SAME payment (Square's dedup) instead of charging twice. got2, err := client.ReplayPaymentByKey(ctx, snapshot) require.NoError(t, err) assert.Equal(t, got.ID, got2.ID, "a replayed ccof: charge under the same key must dedup, never charge twice") } func TestDevClient_ReplayPaymentByKey_UnknownKey_UnregisteredCcof_NotRetained(t *testing.T) { // A ccof: token that is NOT in the saved-card ledger mirrors real Square // rejecting a deleted/disabled card with a definitive 4xx — the charge // never happened. client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "ccof:never-registered", CustomerID: "cus_replay123", IdempotencyKey: "key-never-seen-ccof-deleted", })) require.Error(t, err) assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "an unregistered ccof: token must surface ErrReplayKeyNotRetained, got %v", err) } func TestDevClient_ReplayPaymentByKey_DedupSourceTracked(t *testing.T) { // The mock must record the source used by each CreatePayment so a later // identical-body replay can verify the source matches (the "works in dev == // works in prod" guarantee for the reconcile sweep). client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:dedup-src", IdempotencyKey: "replay-dedup-key", }) require.NoError(t, err) client.mu.RLock() stored := client.paymentSource["replay-dedup-key"] client.mu.RUnlock() assert.Equal(t, "cnon:dedup-src", stored, "the source of each keyed payment must be stored for replay parity") got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:dedup-src", IdempotencyKey: "replay-dedup-key", })) require.NoError(t, err) assert.NotEmpty(t, got.ID) } func TestDevClient_GetCheckout_StillPending(t *testing.T) { client := NewDevClient().(*MockClient) client.HoldCheckouts = true ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "pending-checkout", ReferenceID: "pending-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Equal(t, "PENDING", result.Status) _, err = client.GetCheckout(ctx, result.ID) require.Error(t, err) assert.Contains(t, err.Error(), "pending") } func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) { client := NewDevClient().(*MockClient) client.HoldCheckouts = true ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 2500, Currency: "GBP", IdempotencyKey: "hold-checkout", ReferenceID: "hold-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Equal(t, "PENDING", result.Status) _, err = client.GetCheckout(ctx, result.ID) require.Error(t, err) } func TestDevClient_GetCheckout_ForceInProgress(t *testing.T) { // IN_PROGRESS is a real Square terminal state (the customer is tapping the // card). The mock must hold it — never auto-complete — so the sweep's // isTerminalCheckoutError intermediate-state path is exercisable in dev. client := NewDevClient().(*MockClient) client.ForceCheckoutState = "IN_PROGRESS" ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "in-progress-checkout", ReferenceID: "in-progress-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Equal(t, "IN_PROGRESS", result.Status) // Mirror the real client: IN_PROGRESS → ErrCheckoutPending (still live). _, err = client.GetCheckout(ctx, result.ID) require.Error(t, err) assert.True(t, errors.Is(err, ErrCheckoutPending), "expected ErrCheckoutPending for IN_PROGRESS checkout, got: %v", err) // The forced state must persist (no auto-complete while forced). client.mu.RLock() checkout := client.checkouts[result.ID] client.mu.RUnlock() require.NotNil(t, checkout) assert.Equal(t, "IN_PROGRESS", checkout.Status) } func TestDevClient_GetCheckout_ForceCancelRequested(t *testing.T) { // CANCEL_REQUESTED is the "customer tapped cancel on the terminal" state. // The real client folds it into ErrCheckoutPending (Square does not // promise non-completion), and the mock must mirror that so the sweep // treats it as still-live rather than definitively dead. client := NewDevClient().(*MockClient) client.ForceCheckoutState = "CANCEL_REQUESTED" ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "cancel-requested-checkout", ReferenceID: "cancel-requested-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Equal(t, "CANCEL_REQUESTED", result.Status) _, err = client.GetCheckout(ctx, result.ID) require.Error(t, err) assert.True(t, errors.Is(err, ErrCheckoutPending), "expected ErrCheckoutPending for CANCEL_REQUESTED checkout, got: %v", err) client.mu.RLock() checkout := client.checkouts[result.ID] client.mu.RUnlock() require.NotNil(t, checkout) assert.Equal(t, "CANCEL_REQUESTED", checkout.Status) } func TestDevClient_GetCheckout_ForceCanceled(t *testing.T) { // CANCELED is terminal at Square. The real client surfaces it as a plain // "is CANCELED (not COMPLETED)" error (getCheckoutHTTPWithClient), which // the sweep classifies as definitively dead. The mock must emit the same // shape so isCheckoutDefinitivelyDead runs identically in dev. client := NewDevClient().(*MockClient) client.ForceCheckoutState = "CANCELED" ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "canceled-checkout", ReferenceID: "canceled-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Equal(t, "CANCELED", result.Status) _, err = client.GetCheckout(ctx, result.ID) require.Error(t, err) assert.False(t, errors.Is(err, ErrCheckoutPending), "a CANCELED checkout is terminal, not pending: %v", err) assert.Contains(t, err.Error(), "CANCELED") } func TestDevClient_ListPaymentRefunds_FiltersByPaymentAndTime(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) client.mu.Lock() client.refunds["ref_1"] = &RefundResult{ ID: "ref_1", Status: "COMPLETED", Amount: 5000, PaymentID: "pay_a", LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(2 * 24 * time.Hour).Format(time.RFC3339), } client.refunds["ref_2"] = &RefundResult{ ID: "ref_2", Status: "COMPLETED", Amount: 2500, PaymentID: "pay_b", LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(3 * 24 * time.Hour).Format(time.RFC3339), } client.refunds["ref_3"] = &RefundResult{ ID: "ref_3", Status: "COMPLETED", Amount: 1000, PaymentID: "pay_a", LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(-1 * 24 * time.Hour).Format(time.RFC3339), } client.mu.Unlock() results, err := client.ListPaymentRefunds(ctx, "pay_a", begin) require.NoError(t, err) require.Len(t, results, 1, "only the pay_a refund created after beginTime must be returned") assert.Equal(t, "ref_1", results[0].ID) assert.Equal(t, int64(5000), results[0].Amount) assert.Equal(t, "COMPLETED", results[0].Status) assert.Equal(t, "pay_a", results[0].PaymentID) } func TestDevClient_ListPaymentRefunds_AfterRefundPayment(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "payment-for-list-refunds", ReferenceID: "booking-list-refunds", }) require.NoError(t, err) refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: paymentResult.ID, Amount: 5000, IdempotencyKey: "refund-for-list", Reason: "customer request", }) require.NoError(t, err) results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-24*time.Hour)) require.NoError(t, err) require.Len(t, results, 1, "the refund stored by RefundPayment must be listed") assert.Equal(t, refundResult.ID, results[0].ID) assert.Equal(t, int64(5000), results[0].Amount) assert.Equal(t, "COMPLETED", results[0].Status) assert.Equal(t, paymentResult.ID, results[0].PaymentID) } func TestDevClient_ListPaymentRefunds_Empty(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() results, err := client.ListPaymentRefunds(ctx, "pay_unknown", time.Now().Add(-24*time.Hour)) require.NoError(t, err) assert.NotNil(t, results, "must return an empty slice, not nil") assert.Empty(t, results) } func TestDetectCardInfo_Variants(t *testing.T) { tests := []struct { sourceID string wantBrand string wantLast4 string }{ {"cnon:test-card", "VISA", "4242"}, {"cnon:visa", "VISA", "1111"}, {"cnon:mastercard", "MASTERCARD", "4444"}, {"cnon:amex", "AMERICAN_EXPRESS", "0005"}, {"unknown-source", "VISA", "4242"}, {"", "VISA", "4242"}, } for _, tt := range tests { t.Run(tt.sourceID, func(t *testing.T) { brand, last4 := detectCardInfo(tt.sourceID) assert.Equal(t, tt.wantBrand, brand) assert.Equal(t, tt.wantLast4, last4) }) } } func TestDevClient_GetPayment_Found(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() created, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "payment-for-get", ReferenceID: "booking-get", }) require.NoError(t, err) got, err := client.GetPayment(ctx, created.ID) require.NoError(t, err) assert.Equal(t, created.ID, got.ID) assert.Equal(t, int64(5000), got.Amount) assert.Equal(t, "COMPLETED", got.Status) } func TestDevClient_GetPayment_NotFound(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.GetPayment(ctx, "pay_does_not_exist") require.Error(t, err) assert.Contains(t, err.Error(), "not found") } func TestDevClient_CreateCustomer_Dedup(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() first, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com") require.NoError(t, err) require.NotEmpty(t, first.ID) assert.Equal(t, "jane@example.com", first.Email) assert.NotEmpty(t, first.CreatedAt) assert.True(t, strings.HasPrefix(first.ID, "cus_mock_")) // Same email → same deterministic customer (Square dedups on the // email-derived idempotency key; the mock dedups on email). second, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com") require.NoError(t, err) assert.Equal(t, first.ID, second.ID, "same-email retry must return the original customer") other, err := client.CreateCustomer(ctx, "John Doe", "john@example.com") require.NoError(t, err) assert.NotEqual(t, first.ID, other.ID) client.mu.RLock() defer client.mu.RUnlock() assert.Len(t, client.customers, 2) } func TestDevClient_CreateCustomer_EmptyEmail(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.CreateCustomer(ctx, "Jane Doe", "") require.Error(t, err) assert.Contains(t, err.Error(), "email") } func TestDevClient_DeleteCustomer(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() cust, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com") require.NoError(t, err) err = client.DeleteCustomer(ctx, cust.ID) require.NoError(t, err) client.mu.RLock() defer client.mu.RUnlock() assert.Len(t, client.customers, 0, "deleted customer must be removed from the mock store") } func TestDevClient_DeleteCustomer_NotFoundIsNoop(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() // Deleting a customer the mock never created mirrors Square's NOT_FOUND — // idempotent re-deletion must return nil (GDPR re-runs are safe). err := client.DeleteCustomer(ctx, "cus_missing") require.NoError(t, err) } // TestDevClient_CustomerID_RedactedInLogs verifies the mock never logs a full // customer ID (S-2 convention): DeleteCustomer's entry/success lines and // CreateCustomer's dedup-hit/created lines all use the tokenPrefix redaction, // mirroring how prod redacts ccof: tokens. func TestDevClient_CustomerID_RedactedInLogs(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() cust, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com") require.NoError(t, err) var buf bytes.Buffer log.SetOutput(&buf) defer log.SetOutput(os.Stderr) err = client.DeleteCustomer(ctx, cust.ID) require.NoError(t, err) logs := buf.String() if strings.Contains(logs, cust.ID) { t.Errorf("full customer id %q leaked into mock logs: %q", cust.ID, logs) } if !strings.Contains(logs, tokenPrefix(cust.ID)) { t.Errorf("expected redacted customer id %q in logs, got %q", tokenPrefix(cust.ID), logs) } } func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) { client := NewDevClient().(*MockClient) client.HoldCheckouts = true ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 2500, Currency: "GBP", IdempotencyKey: "cancel-checkout", ReferenceID: "cancel-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Equal(t, "PENDING", result.Status) err = client.CancelCheckout(ctx, result.ID) require.NoError(t, err) client.mu.RLock() checkout := client.checkouts[result.ID] client.mu.RUnlock() require.NotNil(t, checkout) assert.Equal(t, "CANCELED", checkout.Status) } func TestDevClient_CancelCheckout_UnknownIsNoOp(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() err := client.CancelCheckout(ctx, "chk_does_not_exist") require.NoError(t, err) } func TestDevClient_CancelCheckout_CompletedIsNoOp(t *testing.T) { // Square documents that disabling an already-completed/cancelled checkout // has no effect, so the mock must return nil and leave the status alone. client := NewDevClient().(*MockClient) ctx := context.Background() result, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 2500, Currency: "GBP", IdempotencyKey: "cancel-completed", ReferenceID: "cancel-comp-ref", DeviceID: "dvc_test", }) require.NoError(t, err) assert.Eventually(t, func() bool { _, err := client.GetCheckout(ctx, result.ID) return err == nil }, 5*time.Second, 100*time.Millisecond, "expected checkout to complete") err = client.CancelCheckout(ctx, result.ID) require.NoError(t, err) client.mu.RLock() checkout := client.checkouts[result.ID] client.mu.RUnlock() require.NotNil(t, checkout) assert.Equal(t, "COMPLETED", checkout.Status, "cancelling an already-completed checkout must be a no-op") } func TestDevClient_CreateCustomer_RedactsEmailInLogs(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() var buf bytes.Buffer log.SetOutput(&buf) defer log.SetOutput(os.Stderr) email := "pii.marker@example.com" cust, err := client.CreateCustomer(ctx, "PII Marker", email) require.NoError(t, err) assert.Equal(t, email, cust.Email, "return value must keep the full email") logs := buf.String() if strings.Contains(logs, email) { t.Errorf("full email %q leaked into mock logs: %q", email, logs) } if !strings.Contains(logs, "pi***@example.com") { t.Errorf("expected redacted email 'pi***@example.com' in logs, got %q", logs) } } func TestDevClient_CreatePayment_RejectsRawPAN(t *testing.T) { // PCI-DSS parity: CreatePayment accepts only token-like source_ids // (cnon:xxx / ccof:xxx). Raw PANs are rejected exactly like real Square. client := NewDevClient().(*MockClient) ctx := context.Background() tests := []struct { name string pan string }{ {"visa", "4111111111111111"}, {"mastercard", "5555555555554444"}, {"amex", "378282246310005"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: tt.pan, IdempotencyKey: "raw-pan-" + tt.name, ReferenceID: "booking-raw", }) require.Error(t, err, "raw PAN must be rejected for production parity") assert.Nil(t, result) assert.Contains(t, err.Error(), "invalid source_id") }) } } // TestDevClient_CreatePayment_RejectsLegacyVerifyMockSource locks the FIX 5 // removal of the verify_mock_ source widening: the transition to genuine // cnon:sca- tokenize-results is complete and real Square NEVER accepts the // verify_mock__ shape as a source_id — so the mock rejects it // explicitly with a clear legacy-shape error (a raw PAN-style "invalid // source_id" message would obscure the reason). verify_mock_ tokens remain // valid in the VerificationToken field (the deprecated verifyBuyer() contract) // — only the source-slot widening is removed. func TestDevClient_CreatePayment_RejectsLegacyVerifyMockSource(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() t.Run("create_payment_rejects_verify_mock_source", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "verify_mock_4242_5000_ok", IdempotencyKey: "verify-mock-source", }) require.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "legacy verify_mock_ transition shape") assert.False(t, errors.Is(err, ErrRefundDeclined)) }) t.Run("create_card_on_file_rejects_verify_mock_source", func(t *testing.T) { card, err := client.CreateCardOnFile(ctx, "user-verify-mock", "verify_mock_4242_5000_ok", "cus_test123") require.Error(t, err) assert.Nil(t, card) assert.Contains(t, err.Error(), "legacy verify_mock_ transition shape") }) t.Run("verify_mock_verification_token_still_accepted", func(t *testing.T) { // The legacy verifyBuyer() contract (ccof: + verification_token) is // retained for backward-compat — only the SOURCE widening is removed. client.SimulateSavedCardVerificationRequired = true result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_saved", CustomerID: "cus_test123", IdempotencyKey: "verify-mock-token-still-ok", VerificationToken: "verify_mock_mock_5000_ok", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) }) } // TestDevClient_CreatePayment_CardOnFileRequiresCustomerID verifies the mock // mirrors Square's real enforcement: charging a ccof: (card-on-file) token // without a customer_id is rejected with a structured 400 INVALID_REQUEST_ERROR // (this is the exact production bug the mock must catch in dev), while the same // charge with a customer_id succeeds as ON_FILE. func TestDevClient_CreatePayment_CardOnFileRequiresCustomerID(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_saved", IdempotencyKey: "ccof-no-customer", ReferenceID: "booking-ccof-no-customer", }) require.Error(t, err, "ccof charge without customer_id must be rejected") assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err)) assert.Contains(t, ErrorDetail(err), "customer_id required") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_saved", IdempotencyKey: "ccof-with-customer", ReferenceID: "booking-ccof-with-customer", CustomerID: "cus_mock_1", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status) assert.Equal(t, "ON_FILE", result.EntryMethod) assert.Equal(t, "cus_mock_1", result.CustomerID) } func TestDevClient_RefundPayment_ForcePending(t *testing.T) { // ForceRefundPending exercises the prod-only PENDING refund branch that // is otherwise only reachable against the real Square API. client := NewDevClient().(*MockClient) client.ForceRefundPending = true ctx := context.Background() paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "payment-for-pending-refund", ReferenceID: "booking-pending-refund", }) require.NoError(t, err) refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: paymentResult.ID, Amount: 5000, IdempotencyKey: "pending-refund-key", Reason: "customer request", }) require.NoError(t, err) assert.Equal(t, "PENDING", refundResult.Status) assert.Equal(t, int64(5000), refundResult.Amount) assert.Equal(t, paymentResult.ID, refundResult.PaymentID) } func TestDevClient_RefundPayment_ZeroAmountUnknownPayment(t *testing.T) { // A £0 refund resolves to a full refund only when the payment is known. // Against an unknown payment it must fail (the real DB has a CHECK // amount > 0) rather than silently record a £0 refund. client := NewDevClient().(*MockClient) ctx := context.Background() result, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: "pay_unknown_zero", Amount: 0, IdempotencyKey: "zero-refund-unknown", }) require.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "amount must be positive") } func TestDevClient_RefundPayment_ZeroAmountRejected(t *testing.T) { // Square's RefundPayment requires amount_money — a £0 refund is a 400 // rejection even when the payment exists, never a "full refund" shortcut. // The mock must mirror this so a missing-amount bug can't be masked in dev // (handlers guard with ValidateAmount; the DB has a CHECK amount > 0). client := NewDevClient().(*MockClient) ctx := context.Background() paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "payment-for-zero-refund", ReferenceID: "booking-zero-refund", }) require.NoError(t, err) result, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: paymentResult.ID, Amount: 0, IdempotencyKey: "zero-refund-known", }) require.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "amount must be positive") } func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) { // Exercises the RLock read path concurrently with writes (Lock) — would // deadlock or panic under -race if ListPaymentRefunds wrongly used a // write lock. client := NewDevClient().(*MockClient) ctx := context.Background() paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "payment-for-concurrent-list", ReferenceID: "booking-concurrent-list", }) require.NoError(t, err) var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(2) go func(idx int) { defer wg.Done() _, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: paymentResult.ID, Amount: 100, IdempotencyKey: fmt.Sprintf("refund-concurrent-%d", idx), Reason: "concurrent", }) assert.NoError(t, err) }(i) go func() { defer wg.Done() _, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-time.Hour)) assert.NoError(t, err) }() } wg.Wait() results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-time.Hour)) require.NoError(t, err) assert.Len(t, results, 8) } // TestDevClient_ProductionEnvWithoutOverride_HardFails locks the dev-safety // boundary: a `//go:build dev` build must NEVER silently route to the real // PRODUCTION Square API on an env-string match alone (a typo'd/leftover // SQUARE_ENVIRONMENT=production in a dev shell would create REAL charges from // test bookings). NewDevClient hard-fails unless the explicit // SQUARE_ALLOW_REAL_API=1 override is set; with the override it proceeds. func TestDevClient_ProductionEnvWithoutOverride_HardFails(t *testing.T) { t.Setenv("SQUARE_ENVIRONMENT", "production") t.Setenv("SQUARE_ALLOW_REAL_API", "") require.PanicsWithError(t, errDevRealAPIRequiresOverride.Error(), func() { NewDevClient() }, "a dev build must refuse SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1") // The explicit override is the opt-in that lets a dev build route to the // real production API. t.Setenv("SQUARE_ALLOW_REAL_API", "1") client := NewDevClient() require.IsType(t, &devProdClient{}, client, "SQUARE_ALLOW_REAL_API=1 must allow the dev build to route to the real production API") } // TestDevClient_SandboxEnv_RoutesToRealClient locks the sandbox routing // banner: a dev build may route to the Square SANDBOX (no real money), but // only with a loud banner so the sandbox is never mistaken for the mock. func TestDevClient_SandboxEnv_RoutesToRealClient(t *testing.T) { t.Setenv("SQUARE_ENVIRONMENT", "sandbox") t.Setenv("SQUARE_ALLOW_REAL_API", "") var buf bytes.Buffer log.SetOutput(&buf) defer log.SetOutput(os.Stderr) client := NewDevClient() require.IsType(t, &devProdClient{}, client, "a dev build may route to the Square sandbox (no real money)") logs := buf.String() assert.Contains(t, logs, "SANDBOX", "routing a dev build to the sandbox must log a loud banner") assert.Contains(t, logs, squareSandboxURL, "the banner must name the sandbox endpoint, not a mock") } // TestDevClient_CreatePayment_FailAfterCommit locks the FailAfterCommit // fault-injection: CreatePayment COMMITS the charge (retaining key + source in // the ledgers exactly like a successful charge) and THEN returns a 5xx-style // error — the "charged but response lost" prod scenario. A same-key + // same-source retry must dedup to the committed payment, never issue a second // charge. func TestDevClient_CreatePayment_FailAfterCommit_ErrorThenSameKeyRetryDedups(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "fail-after-commit-key", ReferenceID: "booking-lost-response", } client.FailAfterCommit = true got, err := client.CreatePayment(ctx, req) require.Error(t, err, "FailAfterCommit must return an error to the caller (the response was lost)") assert.Nil(t, got) assert.Contains(t, err.Error(), "503", "the lost-response error must read as a 5xx for ambiguous classification") // The charge was committed: the key + source are retained exactly like a // successful charge, and the payment resolves by SquarePayID. client.mu.RLock() committed := client.paymentByKey["fail-after-commit-key"] storedSource := client.paymentSource["fail-after-commit-key"] client.mu.RUnlock() require.NotNil(t, committed, "FailAfterCommit must COMMIT the charge under the idempotency key") assert.Equal(t, "cnon:test-card", storedSource, "FailAfterCommit must retain the source under the key") byID, err := client.GetPayment(ctx, committed.SquarePayID) require.NoError(t, err) assert.Equal(t, committed.ID, byID.ID, "the committed payment must be resolvable by SquarePayID") // A same-key + same-source retry dedups to the committed payment — the // exact prod 503-retry semantics (no double charge). client.FailAfterCommit = false retry, err := client.CreatePayment(ctx, req) require.NoError(t, err) assert.Equal(t, committed.ID, retry.ID, "same-key retry must return the committed payment, not a second charge") client.mu.RLock() payCount := len(client.payments) client.mu.RUnlock() assert.Equal(t, 1, payCount, "FailAfterCommit + same-key retry must store exactly ONE charge") } // TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey locks the mock's // 45-char idempotency-key cap: real Square rejects an over-length key for // POST /v2/payments with a 400 VALUE_TOO_LONG, and the mock must mirror that // structured rejection so dev parity catches a caller bug. func TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() longKey := strings.Repeat("k", 46) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: longKey, }) require.Error(t, err, "an idempotency key over Square's 45-char limit must be rejected") assert.Nil(t, result) assert.Equal(t, "VALUE_TOO_LONG", ErrorCode(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) // A 45-char key is the boundary and must be accepted. ok, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: strings.Repeat("k", 45), }) require.NoError(t, err) assert.Equal(t, "COMPLETED", ok.Status) } // TestDevClient_CreateCardOnFile_SimulateSourceUsed locks the SOURCE_USED // simulation: when SimulateSourceUsed is enabled, a card source (cnon: nonce) // reused after a previous save is rejected with Square's structured 400 // SOURCE_USED error (the CreateCard error for a reused source — NOT the // CreatePayment code CARD_TOKEN_USED). Off by default (dev/test flows reuse // plain test tokens across requests), so the toggle must not reject reuse when // disabled. func TestDevClient_CreateCardOnFile_SimulateSourceUsed(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSourceUsed = true ctx := context.Background() card, err := client.CreateCardOnFile(ctx, "user-token-used", "cnon:single-use-nonce", "cus_test123") require.NoError(t, err) assert.NotEmpty(t, card.ID) // Reusing the same source → Square's SOURCE_USED rejection. _, err = client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:single-use-nonce", "cus_test123") require.Error(t, err) assert.Equal(t, "SOURCE_USED", ErrorCode(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedSources()) // A fresh source still works. fresh, err := client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:fresh-nonce", "cus_test123") require.NoError(t, err) assert.NotEmpty(t, fresh.ID) // With the toggle OFF (default), reusing a source is allowed — dev/test // flows reuse plain "cnon:test-card"-style tokens across requests. client.SimulateSourceUsed = false _, err = client.CreateCardOnFile(ctx, "user-token-reuse", "cnon:reused-token", "cus_test123") require.NoError(t, err) _, err = client.CreateCardOnFile(ctx, "user-token-reuse-2", "cnon:reused-token", "cus_test123") require.NoError(t, err, "with SimulateSourceUsed off, source reuse must be allowed") } // TestIdempotencyKeyLength_Parity_MockAndRealClientAgree asserts the mock and // the real HTTP client AGREE on the over-length idempotency key rejection: // both surface the same structured code (VALUE_TOO_LONG) and HTTP status (400). func TestIdempotencyKeyLength_Parity_MockAndRealClientAgree(t *testing.T) { ctx := context.Background() longKey := strings.Repeat("k", 46) // Real client: Square's 400 VALUE_TOO_LONG response surfaces as a // structured squareAPIError (the doJSON error-parsing path). 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":"VALUE_TOO_LONG","detail":"idempotency_key too long"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, realErr := createPaymentHTTPWithClient(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: longKey, }, hc) require.Error(t, realErr) // Mock: rejects the same key client-side with the identical structured // error (code + status), so dev parity holds. mock := NewDevClient().(*MockClient) _, mockErr := mock.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: longKey, }) require.Error(t, mockErr) assert.Equal(t, "VALUE_TOO_LONG", ErrorCode(realErr)) assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for an over-length key") assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for an over-length key") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr)) } // TestSourceUsed_Parity_MockAndRealClientAgree asserts the mock and the // real HTTP client AGREE on the reused-card-source rejection: both surface the // same structured code (SOURCE_USED — Square's CreateCard error, NOT the // CreatePayment code CARD_TOKEN_USED) and HTTP status (400). func TestSourceUsed_Parity_MockAndRealClientAgree(t *testing.T) { ctx := context.Background() source := "cnon:reused-nonce" // Real client: Square's 400 SOURCE_USED response surfaces as a // structured squareAPIError. 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":"SOURCE_USED","detail":"The provided source id was already used to create a card"}]}`)) })) defer srv.Close() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} _, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", source, "cus_1", hc) require.Error(t, realErr) // Mock: with the simulation enabled, reusing a consumed source surfaces the // identical structured error. mock := NewDevClient().(*MockClient) mock.SimulateSourceUsed = true _, err := mock.CreateCardOnFile(ctx, "user_1", source, "cus_1") require.NoError(t, err) _, mockErr := mock.CreateCardOnFile(ctx, "user_2", source, "cus_1") require.Error(t, mockErr) assert.Equal(t, "SOURCE_USED", ErrorCode(realErr)) assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for a reused card source") assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for a reused card source") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr)) } // TestEnvResolution_HelperMatchesHTTPClient locks the shared env-resolution // contract (finding 4): the sweep and the charge path read SQUARE_ENVIRONMENT / // SQUARE_LOCATION_ID through the SAME helpers the HTTP client uses, so the two // deployables can never drift to independent env reads. func TestEnvResolution_HelperMatchesHTTPClient(t *testing.T) { t.Setenv("SQUARE_ENVIRONMENT", "sandbox") t.Setenv("SQUARE_LOCATION_ID", "L_TEST_ENV") assert.Equal(t, "sandbox", SquareEnvironment()) assert.Equal(t, "L_TEST_ENV", SquareLocationID()) // newHTTPClient derives base URL + location from the SAME helpers. hc := newHTTPClient() assert.Equal(t, squareSandboxURL, hc.baseURL, "sandbox env must resolve the sandbox base URL") assert.Equal(t, "L_TEST_ENV", hc.locationID, "the HTTP client must read the location through SquareLocationID") // Production resolves the production base URL; anything else resolves the // sandbox base URL — never the production URL. t.Setenv("SQUARE_ENVIRONMENT", "production") assert.Equal(t, squareProductionURL, newHTTPClient().baseURL, "production env must resolve the production base URL") t.Setenv("SQUARE_ENVIRONMENT", "mock") assert.Equal(t, squareSandboxURL, newHTTPClient().baseURL, "any non-production env resolves the sandbox base URL (never the production URL)") } // TestProcessingFeeSign_Parity_MockAndRealClientAgree locks the // processing-fee sign convention: Square reports processing_fee amounts as // NEGATIVE on the wire and paymentFromSquare negates them so PaymentResult.Fees // is POSITIVE — the magnitude the handlers store as p.fees. The mock // fabricates the same positive magnitude directly, so mock and real client must // agree on the value (finding A). func TestProcessingFeeSign_Parity_MockAndRealClientAgree(t *testing.T) { ctx := context.Background() amount := int64(5000) wantFees := int64(5000*14/1000 + 25) // 1.4% + 25p on £50.00 = 95p // Real client: Square's wire processing_fee is negative; paymentFromSquare // must surface the positive magnitude. pr := paymentFromSquare(&sqPayment{ ID: "pay_fee", Status: "COMPLETED", TotalMoney: sqMoney{Amount: amount, Currency: "GBP"}, ProcessingFee: []sqFee{ {AmountMoney: sqMoney{Amount: -wantFees, Currency: "GBP"}, Type: "INITIAL"}, }, }) assert.Equal(t, wantFees, pr.Fees, "real client must negate Square's negative processing_fee into a positive PaymentResult.Fees") // Mock: fabricates the same positive fee. mock := NewDevClient().(*MockClient) got, err := mock.CreatePayment(ctx, CreatePaymentReq{ Amount: amount, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "fee-parity-key", }) require.NoError(t, err) assert.Equal(t, wantFees, got.Fees, "mock and real client must agree on the positive fee magnitude") } // TestDevClient_GetCardsOnFile_ExcludesDisabled locks the List Cards parity: // real Square's List Cards API EXCLUDES disabled cards by default (the client // sends no include_disabled param), so a card disabled via DeleteCardOnFile // must disappear from GetCardsOnFile — exactly like prod (finding C). func TestDevClient_GetCardsOnFile_ExcludesDisabled(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() userID := "user-disabled-exclusion" c1, err := client.CreateCardOnFile(ctx, userID, "cnon:enabled-card", "cus_test123") require.NoError(t, err) c2, err := client.CreateCardOnFile(ctx, userID, "cnon:to-be-disabled", "cus_test123") require.NoError(t, err) require.NoError(t, client.DeleteCardOnFile(ctx, c2.ID)) cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) require.Len(t, cards, 1, "only the enabled card must be listed") assert.Equal(t, c1.ID, cards[0].ID) } // TestDevClient_CreatePayment_ForcePaymentStatus drives the "Square returned // 200 with a non-terminal payment" prod scenario: with ForcePaymentStatus set, // CreatePayment returns a payment carrying a non-default status with NIL error // (the client never errors on a status — see paymentFromSquare). A status-blind // handler that records 'completed' on nil error alone would mis-record these; // the toggle makes that regression exercisable in dev (finding D). func TestDevClient_CreatePayment_ForcePaymentStatus(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() for _, status := range []string{"FAILED", "CANCELED", "APPROVED", "PENDING"} { t.Run(status, func(t *testing.T) { client.ForcePaymentStatus = status res, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "forced-status-" + status, }) require.NoError(t, err, "a forced status must still return nil error (the client is status-transparent)") assert.Equal(t, status, res.Status, "the payment must carry the forced status") got, err := client.GetPayment(ctx, res.ID) require.NoError(t, err) assert.Equal(t, status, got.Status, "GetPayment must surface the same status") }) } } // TestDevClient_CreateCheckout_RequiresDeviceID locks the TerminalCheckout // device_id parity: real Square REQUIRES device_options.device_id (400 on // empty). The mock resolves the per-request device ID with the same env // fallback as the real client (SQUARE_TERMINAL_DEVICE_ID) and rejects when // neither is set — a missing terminal misconfiguration is caught in dev // (finding H). func TestDevClient_CreateCheckout_RequiresDeviceID(t *testing.T) { ctx := context.Background() t.Run("empty_device_id_is_rejected", func(t *testing.T) { t.Setenv("SQUARE_TERMINAL_DEVICE_ID", "") client := NewDevClient().(*MockClient) res, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-no-device", }) require.Error(t, err) assert.Nil(t, res) assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) }) t.Run("request_device_id_is_accepted", func(t *testing.T) { client := NewDevClient().(*MockClient) res, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-req-device", DeviceID: "dvc_req", }) require.NoError(t, err) assert.Equal(t, "PENDING", res.Status) }) t.Run("env_device_id_fallback_is_accepted", func(t *testing.T) { t.Setenv("SQUARE_TERMINAL_DEVICE_ID", "dvc_env") client := NewDevClient().(*MockClient) res, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-env-device", }) require.NoError(t, err) assert.Equal(t, "PENDING", res.Status) }) } // TestDevClient_CreateCheckout_CompletedPaymentResolvableByID locks the // terminal-payment registration parity: a completed terminal checkout's payment // must be resolvable via GetPayment (GET /v2/payments/{id} in prod), not just // via GetCheckout. The mock previously never stored it in m.payments, so // GetPayment failed in dev where prod succeeded (finding I). func TestDevClient_CreateCheckout_CompletedPaymentResolvableByID(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() checkout, err := client.CreateCheckout(ctx, CreateCheckoutReq{ Amount: 7500, Currency: "GBP", IdempotencyKey: "chk-payment-resolvable", DeviceID: "dvc_test", }) require.NoError(t, err) var completed *PaymentResult assert.Eventually(t, func() bool { var getErr error completed, getErr = client.GetCheckout(ctx, checkout.ID) return getErr == nil && completed.Status == "COMPLETED" }, 5*time.Second, 100*time.Millisecond, "expected checkout to complete") // The completed terminal payment must resolve by ID — the sweep's // reconcile-by-id GetPayment path, which prod supports. got, err := client.GetPayment(ctx, completed.ID) require.NoError(t, err) assert.Equal(t, completed.ID, got.ID) assert.Equal(t, "COMPLETED", got.Status) } // TestDevClient_DeleteCardOnFile_CcofResolution locks the DeleteCardOnFile // ccof: resolution fix: production callers pass the DB-stored ccof: card // reference (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must // resolve through cardByToken so the deletion actually finds and disables the // card — previously the mock keyed only by its mock-local ID (mock_card_...) // and silently missed every ccof: call. func TestDevClient_DeleteCardOnFile_CcofResolution(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() userID := "user-ccof-delete" t.Run("ccof_card_id_disables_and_hides", func(t *testing.T) { card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-ccof", "cus_test123") require.NoError(t, err) require.True(t, strings.HasPrefix(card.CardID, "ccof:"), "mock CardID must be ccof:-prefixed to exercise the cardByToken path") err = client.DeleteCardOnFile(ctx, card.CardID) require.NoError(t, err, "deleting by the DB-stored ccof: CardID must resolve the card through cardByToken") // The card object itself must be disabled, not just hidden. client.mu.RLock() deleted := client.cardByToken[card.CardID] client.mu.RUnlock() require.NotNil(t, deleted, "the ccof: token must remain resolvable after deletion") assert.False(t, deleted.Enabled, "the ccof: resolved card must be disabled") // Square's List Cards API excludes disabled cards by default — the // deleted card disappears from GetCardsOnFile. cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) assert.Empty(t, cards, "a card deleted by its ccof: CardID must disappear from GetCardsOnFile") }) t.Run("mock_local_id_still_works", func(t *testing.T) { card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-local", "cus_test123") require.NoError(t, err) err = client.DeleteCardOnFile(ctx, card.ID) require.NoError(t, err, "deleting by the mock-local ID (mock_card_...) must keep working via the per-user fallback") cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) assert.Empty(t, cards, "a card deleted by its mock-local ID must also disappear from GetCardsOnFile") }) t.Run("unknown_id_errors_not_silent", func(t *testing.T) { err := client.DeleteCardOnFile(ctx, "ccof:never-created") require.Error(t, err, "an unknown card ID must return an error, never silent success") assert.Contains(t, err.Error(), "card not found") }) } // TestDevClient_RefundPayment_UnknownPayMockID_NotFound locks the mock's // NOT_FOUND strictness: a refund targeting a "pay_mock_*" ID that was never // created is a provable bug (that charge never went through this mock) and real // Square answers 404 NOT_FOUND — the mock must surface the structured error, // never silently proceed. func TestDevClient_RefundPayment_UnknownPayMockID_NotFound(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() result, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: "pay_mock_never_created", Amount: 5000, IdempotencyKey: "refund-unknown-pay-mock", }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "NOT_FOUND", ErrorCode(err)) assert.Equal(t, http.StatusNotFound, ErrorStatusCode(err)) client.mu.RLock() defer client.mu.RUnlock() assert.Len(t, client.refunds, 0, "no refund must be stored for an unknown pay_mock_* payment") assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored for an unknown pay_mock_* payment") } // TestDevClient_RefundPayment_OverRefund locks the mock's real Square // over-refund rejection: refunding more than the remaining balance answers 400 // REFUND_AMOUNT_INVALID. Square returns that SAME code for an already-refunded // payment, so — exactly like the real client (paymentRefundedExactlyWithClient) // — the mock reconciles: an EXACT-amount COMPLETED refund for the requested // amount → ErrRefundAlreadyProcessed (that money provably moved); anything else // (no refund, or a PARTIAL prior refund that does not cover the requested // amount) → the amount is genuinely invalid → ErrRefundDeclined. A different // amount is NEVER AlreadyProcessed — the over-refund guard bug must surface in // dev too. The exact-remaining boundary refund succeeds. func TestDevClient_RefundPayment_OverRefund(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() t.Run("over_refund_no_prior_refunds_is_declined", func(t *testing.T) { payment, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-fresh", }) require.NoError(t, err) result, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: payment.ID, Amount: 12000, IdempotencyKey: "refund-overrefund-fresh", }) require.Error(t, err) assert.Nil(t, result) // The mock builds a squareAPIError with Code REFUND_AMOUNT_INVALID but // wraps it with %v (exactly like the real client's sentinel wrap), so // the code is not reachable via ErrorCode(err) — the observable // contract is the ErrRefundDeclined sentinel. assert.True(t, errors.Is(err, ErrRefundDeclined), "a genuine over-refund with no prior refunds must be ErrRefundDeclined, got %v", err) assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed)) }) t.Run("exact_remaining_refund_succeeds", func(t *testing.T) { payment, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-exact-remaining", }) require.NoError(t, err) // A partial refund leaves 7000 remaining. first, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-partial", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", first.Status) // Refunding exactly the remaining balance is NOT an over-refund. second, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: payment.ID, Amount: 7000, IdempotencyKey: "refund-exact-remaining", }) require.NoError(t, err) assert.Equal(t, int64(7000), second.Amount) assert.Equal(t, "COMPLETED", second.Status) }) t.Run("over_refund_after_partial_refund_is_declined", func(t *testing.T) { payment, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-existing", }) require.NoError(t, err) // A COMPLETED refund moves money; the remaining balance drops to 7000. first, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-first-move", }) require.NoError(t, err) require.Equal(t, "COMPLETED", first.Status) // Over-refunding with a DIFFERENT amount (8000) is a genuine decline: // no EXACT-amount refund for 8000 exists, so prod classifies it // ErrRefundDeclined (the row is marked failed — the over-refund guard // bug surfaces) — never ErrRefundAlreadyProcessed, which would hide it. result, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-existing", }) require.Error(t, err) assert.Nil(t, result) assert.True(t, errors.Is(err, ErrRefundDeclined), "an over-refund on top of a PARTIAL prior refund must reconcile to ErrRefundDeclined (no exact-amount refund covers 8000), got %v", err) assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed)) }) t.Run("over_refund_exact_amount_already_refunded_is_already_processed", func(t *testing.T) { payment, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-exact", }) require.NoError(t, err) // An 8000 refund leaves 2000 remaining. first, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-exact-move", }) require.NoError(t, err) require.Equal(t, "COMPLETED", first.Status) // Re-requesting the SAME 8000 (now an over-refund) matches an EXACT- // amount COMPLETED refund — that money provably already moved, exactly // like prod's paymentRefundedExactlyWithClient reconciliation. result, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-exact", }) require.Error(t, err) assert.Nil(t, result) assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund matching an existing EXACT-amount refund must reconcile to ErrRefundAlreadyProcessed, got %v", err) assert.False(t, errors.Is(err, ErrRefundDeclined)) }) } // TestDevClient_RefundPayment_LenientPathForNonMockIDs locks the lenient refund // path: payment IDs that are NOT "pay_mock_*" (e.g. the DB-fixture // square_payment_id values like "sqp_..." that sweep tests seed refunds // against) exist outside the mock's ledger — exactly as they would at real // Square — so RefundPayment processes them without the NOT_FOUND rejection. func TestDevClient_RefundPayment_LenientPathForNonMockIDs(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() result, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: "sqp_fixture_123", Amount: 5000, IdempotencyKey: "refund-lenient-sqp", }) require.NoError(t, err, "a non-mock fixture payment ID must take the lenient path, not NOT_FOUND") assert.Equal(t, "COMPLETED", result.Status) assert.Equal(t, int64(5000), result.Amount) assert.Equal(t, "sqp_fixture_123", result.PaymentID) } // TestDevClient_CreatePayment_SimulateVerificationRequired locks the mock's SCA // enforcement: with SimulateVerificationRequired=true, a new-card (cnon:) charge // without a 3DS/SCA verification token is rejected with a structured 400 // CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error the buyer must // resolve by re-verifying — never retried as-is); a present verification token // (verify_mock_...) satisfies the gate; and with the toggle off (default) no // verification is required. func TestDevClient_CreatePayment_SimulateVerificationRequired(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateVerificationRequired = true ctx := context.Background() t.Run("cnon_without_verification_token_is_rejected", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-no-token", }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) // The WIRE status must be 402 (Square's documented status for // verification-required), not 400. assert.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err)) assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error") }) t.Run("verification_token_satisfies_gate", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-with-token", VerificationToken: "verify_mock_ok", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status) }) t.Run("toggle_off_requires_no_verification", func(t *testing.T) { client.SimulateVerificationRequired = false result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-default-off", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status, "with SimulateVerificationRequired off (default), no verification token is required") }) } // TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption locks the // mock's single-use cnon: nonce simulation on CreatePayment: with // SimulateSourceUsed enabled, a cnon used in one CreatePayment is rejected with // CARD_TOKEN_USED on a second CreatePayment under a DIFFERENT idempotency key; // ccof: (card-on-file) sources are NEVER consumed (they are stored references, // not single-use nonces); and with the toggle off (default) the same cnon can // be reused freely. func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSourceUsed = true ctx := context.Background() t.Run("cnon_reuse_rejected_with_card_token_used", func(t *testing.T) { source := "cnon:single-use-pay" first, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-1", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", first.Status) // Second CreatePayment with the SAME cnon under a DIFFERENT key → // Square's CARD_TOKEN_USED rejection (the CreatePayment code for a used // source). result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-2", }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err)) assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) assert.Contains(t, client.UsedSources(), source, "the consumed cnon must be reported by UsedSources") }) t.Run("ccof_sources_are_never_consumed", func(t *testing.T) { // Create the card with the toggle off so the underlying cnon is not // consumed; CreatePayment charges the ccof: CardID, which is a stored // reference rather than a single-use nonce. client.SimulateSourceUsed = false card, err := client.CreateCardOnFile(ctx, "user-ccof-never-consumed", "cnon:card-src", "cus_test123") require.NoError(t, err) client.SimulateSourceUsed = true first, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-1", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", first.Status) second, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-2", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", second.Status, "a ccof: card must be chargeable again under a different key") assert.NotContains(t, client.UsedSources(), card.CardID, "ccof: sources must never be consumed") }) t.Run("toggle_off_allows_reuse", func(t *testing.T) { client.SimulateSourceUsed = false first, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-1", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", first.Status) second, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-2", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable") }) } // TestDevClient_CreatePayment_SavedCardVerificationRequired locks the mock's SCA // enforcement on SAVED-CARD (ccof:) charges: with // SimulateSavedCardVerificationRequired=true, a ccof charge without a 3DS/SCA // verification token is rejected with a structured 400 // CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error), a // deterministic verify_mock___ok token satisfies the gate, a // grandfathered card charges without a token, and with the toggle off (default) // no verification is required. Placement check: a ccof charge WITHOUT a customer // id must still be MISSING_REQUIRED_PARAMETER, never verification-required. func TestDevClient_CreatePayment_SavedCardVerificationRequired(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_saved" t.Run("ccof_without_verification_token_is_rejected", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-no-token", }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) // The WIRE status must be 402 (Square's documented status for // verification-required), not 400. assert.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err)) assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error") }) t.Run("verification_token_satisfies_gate", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-with-token", VerificationToken: "verify_mock_mock_5000_ok", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status) assert.Equal(t, "ON_FILE", result.EntryMethod) }) t.Run("customer_id_gate_fires_before_verification_gate", func(t *testing.T) { _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "ccof:other_card", IdempotencyKey: "sca-ccof-no-customer", }) require.Error(t, err) assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err), "a ccof charge without a customer must stay MISSING_REQUIRED_PARAMETER, never verification-required") }) t.Run("grandfathered_card_charges_without_token", func(t *testing.T) { client.GrandfatherSavedCard(ccof) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-grandfathered", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status) }) t.Run("toggle_off_requires_no_verification", func(t *testing.T) { client.SimulateSavedCardVerificationRequired = false result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-default-off", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status, "with SimulateSavedCardVerificationRequired off (default), a ccof charge needs no verification token") }) } // TestDevClient_CreatePayment_VerificationToken_OneTimeUse locks the mock's // one-time-use verification-token ledger: a verify_mock_* token is consumed on // its first successful charge, so a second charge with the SAME token (under a // DIFFERENT idempotency key) is rejected with a definitive 400 // VERIFICATION_TOKEN_INVALID — mirroring real Square consuming a verification // token on use. func TestDevClient_CreatePayment_VerificationToken_OneTimeUse(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_saved" const token = "verify_mock_mock_5000_ok" first, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-1", VerificationToken: token, }) require.NoError(t, err) assert.Equal(t, "COMPLETED", first.Status) client.mu.RLock() consumed := client.verifyTokens[token] client.mu.RUnlock() assert.True(t, consumed, "a successfully used verification token must be consumed") result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-2", VerificationToken: token, }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) assert.True(t, IsDefinitivePaymentError(err), "VERIFICATION_TOKEN_INVALID must classify as a definitive payment error") } // TestDevClient_CreatePayment_VerificationToken_Denied locks the shared-state // challenge resolution: DenyPendingVerification marks the recorded challenge for // a saved card as denied, so a subsequent tokenized charge is rejected with // VERIFICATION_TOKEN_INVALID and the token is consumed (a second use of the same // token is also invalid). func TestDevClient_CreatePayment_VerificationToken_Denied(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_saved" const token = "verify_mock_mock_5000_ok" // The gate rejects the no-token charge and records a pending challenge. _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-gate", }) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) // The buyer denies the challenge in the banking app. client.DenyPendingVerification(ccof) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry", VerificationToken: token, }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) // The denied token is consumed: a second use of the SAME token is also invalid. result2, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry-2", VerificationToken: token, }) require.Error(t, err) assert.Nil(t, result2) assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) } // TestDevClient_CreatePayment_VerificationToken_AmountMismatch locks the // deterministic token's amount/source binding: a verify_mock__ // token is bound to the card + amount it was issued for, so charging a DIFFERENT // amount (or a different card) with it is rejected with VERIFICATION_TOKEN_INVALID. func TestDevClient_CreatePayment_VerificationToken_AmountMismatch(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_saved" t.Run("token_for_wrong_amount_is_rejected", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "amount-mismatch", VerificationToken: "verify_mock_mock_9999_ok", // bound to £99.99, the charge is £50.00 }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) }) t.Run("token_for_wrong_card_is_rejected", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "source-mismatch", VerificationToken: "verify_mock_other_5000_ok", // bound to a different card }) require.Error(t, err) assert.Nil(t, result) assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) }) } // TestDevClient_CreatePayment_SavedCardVerification_Grandfathered locks the // GrandfatherSavedCard exemption: marking a ccof token exempt lets it charge // without a verification token even while the saved-card SCA gate is on, while // a DIFFERENT (non-grandfathered) card stays gated. func TestDevClient_CreatePayment_SavedCardVerification_Grandfathered(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_saved" _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-before", }) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "before grandfathering the card must be gated") client.GrandfatherSavedCard(ccof) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-after", }) require.NoError(t, err) assert.Equal(t, "COMPLETED", result.Status, "a grandfathered card must charge without a verification token") _, err = client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "grand-other", }) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "a non-grandfathered card must stay gated") } // ============================================================================= // Deterministic verification-token parsing and prefix binding // ============================================================================= // TestParseVerifyToken pins the deterministic verify_mock__ // [_ok|_deny] encoding: the outcome suffix defaults to approval, "_deny" sets // denied=true, and anything malformed is not parseable (an opaque token — the // same shape as real Square's verification tokens). func TestParseVerifyToken(t *testing.T) { tests := []struct { name string token string wantOK bool wantDenied bool wantAmount int64 wantPrefix string }{ {"explicit ok suffix", "verify_mock_mock_5000_ok", true, false, 5000, "mock"}, {"deny suffix sets denied", "verify_mock_mock_5000_deny", true, true, 5000, "mock"}, {"no suffix defaults to approval", "verify_mock_4242_2500", true, false, 2500, "4242"}, {"prefix with underscores", "verify_mock_visa_3000_deny", true, true, 3000, "visa"}, {"opaque token is not parseable", "vrf_opaque_123", false, false, 0, ""}, {"marker with empty rest", "verify_mock_", false, false, 0, ""}, {"non-numeric amount", "verify_mock_mock_abc_ok", false, false, 0, ""}, {"missing amount", "verify_mock_mock_ok", false, false, 0, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parsed, ok := parseVerifyToken(tt.token) require.Equal(t, tt.wantOK, ok) if ok { require.Equal(t, tt.wantDenied, parsed.denied) require.Equal(t, tt.wantAmount, parsed.amount) require.Equal(t, tt.wantPrefix, parsed.prefix) } }) } } // TestVerificationTokenPrefixForSource pins the card prefix a verify_mock_* // token binds to for a source_id: the SAME derivation the dev frontend uses, so // the two sides cannot drift. func TestVerificationTokenPrefixForSource(t *testing.T) { assert.Equal(t, "4242", verificationTokenPrefixForSource("cnon:test-card")) assert.Equal(t, "4111", verificationTokenPrefixForSource("cnon:visa")) assert.Equal(t, "5555", verificationTokenPrefixForSource("cnon:mastercard")) assert.Equal(t, "3782", verificationTokenPrefixForSource("cnon:amex")) assert.Equal(t, "mock", verificationTokenPrefixForSource("ccof:mock_card")) assert.Equal(t, "abc", verificationTokenPrefixForSource("ccof:abc")) assert.Equal(t, "abcd", verificationTokenPrefixForSource("ccof:abcd")) assert.Equal(t, "", verificationTokenPrefixForSource("unknown-source")) } // TestVerificationTokenInvalidError pins Square's VERIFICATION_TOKEN_INVALID // rejection builder: a definitive payment error with the PAYMENT_METHOD_ERROR // category and a 400 status. func TestVerificationTokenInvalidError(t *testing.T) { err := verificationTokenInvalidError("vrf_bad", "ccof:card_1") require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) require.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) require.True(t, IsDefinitivePaymentError(err), "VERIFICATION_TOKEN_INVALID must classify as a definitive payment error") } // ============================================================================= // Challenge resolution: ApprovePendingVerification, ChallengeResult auto/deny, // and the stateless _deny token suffix // ============================================================================= // TestDevClient_ApprovePendingVerification_OpaqueTokenResolution locks the // shared-state challenge resolution for OPAQUE (real-Square-shaped) tokens: a // token for a pending challenge is invalid, ApprovePendingVerification marks // the challenge approved, and the next tokenized charge then succeeds. func TestDevClient_ApprovePendingVerification_OpaqueTokenResolution(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_approve" _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-gate", }) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) _, err = client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-before", VerificationToken: "vrf_opaque_not_approved", }) require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err), "an opaque token for a still-pending challenge must be invalid") client.ApprovePendingVerification(ccof) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-after", VerificationToken: "vrf_opaque_approved_1", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status, "an approved challenge must resolve the opaque token") } // TestDevClient_ApprovePendingVerification_CreatesIfMissing locks the // create-if-missing behavior: approving a card with NO recorded challenge still // lets a subsequent tokenized charge through. func TestDevClient_ApprovePendingVerification_CreatesIfMissing(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_approve_fresh" client.ApprovePendingVerification(ccof) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr2", IdempotencyKey: "approve-fresh", VerificationToken: "vrf_opaque_fresh_1", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) } // TestDevClient_ChallengeResult_Auto locks ChallengeResult="auto": the banking- // app challenge resolves itself as approved at gate time, so the next tokenized // retry succeeds WITHOUT an explicit ApprovePendingVerification call. func TestDevClient_ChallengeResult_Auto(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true client.ChallengeResult = "auto" ctx := context.Background() const ccof = "ccof:mock_auto" _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_auto", IdempotencyKey: "auto-gate", }) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_auto", IdempotencyKey: "auto-retry", VerificationToken: "vrf_opaque_auto_1", }) require.NoError(t, err, "ChallengeResult=auto must pre-approve the challenge so the retry succeeds") require.Equal(t, "COMPLETED", result.Status) } // TestDevClient_ChallengeResult_Deny locks ChallengeResult="deny": the buyer // denies every banking-app challenge, so ANY verification token — including a // statelessly-approved deterministic one — is definitively rejected. func TestDevClient_ChallengeResult_Deny(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true client.ChallengeResult = "deny" ctx := context.Background() const ccof = "ccof:mock_deny_all" _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny", IdempotencyKey: "deny-1", VerificationToken: "verify_mock_mock_5000_ok", }) require.Error(t, err) require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err), "a buyer denial must reject even a deterministically-ok token") _, err = client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny", IdempotencyKey: "deny-2", VerificationToken: "verify_mock_mock_5000_deny", }) require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) } // TestDevClient_VerifyMockDenyTokenSuffix locks the stateless _deny token // suffix: verify_mock___deny encodes a buyer denial and is // rejected with VERIFICATION_TOKEN_INVALID even with NO shared-state challenge, // while the _ok encoding of the SAME binding succeeds (the control). func TestDevClient_VerifyMockDenyTokenSuffix(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() const ccof = "ccof:mock_deny_suffix" _, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny2", IdempotencyKey: "deny-suffix", VerificationToken: "verify_mock_mock_5000_deny", }) require.Error(t, err) require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) require.True(t, IsDefinitivePaymentError(err), "a _deny token is a definitive rejection") result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny2", IdempotencyKey: "deny-suffix-ok", VerificationToken: "verify_mock_mock_5000_ok", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status, "the _ok encoding of the same binding must succeed (control)") } // ============================================================================= // CURRENT saved-card SCA contract: tokenize-result as source_id + customer_id // ============================================================================= // TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical is the CRITICAL wire // contract guard. For the same logical saved-card SCA charge, the real client // (buildCreatePaymentBody, square_http_client.go) and the dev mock // (mockPaymentWireBody) must emit BYTE-IDENTICAL CreatePayment request bodies: // source_id = the card.tokenize(verificationDetails, cardId) tokenize-result // (a fresh cnon:-style one-time token) + customer_id resolved from the saved // card, with NO legacy verification_token. The constructors are written // independently on purpose — if either side drifts (a renamed field, a token // misrouted into verification_token, amount handling drift), the byte // comparison fails here before the divergence can reach prod, exactly like the // legacy verification_token + ccof: drift this rebuild replaces. func TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical(t *testing.T) { ctx := context.Background() // The same logical saved-card SCA charge the frontend + handler produce: // SourceID is the fresh tokenize-result, CustomerID derives from the saved // card row, and NO verification_token rides along (the token IS the SCA). req := CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-4242_5000_ok", IdempotencyKey: "sca-contract-key-1", ReferenceID: "booking-contract-1", Note: "full", CustomerID: "cus_sca_123", BuyerEmail: "buyer@example.com", } // Real client: buildCreatePaymentBody with an explicit location matching // the mock's default (location_id is the only env-dependent wire field — // buildCreatePaymentBody defaults it to SQUARE_LOCATION_ID, the mock to // L_MOCK). clientWire, err := json.Marshal(buildCreatePaymentBody(req, &httpClient{locationID: "L_MOCK"})) require.NoError(t, err) // Dev mock: its OWN independently-written wire-body constructor. mockWire, err := json.Marshal(mockPaymentWireBody(req)) require.NoError(t, err) require.Equal(t, string(clientWire), string(mockWire), "mock and real client must emit byte-identical CreatePayment bodies for the saved-card SCA charge — wire drift re-slipped") // Sanity-pin the wire shape so the byte-identity is provably the CURRENT // contract, not a shared-but-wrong shape. var wire map[string]any require.NoError(t, json.Unmarshal(clientWire, &wire)) require.Equal(t, "cnon:sca-4242_5000_ok", wire["source_id"], "the tokenize-result token must be the source_id") require.Equal(t, "cus_sca_123", wire["customer_id"], "customer_id must come from the saved card") require.NotContains(t, wire, "verification_token", "the SCA tokenize-result path must not emit a legacy verification_token") amt, ok := wire["amount_money"].(map[string]any) require.True(t, ok, "amount_money object expected, got %v", wire["amount_money"]) require.Equal(t, float64(5000), amt["amount"], "amount_money.amount must be integer pence") require.Equal(t, "GBP", amt["currency"]) require.Equal(t, "sca-contract-key-1", wire["idempotency_key"]) require.Equal(t, "L_MOCK", wire["location_id"]) // Functional acceptance: the mock must ACCEPT this exact charge end-to-end // even with the saved-card SCA gate enforced — the tokenize-result IS the // buyer verification, so no verification_token is demanded. mock := NewDevClient().(*MockClient) mock.SimulateSavedCardVerificationRequired = true pr, err := mock.CreatePayment(ctx, req) require.NoError(t, err, "the mock must accept the client's SCA tokenize-result charge end-to-end") require.Equal(t, "COMPLETED", pr.Status) // And the LEGACY verifyBuyer() shape (ccof: + verification_token) stays // accepted for backward-compat — the current contract replaces the legacy // primary path, it does not remove it. legacyReq := req legacyReq.SourceID = "ccof:mock_saved" legacyReq.VerificationToken = "verify_mock_mock_5000_ok" legacyReq.IdempotencyKey = "sca-contract-key-2" pr, err = mock.CreatePayment(ctx, legacyReq) require.NoError(t, err, "the legacy ccof + verification_token shape must stay accepted") require.Equal(t, "COMPLETED", pr.Status) } // TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_Accepted locks the // mock's saved-card gate on the CURRENT contract: under // SimulateSavedCardVerificationRequired, a charge with a fresh cnon:-style // tokenize-result as source_id + the saved card's customer_id is ACCEPTED // without any verification_token (the token only exists because the buyer // completed issuer verification — it IS the SCA proof). A cnon: charge with NO // customer_id is NOT a saved-card charge (the one-off new-card flow never sets // one), so the saved-card gate leaves it to the separate new-card gate. func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_Accepted(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() t.Run("tokenize_result_with_customer_is_sca_compliant", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_sca_1", IdempotencyKey: "sca-accept-1", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) require.Equal(t, "cus_sca_1", result.CustomerID) }) t.Run("tokenize_result_without_customer_is_not_a_saved_card_charge", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card-nonce", IdempotencyKey: "sca-newcard-1", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) }) } // TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_BothGatesOn locks the // interaction between the new-card and saved-card SCA gates: with BOTH toggles // on, a cnon:+customer_id tokenize-result is accepted (the token IS the buyer // verification, so it is exempt from the new-card verification-token // requirement), while a plain cnon: new-card nonce without a verification token // is still rejected by the new-card gate. func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_BothGatesOn(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateVerificationRequired = true client.SimulateSavedCardVerificationRequired = true ctx := context.Background() t.Run("tokenize_result_with_customer_passes_both_gates", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_sca_both", IdempotencyKey: "both-on-accept", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) }) t.Run("new_card_nonce_without_token_still_rejected", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card-nonce", IdempotencyKey: "both-on-reject", }) require.Error(t, err) require.Nil(t, result) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) require.True(t, IsDefinitivePaymentError(err)) }) } // TestDevClient_CreatePayment_SavedCard_RawNonceInTokenizeSlot_Rejected locks // the money-F2 fix: under SimulateSavedCardVerificationRequired, a RAW // card.tokenize() nonce (e.g. "cnon:test-card") sent in the SCA tokenize-result // slot (source_id + customer_id) is REJECTED with the structured 400 // CARD_DECLINED_VERIFICATION_REQUIRED — real Square rejects an unverified nonce // as a card-on-file charge source, so the mock must too (the handler treats any // non-empty new_card_token + saved-card ref as an SCA tokenize-result, skipping // the 2FA and consent gates; the mock is the enforcement point that stops the // forged shape). A GENUINE tokenize-result (cnon:sca-..., the marker the dev // frontend mints) still passes — the token IS the buyer verification. With the // gate off (default) the raw-nonce shape keeps charging, so existing dev flows // are unaffected. func TestDevClient_CreatePayment_SavedCard_RawNonceInTokenizeSlot_Rejected(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() t.Run("raw_nonce_with_customer_is_rejected", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", CustomerID: "cus_forge", IdempotencyKey: "forge-raw-nonce", }) require.Error(t, err) require.Nil(t, result) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) require.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) // The WIRE status must be 402 (Square's documented status for // verification-required), not 400. require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err)) require.True(t, IsDefinitivePaymentError(err), "a forged unverified nonce must be a definitive payment error") }) t.Run("genuine_tokenize_result_still_accepted", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_genuine", IdempotencyKey: "genuine-tokenize-result", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) }) t.Run("gate_off_keeps_legacy_raw_nonce_shape", func(t *testing.T) { client.SimulateSavedCardVerificationRequired = false result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", CustomerID: "cus_legacy", IdempotencyKey: "raw-nonce-gate-off", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status, "with the saved-card SCA gate off (default), the raw-nonce shape must keep charging") }) } // TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse locks the // single-use contract for the saved-card SCA shape under SimulateSourceUsed: a // genuine tokenize-result (cnon:sca-...) sent with a customer_id is consumed on // its first CreatePayment, so reusing the SAME token for a DIFFERENT booking // (a different idempotency key) is rejected with CARD_TOKEN_USED — Square // consumes a nonce regardless of which endpoint or charge used it. func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSourceUsed = true client.SimulateSavedCardVerificationRequired = true ctx := context.Background() source := "cnon:sca-4242_5000_ok" // Booking A's charge consumes the tokenize-result. first, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: source, CustomerID: "cus_single", IdempotencyKey: "booking-A-charge", }) require.NoError(t, err) require.Equal(t, "COMPLETED", first.Status) // Booking B's charge reuses the SAME token under a DIFFERENT key — Square's // CARD_TOKEN_USED rejection. result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: source, CustomerID: "cus_single", IdempotencyKey: "booking-B-charge", }) require.Error(t, err) require.Nil(t, result) require.Equal(t, "CARD_TOKEN_USED", ErrorCode(err)) require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) require.Contains(t, client.UsedSources(), source, "the consumed tokenize-result must be reported by UsedSources") } // TestParseSCATokenizeResult pins the deterministic cnon:sca- tokenize-result // encoding the dev frontend mints (MockCardForm.tokenizeSavedCard / // verifySavedCard, square.ts's tokenizeSavedCardWithVerification fallback): // cnon:sca-_[_ok|_deny], outcome suffix defaults to approval, // "_deny" sets denied=true, and anything malformed is not parseable. func TestParseSCATokenizeResult(t *testing.T) { tests := []struct { name string token string wantOK bool wantDenied bool wantAmount int64 wantPrefix string }{ {"explicit ok suffix", "cnon:sca-4242_5000_ok", true, false, 5000, "4242"}, {"deny suffix sets denied", "cnon:sca-4242_5000_deny", true, true, 5000, "4242"}, {"no suffix defaults to approval", "cnon:sca-mock_2500", true, false, 2500, "mock"}, {"prefix with underscores", "cnon:sca-visa_card_3000_deny", true, true, 3000, "visa_card"}, {"marker with empty rest", "cnon:sca-", false, false, 0, ""}, {"non-numeric amount", "cnon:sca-4242_abc_ok", false, false, 0, ""}, {"missing amount", "cnon:sca-4242_ok", false, false, 0, ""}, {"not a tokenize-result", "cnon:test-card", false, false, 0, ""}, {"raw tokenize-result marker", "cnon:sca-tokenize-result", false, false, 0, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parsed, ok := parseSCATokenizeResult(tt.token) require.Equal(t, tt.wantOK, ok) if ok { require.Equal(t, tt.wantDenied, parsed.denied) require.Equal(t, tt.wantAmount, parsed.amount) require.Equal(t, tt.wantPrefix, parsed.prefix) } }) } } // TestDevClient_CreatePayment_SCATokenizeResult_BindingValidation locks the // FIX 2 contract: the mock validates the deterministic binding the dev frontend // encodes into a cnon:sca- tokenize-result. A token bound to the correct card // prefix + amount with an _ok outcome passes; a wrong-prefix, wrong-amount, // buyer-denied (_deny) or unparseable cnon:sca- token is refused with the // documented 402 CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment // error) — an arbitrary cnon:sca-... string must never pass the gate. func TestDevClient_CreatePayment_SCATokenizeResult_BindingValidation(t *testing.T) { client := NewDevClient().(*MockClient) client.SimulateSavedCardVerificationRequired = true ctx := context.Background() t.Run("valid_token_passes", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_valid", IdempotencyKey: "sca-binding-valid", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) }) t.Run("wrong_prefix_is_refused", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-9999_5000_ok", CustomerID: "cus_wrong_prefix", IdempotencyKey: "sca-binding-wrong-prefix", }) require.Error(t, err) require.Nil(t, result) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err)) require.True(t, IsDefinitivePaymentError(err)) }) t.Run("wrong_amount_is_refused", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-4242_9999_ok", CustomerID: "cus_wrong_amount", IdempotencyKey: "sca-binding-wrong-amount", }) require.Error(t, err) require.Nil(t, result) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err)) require.True(t, IsDefinitivePaymentError(err)) }) t.Run("deny_outcome_is_refused", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-4242_5000_deny", CustomerID: "cus_denied", IdempotencyKey: "sca-binding-deny", }) require.Error(t, err) require.Nil(t, result) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err)) require.True(t, IsDefinitivePaymentError(err)) }) t.Run("unparseable_cnon_sca_is_refused", func(t *testing.T) { result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-garbage", CustomerID: "cus_forged", IdempotencyKey: "sca-binding-forged", }) require.Error(t, err) require.Nil(t, result) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err)) require.True(t, IsDefinitivePaymentError(err)) }) t.Run("mock_ledger_card_prefix_is_accepted", func(t *testing.T) { // The frontend derives the token prefix from the SAVED card's ccof id // (first 4 chars after ccof:). A card this mock created in its ledger // ("ccof:mock_...") mints tokens bound to the "mock" prefix — the mock // must recognise that prefix as valid. card, err := client.CreateCardOnFile(ctx, "user-sca-ledger", "cnon:token-ledger", "cus_ledger") require.NoError(t, err) require.True(t, strings.HasPrefix(card.CardID, "ccof:mock_"), "mock-created cards use the ccof:mock_ shape") result, err := client.CreatePayment(ctx, CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:sca-mock_5000_ok", CustomerID: "cus_ledger", IdempotencyKey: "sca-binding-ledger", }) require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status) }) }