fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change
Fresh-eyes review round with 6 independent agents (money-safety, concurrency, Square wire parity, security, frontend flow, testing-gaps). Every finding was independently verified against the code before fixing. All backend changes now carry full test suites (10+ new tests, each verified to FAIL without its guard). All 20 packages green, race detector clean. Money-safety: - Gift-card purchase refunds no longer create money: manual refunds of a no-booking (gift-card purchase) payment are rejected with a clear message in the direct handler AND never re-issued by the sweep-resume path (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue). - BuyGiftCard no-client-key fallback: derived deterministically under the advisory lock (pending-row reuse fixes lost-response double-charge; completed-row sequence advance preserves distinct-purchase collapse fix). - Terminal completion is never unrecorded: activeTerminalCheckoutID now calls recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found COMPLETED at Square (previously only marked the row COMPLETED — a lost poll left the payment invisible and unrefundable). - Sweep: provisional tmp- checkout rows are resolved against Square first (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail; ambiguous → leave pending) instead of blind-failing a possibly-live checkout. recordUntrackedTerminalPayment re-checks the booking status (FOR UPDATE) and refuses to record on a cancelled booking, inserting a critical_payment_log admin notification instead. Till-sale post-charge UPDATE now requires status='pending' (no resurrection of a clawed-back sale). Frontend (Svelte 5): - UserPaymentModal keeps CardSelection mounted through processing (bind:this ref + Square iframe survive the loyalty/tokenize awaits) — new-card payments work again. - BookingFlow clears the cached nonce/verification pair on any failure (retry re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid' refetches the booking and reconciles depositPaid so the confirmation gate opens; Back button disabled during processing. - Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip. Square wire parity (mock vs real): - processing_fee sign unified (negated at paymentFromSquare; mock agrees). - SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard. - GetCardsOnFile excludes disabled cards (matches ListCards). - ForcePaymentStatus toggle + tests prove the charge path can't be status-blind. - CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID); completed terminal checkout's payment resolvable by id. Security: - 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction scan reads race-free; concurrent verify+evict tests under -race. - Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or known public placeholders) with openssl rand -hex 32 guidance. - Dockerfile no longer COPYs .env (secrets injected via compose env_file). - SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose fails at config time when missing. Testing gaps closed (each verified to FAIL without its guard): - refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown in both by-key and by-id paths), resolveChargeSource Square-failure branches, structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending), deriveBookingPaymentIdempotencyKey >45-char truncation, webhook findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch, dispute.evidence / terminal.checkout dispatch. Infra: - local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures (previously died silently under ERR_EXIT with hidden output). - Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go gained the missing build tag. Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok), -race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose config valid.
This commit is contained in:
@@ -22,15 +22,18 @@ package square
|
||||
//
|
||||
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
|
||||
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
|
||||
// FailAfterCommit, SimulateCardTokenUsed) that let dev/tests drive Square
|
||||
// failure modes that are otherwise only reachable against the real API.
|
||||
// FailAfterCommit simulates the exact "charged but response lost → same-key
|
||||
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) that let dev/tests
|
||||
// drive Square failure modes that are otherwise only reachable against the real
|
||||
// API. FailAfterCommit simulates the exact "charged but response lost → same-key
|
||||
// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key
|
||||
// and source in the ledgers exactly like a successful charge) and THEN returns
|
||||
// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME
|
||||
// key + SAME source dedups to the committed payment, proving no double charge.
|
||||
// SimulateCardTokenUsed simulates Square's CARD_TOKEN_USED rejection of a card
|
||||
// token (cnon: nonce) reused after a previous save.
|
||||
// SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source
|
||||
// (cnon: nonce) reused after a previous save. ForcePaymentStatus forces
|
||||
// CreatePayment's payment status while returning nil error — the "Square
|
||||
// returned 200 with a non-terminal payment" prod scenario, so a status-blind
|
||||
// handler (records 'completed' on nil error alone) is caught in dev.
|
||||
//
|
||||
// REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to
|
||||
// the real PRODUCTION Square API on an env-string match alone — a typo'd or
|
||||
@@ -111,18 +114,29 @@ type MockClient struct {
|
||||
// committed payment — never a second charge — exercising the retry path
|
||||
// devs hit in prod when Square processes a charge but the response is lost.
|
||||
FailAfterCommit bool
|
||||
// SimulateCardTokenUsed makes CreateCardOnFile enforce Square's
|
||||
// CARD_TOKEN_USED rejection: a card token (cnon: nonce) already used to
|
||||
// create a card on this mock instance is rejected with the same structured
|
||||
// 400 CARD_TOKEN_USED error real Square returns. Off by default — dev/test
|
||||
// flows reuse plain "cnon:test-card"-style tokens across requests, so
|
||||
// enforcement is enabled only in tests that exercise the reused-token
|
||||
// rejection. UsedCardTokens() reports the tokens consumed so far.
|
||||
SimulateCardTokenUsed bool
|
||||
// usedCardTokens records card tokens consumed by CreateCardOnFile while
|
||||
// SimulateCardTokenUsed is enabled (Square consumes a cnon: nonce on card
|
||||
// creation, so reusing it is rejected with CARD_TOKEN_USED).
|
||||
usedCardTokens map[string]bool
|
||||
// SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED
|
||||
// rejection: a card source (cnon: nonce) already used to create a card on
|
||||
// this mock instance is rejected with the same structured 400 SOURCE_USED
|
||||
// error real Square's CreateCard API returns (SOURCE_USED — NOT the
|
||||
// CreatePayment code CARD_TOKEN_USED). Off by default — dev/test flows
|
||||
// reuse plain "cnon:test-card"-style tokens across requests, so enforcement
|
||||
// is enabled only in tests that exercise the reused-source rejection.
|
||||
// UsedSources() reports the sources consumed so far.
|
||||
SimulateSourceUsed bool
|
||||
// usedSources records card sources consumed by CreateCardOnFile while
|
||||
// SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card
|
||||
// creation, so reusing it is rejected with SOURCE_USED).
|
||||
usedSources map[string]bool
|
||||
// ForcePaymentStatus forces CreatePayment's payment status instead of the
|
||||
// default "COMPLETED" (or "APPROVED" for autocomplete=false). When set,
|
||||
// CreatePayment returns a payment carrying the forced status with nil
|
||||
// error — the "Square returned 200 with a non-terminal payment" prod
|
||||
// scenario. It proves a status-blind handler (one that records 'completed'
|
||||
// on nil error alone) is a regression: the client surfaces Status
|
||||
// faithfully (paymentFromSquare never errors on a non-terminal status —
|
||||
// see square_http_client.go), so only the handler's own status check can
|
||||
// catch a FAILED/CANCELED/PENDING/APPROVED payment.
|
||||
ForcePaymentStatus string
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
@@ -211,7 +225,7 @@ func NewDevClient() SquareClient {
|
||||
refundByKey: make(map[string]*RefundResult),
|
||||
customers: make(map[string]*CustomerResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
usedCardTokens: make(map[string]bool),
|
||||
usedSources: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,6 +340,13 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
if req.Autocomplete != nil && !*req.Autocomplete {
|
||||
status = "APPROVED"
|
||||
}
|
||||
if m.ForcePaymentStatus != "" {
|
||||
// Drive the "Square returned 200 with a non-terminal payment" prod
|
||||
// scenario: the payment comes back with a non-default status and nil
|
||||
// error, so a status-blind caller (records 'completed' on nil error
|
||||
// alone) is exposed as a regression.
|
||||
status = m.ForcePaymentStatus
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
tipAmount := int64(0)
|
||||
@@ -343,6 +364,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
}
|
||||
|
||||
paymentID := fmt.Sprintf("pay_mock_%d", now.UnixNano())
|
||||
// Sign-convention parity (finding A): Square reports processing_fee amounts
|
||||
// as NEGATIVE on the wire, and paymentFromSquare negates them so
|
||||
// PaymentResult.Fees is POSITIVE — the value handlers store as p.fees. The
|
||||
// mock fabricates the same positive magnitude directly: online rate 1.4% +
|
||||
// 25p (amount*14/1000+25). Mock and real client must agree on the sign;
|
||||
// see TestProcessingFeeSign_Parity_MockAndRealClientAgree.
|
||||
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
|
||||
|
||||
locationID := req.LocationID
|
||||
@@ -404,6 +431,26 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
||||
if m.FailCreateCheckout {
|
||||
return nil, fmt.Errorf("mock: checkout creation failed (simulated failure)")
|
||||
}
|
||||
// Real Square's TerminalCheckout API REQUIRES device_options.device_id: a
|
||||
// checkout with an empty device id is rejected with a 400. The real client
|
||||
// resolves the per-request device ID with an env fallback
|
||||
// (SQUARE_TERMINAL_DEVICE_ID, square_http_client.go:516); the mock mirrors
|
||||
// the SAME resolution and rejects when neither is set — so a missing
|
||||
// terminal misconfiguration is caught in dev instead of silently
|
||||
// "succeeding" where prod 400s.
|
||||
deviceID := req.DeviceID
|
||||
if deviceID == "" {
|
||||
deviceID = os.Getenv("SQUARE_TERMINAL_DEVICE_ID")
|
||||
}
|
||||
if deviceID == "" {
|
||||
return nil, &squareAPIError{
|
||||
Code: "INVALID_REQUEST_ERROR",
|
||||
Detail: "device_options.device_id is required to create a terminal checkout",
|
||||
Category: "INVALID_REQUEST_ERROR",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: device_options.device_id is required for a terminal checkout (set SQUARE_TERMINAL_DEVICE_ID or pass DeviceID)"),
|
||||
}
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
|
||||
|
||||
now := clock.Now().UTC()
|
||||
@@ -488,6 +535,15 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
||||
ReferenceID: req.ReferenceID,
|
||||
}
|
||||
m.completed[checkoutID] = paymentResult
|
||||
// Real Square registers the terminal payment under its own ID:
|
||||
// GET /v2/payments/{id} succeeds on a completed terminal
|
||||
// checkout's payment in prod, but failed in dev because the
|
||||
// payment was never added to m.payments (finding I). Mirror prod
|
||||
// by registering it under both the ID and SquarePayID keys, exactly
|
||||
// like CreatePayment, so the reconcile/sweep GetPayment path
|
||||
// behaves identically.
|
||||
m.payments[paymentID] = paymentResult
|
||||
m.payments[paymentResult.SquarePayID] = paymentResult
|
||||
m.checkouts[checkoutID].Status = "COMPLETED"
|
||||
m.checkouts[checkoutID].UpdatedAt = payNow.Format(time.RFC3339)
|
||||
m.checkouts[checkoutID].PaymentIDs = []string{paymentID}
|
||||
@@ -697,15 +753,15 @@ func (m *MockClient) RefundKeyCount() int {
|
||||
return len(m.refundByKey)
|
||||
}
|
||||
|
||||
// UsedCardTokens returns the card tokens consumed by CreateCardOnFile while
|
||||
// SimulateCardTokenUsed is enabled. Test accessor for asserting that a reused
|
||||
// token is rejected with CARD_TOKEN_USED after a previous save.
|
||||
func (m *MockClient) UsedCardTokens() []string {
|
||||
// UsedSources returns the card sources consumed by CreateCardOnFile while
|
||||
// SimulateSourceUsed is enabled. Test accessor for asserting that a reused
|
||||
// source is rejected with SOURCE_USED after a previous save.
|
||||
func (m *MockClient) UsedSources() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]string, 0, len(m.usedCardTokens))
|
||||
for tok := range m.usedCardTokens {
|
||||
out = append(out, tok)
|
||||
out := make([]string, 0, len(m.usedSources))
|
||||
for src := range m.usedSources {
|
||||
out = append(out, src)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -738,16 +794,18 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.SimulateCardTokenUsed && m.usedCardTokens[cardToken] {
|
||||
if m.SimulateSourceUsed && m.usedSources[cardToken] {
|
||||
// Real Square consumes a cnon: nonce on card creation — reusing it to
|
||||
// create another card is rejected with CARD_TOKEN_USED. The mock
|
||||
// mirrors that structured 400 rejection (opt-in, see the struct doc).
|
||||
// create another card is rejected with SOURCE_USED (the CreateCard
|
||||
// error; CARD_TOKEN_USED is a CreatePayment code and would be wrong
|
||||
// here). The mock mirrors that structured 400 rejection (opt-in, see
|
||||
// the struct doc).
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_TOKEN_USED",
|
||||
Detail: "The card token has already been used.",
|
||||
Code: "SOURCE_USED",
|
||||
Detail: "The provided source id was already used to create a card",
|
||||
Category: "INVALID_REQUEST_ERROR",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: fmt.Errorf("square: card token %s has already been used", tokenPrefix(cardToken)),
|
||||
err: fmt.Errorf("square: card source %s has already been used to create a card", tokenPrefix(cardToken)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,8 +837,8 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
|
||||
}
|
||||
m.cards[userID][cardID] = card
|
||||
m.cardByToken[card.CardID] = card
|
||||
if m.SimulateCardTokenUsed {
|
||||
m.usedCardTokens[cardToken] = true
|
||||
if m.SimulateSourceUsed {
|
||||
m.usedSources[cardToken] = true
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
|
||||
return card, nil
|
||||
@@ -799,6 +857,13 @@ func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
|
||||
|
||||
var cards []CardOnFile
|
||||
for _, card := range userCards {
|
||||
// Real Square's List Cards API EXCLUDES disabled cards by default
|
||||
// (the client sends no include_disabled param) — a disabled/deleted
|
||||
// card disappears from GetCardsOnFile. Mirror that so dev parity
|
||||
// matches prod (finding C).
|
||||
if !card.Enabled {
|
||||
continue
|
||||
}
|
||||
cards = append(cards, *card)
|
||||
}
|
||||
return cards, nil
|
||||
|
||||
@@ -83,6 +83,7 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
||||
IdempotencyKey: "checkout-key-1",
|
||||
ReferenceID: "booking-456",
|
||||
AllowTipping: true,
|
||||
DeviceID: "dvc_test",
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
@@ -126,6 +127,7 @@ func TestDevClient_CreateCheckout_DeadlineDurationFormat(t *testing.T) {
|
||||
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")
|
||||
@@ -146,6 +148,7 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
||||
IdempotencyKey: "checkout-key-notip",
|
||||
ReferenceID: "booking-789",
|
||||
AllowTipping: false,
|
||||
DeviceID: "dvc_test",
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
@@ -267,11 +270,11 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) {
|
||||
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)
|
||||
|
||||
require.Len(t, cards, 1)
|
||||
assert.False(t, cards[0].Enabled)
|
||||
assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards")
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) {
|
||||
@@ -724,10 +727,11 @@ func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) {
|
||||
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)
|
||||
require.Len(t, cards, 1)
|
||||
assert.False(t, cards[0].Enabled)
|
||||
assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards")
|
||||
}
|
||||
|
||||
func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
|
||||
@@ -900,6 +904,7 @@ func TestDevClient_GetCheckout_StillPending(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "pending-checkout",
|
||||
ReferenceID: "pending-ref",
|
||||
DeviceID: "dvc_test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
@@ -919,6 +924,7 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "hold-checkout",
|
||||
ReferenceID: "hold-ref",
|
||||
DeviceID: "dvc_test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
@@ -940,6 +946,7 @@ func TestDevClient_GetCheckout_ForceInProgress(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "in-progress-checkout",
|
||||
ReferenceID: "in-progress-ref",
|
||||
DeviceID: "dvc_test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "IN_PROGRESS", result.Status)
|
||||
@@ -971,6 +978,7 @@ func TestDevClient_GetCheckout_ForceCancelRequested(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "cancel-requested-checkout",
|
||||
ReferenceID: "cancel-requested-ref",
|
||||
DeviceID: "dvc_test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "CANCEL_REQUESTED", result.Status)
|
||||
@@ -1000,6 +1008,7 @@ func TestDevClient_GetCheckout_ForceCanceled(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "canceled-checkout",
|
||||
ReferenceID: "canceled-ref",
|
||||
DeviceID: "dvc_test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "CANCELED", result.Status)
|
||||
@@ -1228,6 +1237,7 @@ func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "cancel-checkout",
|
||||
ReferenceID: "cancel-ref",
|
||||
DeviceID: "dvc_test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
@@ -1261,6 +1271,7 @@ func TestDevClient_CancelCheckout_CompletedIsNoOp(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "cancel-completed",
|
||||
ReferenceID: "cancel-comp-ref",
|
||||
DeviceID: "dvc_test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1598,39 +1609,41 @@ func TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey(t *testing.T) {
|
||||
assert.Equal(t, "COMPLETED", ok.Status)
|
||||
}
|
||||
|
||||
// TestDevClient_CreateCardOnFile_SimulateCardTokenUsed locks the CARD_TOKEN_USED
|
||||
// simulation: when SimulateCardTokenUsed is enabled, a card token (cnon: nonce)
|
||||
// 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
|
||||
// CARD_TOKEN_USED error. Off by default (dev/test flows reuse plain test
|
||||
// tokens across requests), so the toggle must not reject reuse when disabled.
|
||||
func TestDevClient_CreateCardOnFile_SimulateCardTokenUsed(t *testing.T) {
|
||||
// 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.SimulateCardTokenUsed = true
|
||||
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 token → Square's CARD_TOKEN_USED rejection.
|
||||
// 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, "CARD_TOKEN_USED", ErrorCode(err))
|
||||
assert.Equal(t, "SOURCE_USED", ErrorCode(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedCardTokens())
|
||||
assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedSources())
|
||||
|
||||
// A fresh token still works.
|
||||
// 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 token is allowed — dev/test
|
||||
// With the toggle OFF (default), reusing a source is allowed — dev/test
|
||||
// flows reuse plain "cnon:test-card"-style tokens across requests.
|
||||
client.SimulateCardTokenUsed = false
|
||||
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 SimulateCardTokenUsed off, token reuse must be allowed")
|
||||
require.NoError(t, err, "with SimulateSourceUsed off, source reuse must be allowed")
|
||||
}
|
||||
|
||||
// TestIdempotencyKeyLength_Parity_MockAndRealClientAgree asserts the mock and
|
||||
@@ -1668,37 +1681,38 @@ func TestIdempotencyKeyLength_Parity_MockAndRealClientAgree(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
|
||||
}
|
||||
|
||||
// TestCardTokenUsed_Parity_MockAndRealClientAgree asserts the mock and the
|
||||
// real HTTP client AGREE on the reused-card-token rejection: both surface the
|
||||
// same structured code (CARD_TOKEN_USED) and HTTP status (400).
|
||||
func TestCardTokenUsed_Parity_MockAndRealClientAgree(t *testing.T) {
|
||||
// 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()
|
||||
token := "cnon:reused-nonce"
|
||||
source := "cnon:reused-nonce"
|
||||
|
||||
// Real client: Square's 400 CARD_TOKEN_USED response surfaces as a
|
||||
// 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":"CARD_TOKEN_USED","detail":"The card token has already been used."}]}`))
|
||||
_, _ = 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", token, "cus_1", hc)
|
||||
_, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", source, "cus_1", hc)
|
||||
require.Error(t, realErr)
|
||||
|
||||
// Mock: with the simulation enabled, reusing a consumed token surfaces the
|
||||
// Mock: with the simulation enabled, reusing a consumed source surfaces the
|
||||
// identical structured error.
|
||||
mock := NewDevClient().(*MockClient)
|
||||
mock.SimulateCardTokenUsed = true
|
||||
_, err := mock.CreateCardOnFile(ctx, "user_1", token, "cus_1")
|
||||
mock.SimulateSourceUsed = true
|
||||
_, err := mock.CreateCardOnFile(ctx, "user_1", source, "cus_1")
|
||||
require.NoError(t, err)
|
||||
_, mockErr := mock.CreateCardOnFile(ctx, "user_2", token, "cus_1")
|
||||
_, mockErr := mock.CreateCardOnFile(ctx, "user_2", source, "cus_1")
|
||||
require.Error(t, mockErr)
|
||||
|
||||
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(realErr))
|
||||
assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for a reused card token")
|
||||
assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for a reused card token")
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1726,3 +1740,153 @@ func TestEnvResolution_HelperMatchesHTTPClient(t *testing.T) {
|
||||
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, "INVALID_REQUEST_ERROR", 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)
|
||||
}
|
||||
|
||||
@@ -996,6 +996,19 @@ func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *ht
|
||||
// Conversion helpers — Square JSON → domain types.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// paymentFromSquare maps Square's Payment object into the domain PaymentResult.
|
||||
// Status is copied FAITHFULLY (COMPLETED/APPROVED/PENDING/FAILED/CANCELED are
|
||||
// all surfaced verbatim) — the client deliberately does NOT turn a non-terminal
|
||||
// status into an error. doJSON already parses 2xx bodies without dropping them,
|
||||
// so a 200-with-FAILED payment reaches the caller with nil error and Status
|
||||
// "FAILED". This is intentional: the payments sweep reconciles by id via
|
||||
// GetPayment/ReplayPaymentByKey and SWITCHES on pr.Status
|
||||
// (reconcileStalePaymentAtSquare marks a FAILED/CANCELED payment
|
||||
// definitively-failed, leaves APPROVED/PENDING pending). If the client returned
|
||||
// an error for a FAILED payment, the sweep would classify it as an ambiguous
|
||||
// "leave pending" instead — strictly worse. Handlers must therefore check
|
||||
// Status, not nil-error alone; the mock's ForcePaymentStatus toggle exists so a
|
||||
// status-blind handler regression is exercisable in dev.
|
||||
func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
||||
r := &PaymentResult{
|
||||
ID: sq.ID,
|
||||
@@ -1015,8 +1028,15 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
||||
if sq.TipMoney != nil {
|
||||
r.TipAmount = sq.TipMoney.Amount
|
||||
}
|
||||
// Square reports processing_fee amounts as NEGATIVE (money withheld from the
|
||||
// gross charge) or zero — never positive. PaymentResult.Fees is the POSITIVE
|
||||
// magnitude the handlers store into p.fees (accounting sums a positive
|
||||
// total_square_fees), so the raw negative Square amounts are negated at this
|
||||
// boundary. The dev mock fabricates the same positive magnitude directly, so
|
||||
// mock and real client agree on the sign convention (see
|
||||
// TestProcessingFeeSign_Parity_MockAndRealClientAgree).
|
||||
for _, f := range sq.ProcessingFee {
|
||||
r.Fees += f.AmountMoney.Amount
|
||||
r.Fees += -f.AmountMoney.Amount
|
||||
}
|
||||
if sq.CardDetails != nil {
|
||||
cd := sq.CardDetails
|
||||
|
||||
@@ -1686,3 +1686,100 @@ func TestDeleteCustomerHTTP(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentFromSquare_NegatesProcessingFees locks the processing-fee sign
|
||||
// convention (finding A): Square reports processing_fee amounts as NEGATIVE on
|
||||
// the wire, and paymentFromSquare must surface a POSITIVE PaymentResult.Fees
|
||||
// (the magnitude handlers store as p.fees). A fee of -95 on the wire must
|
||||
// become Fees == 95.
|
||||
func TestPaymentFromSquare_NegatesProcessingFees(t *testing.T) {
|
||||
p := &sqPayment{
|
||||
ID: "pay_fee",
|
||||
Status: "COMPLETED",
|
||||
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
||||
ProcessingFee: []sqFee{
|
||||
{AmountMoney: sqMoney{Amount: -95, Currency: "GBP"}, Type: "INITIAL"},
|
||||
{AmountMoney: sqMoney{Amount: -20, Currency: "GBP"}, Type: "SECONDARY"},
|
||||
},
|
||||
}
|
||||
result := paymentFromSquare(p)
|
||||
if result.Fees != 115 {
|
||||
t.Errorf("expected Fees 115 (sum of negated Square fees), got %d", result.Fees)
|
||||
}
|
||||
|
||||
// A zero fee stays zero.
|
||||
zero := paymentFromSquare(&sqPayment{ID: "pay_zero", Status: "COMPLETED", ProcessingFee: []sqFee{{AmountMoney: sqMoney{Amount: 0, Currency: "GBP"}}}})
|
||||
if zero.Fees != 0 {
|
||||
t.Errorf("expected Fees 0 for a zero fee, got %d", zero.Fees)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentFromSquare_StatusMapping verifies paymentFromSquare maps every
|
||||
// documented Square payment status FAITHFULLY (finding D): APPROVED, PENDING,
|
||||
// FAILED, CANCELED and COMPLETED all flow through into PaymentResult.Status.
|
||||
// The client never downgrades or drops a non-terminal status.
|
||||
func TestPaymentFromSquare_StatusMapping(t *testing.T) {
|
||||
for _, status := range []string{"APPROVED", "PENDING", "FAILED", "CANCELED", "COMPLETED"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
p := &sqPayment{ID: "pay_" + status, Status: status, TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}}
|
||||
result := paymentFromSquare(p)
|
||||
if result.Status != status {
|
||||
t.Errorf("expected Status %q mapped verbatim, got %q", status, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_200WithFailedPayment_NotDropped verifies doJSON does NOT silently
|
||||
// drop a 200-with-FAILED payment (finding D): a 2xx body carrying a FAILED
|
||||
// payment is parsed into a PaymentResult with Status "FAILED" and nil error —
|
||||
// the client surfaces the status faithfully instead of erroring, so a
|
||||
// status-blind handler (records 'completed' on nil error alone) is exposed.
|
||||
func TestDoJSON_200WithFailedPayment_NotDropped(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_failed_200","status":"FAILED","total_money":{"amount":5000,"currency":"GBP"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
res, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-failed-200",
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("a 200-with-FAILED payment must NOT error (the client is status-transparent), got %v", err)
|
||||
}
|
||||
if res.Status != "FAILED" {
|
||||
t.Errorf("expected Status FAILED surfaced faithfully, got %q", res.Status)
|
||||
}
|
||||
if res.ID != "pay_failed_200" {
|
||||
t.Errorf("expected the failed payment returned, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_CardProcessingNotEnabled403 verifies a 403 CARD_PROCESSING_NOT_ENABLED
|
||||
// response surfaces the structured Square error with StatusCode 403 (finding E)
|
||||
// so the handlers agent can special-case it (errors.go, not owned here).
|
||||
func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"CARD_PROCESSING_NOT_ENABLED","detail":"Card processing is not enabled for this account."}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403")
|
||||
}
|
||||
if got := ErrorStatusCode(err); got != http.StatusForbidden {
|
||||
t.Errorf("expected ErrorStatusCode 403, got %d", got)
|
||||
}
|
||||
if got := ErrorCode(err); got != "CARD_PROCESSING_NOT_ENABLED" {
|
||||
t.Errorf("expected ErrorCode CARD_PROCESSING_NOT_ENABLED, got %q", got)
|
||||
}
|
||||
if got := ErrorCategory(err); got != "PAYMENT_METHOD_ERROR" {
|
||||
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user