//go:build test && dev package square import ( "context" "errors" "fmt" "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) assert.Equal(t, 12, result.ExpMonth) 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", TipEnabled: 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", TipEnabled: 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) assert.Equal(t, userID, 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) }) } }