//go:build test && dev package square import ( "bytes" "context" "errors" "fmt" "log" "os" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) 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) 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_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, } 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(8000), completed.Amount, "expected amount 8000 (7500 + 500 tip)") assert.Equal(t, int64(500), completed.TipAmount) assert.NotEmpty(t, completed.CardFingerprint) assert.NotEmpty(t, completed.EntryMethod) } 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, } 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", "") 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", "") require.NoError(t, err) card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2", "") 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", "") require.NoError(t, err) err = client.DeleteCardOnFile(ctx, card.ID) require.NoError(t, err) cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) require.Len(t, cards, 1) assert.False(t, cards[0].Enabled) } 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, "") require.Error(t, err, "raw PAN must be rejected for production parity") assert.Nil(t, card) assert.Contains(t, err.Error(), "invalid source_id") }) } } 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_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) } 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", "") 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", "") require.NoError(t, err) err = client.DeleteCardOnFile(ctx, card.ID) require.NoError(t, err) cards, err := client.GetCardsOnFile(ctx, userID) require.NoError(t, err) require.Len(t, cards, 1) assert.False(t, cards[0].Enabled) } 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_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", }) 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", }) require.NoError(t, err) assert.Equal(t, "PENDING", result.Status) _, err = client.GetCheckout(ctx, result.ID) require.Error(t, err) } 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_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", }) 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", }) 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") }) } } 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_ZeroAmountFullRefundWhenPaymentExists(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-zero-refund", ReferenceID: "booking-zero-refund", }) require.NoError(t, err) refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{ PaymentID: paymentResult.ID, Amount: 0, IdempotencyKey: "zero-refund-known", }) require.NoError(t, err) assert.Equal(t, int64(10000), refundResult.Amount, "amount 0 = full refund when the payment exists") } 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) }