Expand payment test coverage: lock contention, nonce-direct, provisional rows, GDPR scrub, validators

Close the coverage-gap round: terminal CreateCheckout-failure marks the provisional row failed, GetCheckoutStatus reference_id mismatch 400, deadline wire shape, concurrent loyalty redemption 409, delete_guest_user + stale-guest saved-card scrubbing, ValidateAmount and isTokenLike direct units, bounded try-lock timeout, buildSplitRecords tip-overflow, and concurrent same-key dedup for gift card / booking / tip / checkout.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 0d22f8d597
commit 738f6b6a51
7 changed files with 710 additions and 18 deletions
+86 -5
View File
@@ -2023,6 +2023,52 @@ func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) {
}
}
func TestBuildSplitRecords_OverflowBeyondTotal_BecomesTip(t *testing.T) {
// £60 on a £50 future booking: deposit caps at £25 (50%), balance covers
// the remaining £25 owed, and the £10 overflow beyond the booking total
// becomes a tip record.
record := makeTestRecord("b5", "full", 60)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildSplitRecords(record, "full", info, 60)
if len(records) != 3 {
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
}
if records[0].PaymentType != "deposit" {
t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType)
}
if records[0].Amount != 25 {
t.Errorf("expected deposit amount 25, got %.2f", records[0].Amount)
}
if records[1].PaymentType != "balance" {
t.Errorf("expected second record 'balance', got %q", records[1].PaymentType)
}
if records[1].Amount != 25 {
t.Errorf("expected balance amount 25, got %.2f", records[1].Amount)
}
if records[2].PaymentType != "tip" {
t.Errorf("expected third record 'tip', got %q", records[2].PaymentType)
}
if records[2].Amount != 10 {
t.Errorf("expected tip amount 10 (overflow beyond the booking total), got %.2f", records[2].Amount)
}
// The tip is a split: zero fees and a derived idempotency key.
if records[2].Fees != 0 {
t.Errorf("expected tip fees=0, got %.2f", records[2].Fees)
}
if *records[2].IdempotencyKey != *records[0].IdempotencyKey+"-split-3" {
t.Errorf("expected tip key derived from primary, got %q", *records[2].IdempotencyKey)
}
// The splits share the Square payment id of the primary record.
if *records[2].SquarePaymentID != *records[0].SquarePaymentID {
t.Error("tip split must share square_payment_id with the primary record")
}
}
// ---------------------------------------------------------------------------
// Handler-level atomicity — verify the full handler succeeds with split.
// ---------------------------------------------------------------------------
@@ -3405,7 +3451,7 @@ func TestSaveCardForUser_Success(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
svc := NewPaymentService()
cardID, err := svc.SaveCardForUser(ctx, userID, "cfa_test_success", "VISA", "4242", 12, 2030, "fp_success")
cardID, err := svc.SaveCardForUser(ctx, userID, "cus_test_success", "cfa_test_success", "VISA", "4242", 12, 2030, "fp_success")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@@ -3418,7 +3464,7 @@ func TestSaveCardForUser_InvalidUserID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.SaveCardForUser(ctx, "000000000001", "cfa_test", "VISA", "4242", 12, 2030, "fp_test")
_, err := svc.SaveCardForUser(ctx, "000000000001", "cus_test_invalid", "cfa_test", "VISA", "4242", 12, 2030, "fp_test")
if err == nil {
t.Error("expected error for non-existent user ID (FK violation)")
}
@@ -3584,8 +3630,6 @@ func TestTerminalPayment_ValidatePaymentTypeFails(t *testing.T) {
}
}
func TestSweepStalePendingPayments_MarksOldFailed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
@@ -3728,7 +3772,6 @@ func TestSavedCardPayment_SweptFailed_Rejected(t *testing.T) {
}
}
// TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep
// also marks stale pending till_sales rows (card payments) as failed, so a
// lost-response till sale can't stay pending past Square's key retention.
@@ -3782,3 +3825,41 @@ func TestSweepStalePendingPayments_CoversTillSales(t *testing.T) {
t.Errorf("expected fresh pending till sale to stay pending, got %q", freshStatus)
}
}
func TestBuildSplitRecords_TipOverflow_SeparateTipRecord(t *testing.T) {
// A £60 payment on a £50 future booking with nothing paid yet:
// deposit = min(60, 25) = £25, balance = min(35, 25) = £25,
// tip = 60 - 25 - 25 = £10 — the overflow becomes a separate tip record.
record := makeTestRecord("b-overflow", "full", 60)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildSplitRecords(record, "full", info, 60)
if len(records) != 3 {
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
}
if records[0].PaymentType != "deposit" || records[0].Amount != 25 {
t.Errorf("expected deposit 25, got %q %.2f", records[0].PaymentType, records[0].Amount)
}
if records[1].PaymentType != "balance" || records[1].Amount != 25 {
t.Errorf("expected balance 25, got %q %.2f", records[1].PaymentType, records[1].Amount)
}
if records[2].PaymentType != "tip" || records[2].Amount != 10 {
t.Errorf("expected tip record 10, got %q %.2f", records[2].PaymentType, records[2].Amount)
}
// Tip is an overflow split — zero fees, derived idempotency key -split-2.
if records[2].Fees != 0 {
t.Errorf("expected tip record fees=0, got %.2f", records[2].Fees)
}
wantKey := *record.IdempotencyKey + "-split-3"
if *records[2].IdempotencyKey != wantKey {
t.Errorf("expected tip key %q, got %q", wantKey, *records[2].IdempotencyKey)
}
// All three share the same SquarePaymentID (one charge, three ledger rows).
if *records[2].SquarePaymentID != *record.SquarePaymentID {
t.Error("tip split must share square_payment_id")
}
}