The CURRENT saved-card SCA contract (Square card.tokenize(verificationDetails, cardId)) returns a one-time tokenize-result that must be sent as the charge SOURCE (source_id), not a separate verification_token. - square_dev.go: the mock validates the WIRE BODY (mockPaymentWireBody — an independently assembled copy of buildCreatePaymentBody) so it accepts exactly the request shape the real client emits. SimulateSavedCardVerificationRequired now demands SCA on every saved-card charge in both wire shapes: (a) a genuine tokenize-result (cnon:sca-... — isSCATokenizeResultSource) as source_id + customer_id is ACCEPTED (the token IS the buyer verification); a RAW card.tokenize() nonce in the tokenize-result slot is REJECTED CARD_DECLINED_VERIFICATION_REQUIRED (money-F2 — the mock is the enforcement point that stops the forged shape); (b) legacy ccof: + verification_token is kept for backward-compat. - square_http_client.go: byte-identical body assembly shared with the mock, so TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical pins the mock and the real client emit identical CreatePayment bodies (a wire drift fails the test before reaching prod).
2890 lines
114 KiB
Go
2890 lines
114 KiB
Go
//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_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_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 — the mock reconciles: no refund
|
|
// recorded yet → the amount is genuinely invalid → ErrRefundDeclined; an
|
|
// existing refund (money already moved) → ErrRefundAlreadyProcessed. 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_with_existing_refund_is_already_processed", 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)
|
|
|
|
result, err := client.RefundPayment(ctx, RefundPaymentReq{
|
|
PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-existing",
|
|
})
|
|
require.Error(t, err)
|
|
assert.Nil(t, result)
|
|
// The REFUND_AMOUNT_INVALID squareAPIError is hidden behind the %v
|
|
// sentinel wrap (ErrorCode returns ""), so the observable contract is
|
|
// the ErrRefundAlreadyProcessed sentinel.
|
|
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund on a payment that already has refunds 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))
|
|
assert.Equal(t, http.StatusBadRequest, 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_<prefix>_<amount>_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))
|
|
assert.Equal(t, http.StatusBadRequest, 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_<prefix>_<amount>
|
|
// 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_<prefix>_<amount>
|
|
// [_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_<prefix>_<amount>_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-tokenize-result",
|
|
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-tokenize-result", 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-tokenize-1", 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-both-on", 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))
|
|
require.Equal(t, http.StatusBadRequest, 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-tokenize-fresh", 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-single-use-booking"
|
|
|
|
// 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")
|
|
}
|