- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows - giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund) - sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge - webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync - account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
428 lines
18 KiB
Go
428 lines
18 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Round 10 — adversarial sweep tests
|
|
// =============================================================================
|
|
//
|
|
// 1. BUG 1 (MAJOR): the sweep's payments-table rescue aligned the primary row
|
|
// to the split amount but did NOT clear the pending row's VAT fields, and
|
|
// apply_vat_to_payment is guarded on vat_amount IS NULL — so the re-apply
|
|
// was a silent no-op and the rescued row kept VAT computed on the FULL
|
|
// pre-split charge (a larger base than the split primary). The all-tip
|
|
// rescue was worse: the completed primary row carried VAT on the whole
|
|
// tip, violating the tip-never-VAT invariant.
|
|
// 2. BUG 2 (MAJOR): a keyless till-sale retry locks the derived BASE key while
|
|
// its slot scan resolves a SUFFIXED final key that becomes the STORED key
|
|
// the sweep locks — the sweep and the retry do not serialize. If the retry
|
|
// resolves the stale sale (completes it, or re-keys it) between the sweep's
|
|
// fetch and its lock acquisition, the sweep must SKIP the row instead of
|
|
// failing/clawing back.
|
|
|
|
// flipTillSaleClient simulates a same-key till-sale retry racing the sweep: it
|
|
// mutates the stale sale's row inside ReplayPaymentByKey (the window between
|
|
// the sweep's fetch and its lock acquisition) and then answers the probe with
|
|
// the definitive no-charge rejection that would normally drive the sweep's
|
|
// fail/clawback. Used with a cnon source so the rejection classifies as
|
|
// staleReconcileDefinitivelyFailed (a ccof source would take the A1
|
|
// leave-pending path instead).
|
|
type flipTillSaleClient struct {
|
|
square.SquareClient
|
|
t *testing.T
|
|
saleID string
|
|
// flipTo is the status to set on the row before answering the probe
|
|
// ("" = leave the status unchanged).
|
|
flipTo string
|
|
// newKey is the idempotency_key to set on the row before answering the
|
|
// probe ("" = leave the stored key unchanged).
|
|
newKey string
|
|
}
|
|
|
|
func (c *flipTillSaleClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*square.PaymentResult, error) {
|
|
c.t.Helper()
|
|
if c.newKey != "" {
|
|
if _, err := db.Conn.Exec(ctx, `UPDATE till_sales SET idempotency_key = $1, updated_at = NOW() WHERE id = $2`, c.newKey, c.saleID); err != nil {
|
|
c.t.Fatalf("failed to re-key till sale %s during the sweep reconcile: %v", c.saleID, err)
|
|
}
|
|
}
|
|
if c.flipTo != "" {
|
|
if _, err := db.Conn.Exec(ctx, `UPDATE till_sales SET status = $1, updated_at = NOW() WHERE id = $2`, c.flipTo, c.saleID); err != nil {
|
|
c.t.Fatalf("failed to flip till sale %s during the sweep reconcile: %v", c.saleID, err)
|
|
}
|
|
}
|
|
return nil, square.ErrReplayKeyNotRetained
|
|
}
|
|
|
|
// seedKeyedStaleTillSale ages a seedStaleTillSaleWithCard sale to 23h (inside
|
|
// Square's key-retention window so the keyed pass replays it) with a stored
|
|
// idempotency key and a chargeable cnon source.
|
|
func seedKeyedStaleTillSale(t *testing.T, ctx context.Context, q db.Querier, adminID, key string) (saleID, giftCardID string) {
|
|
t.Helper()
|
|
saleID, giftCardID = seedStaleTillSaleWithCard(t, ctx, q, adminID, 50.00, "", true)
|
|
if _, err := q.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, saleID); err != nil {
|
|
t.Fatalf("failed to age the keyed till sale: %v", err)
|
|
}
|
|
if _, err := q.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
|
|
t.Fatalf("failed to age the gift card: %v", err)
|
|
}
|
|
return saleID, giftCardID
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_RescuedSplitPrimary_VATOnSplitAmount locks
|
|
// BUG 1's payments-table rescue VAT fix: a VAT-registered booking's deposit
|
|
// charge that COMPLETED at Square but whose response was lost is rescued by the
|
|
// sweep, the primary row is aligned to the split booking portion, and its VAT
|
|
// is recomputed on the SPLIT amount — not carried over from the full pre-split
|
|
// charge the pending insert taxed. Pre-fix the align left vat_amount from the
|
|
// full £80 charge (13.33) on a row aligned to the £50 split (correct 8.33).
|
|
func TestSweepStalePendingPayments_RescuedSplitPrimary_VATOnSplitAmount(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
// Post-start £50 booking: the £80 rescued charge partitions into a £50
|
|
// booking portion (the aligned primary) + a £30 overflow tip
|
|
// (buildSplitRecords' post-start carve).
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(-2*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
|
|
t.Fatalf("failed to enable VAT in business_settings: %v", err)
|
|
}
|
|
|
|
const key = "key-round10-vat-split"
|
|
var payID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_by, created_at, updated_at)
|
|
VALUES ($1, 'deposit', 'online_square', 'pending', 80.00, $2, 'cnon:test-card', $3, NOW() - INTERVAL '23 hours', NOW())
|
|
RETURNING id
|
|
`, bookingID, key, userID).Scan(&payID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed stale keyed pending payment: %v", err)
|
|
}
|
|
// Step-1 path parity: the pending insert applied VAT on the FULL pre-split
|
|
// £80 charge → vat_amount 13.33. The rescue must recompute on the split £50.
|
|
ApplyVATToBookingPayment(ctx, tx, payID)
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 8000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: key,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
|
}
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
|
|
})
|
|
|
|
pool := context.Background()
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status, ptype string
|
|
var amount float64
|
|
var isVAT bool
|
|
var vatAmount, vatRate, netAmount sql.NullFloat64
|
|
if err := db.Conn.QueryRow(pool, `SELECT status, payment_type, amount, is_vat_applicable, vat_amount, vat_rate, net_amount FROM payments WHERE id = $1`, payID).Scan(&status, &ptype, &amount, &isVAT, &vatAmount, &vatRate, &netAmount); err != nil {
|
|
t.Fatalf("failed to query rescued payment: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected the genuinely-charged stale payment rescued to 'completed', got %q", status)
|
|
}
|
|
if ptype != "deposit" {
|
|
t.Errorf("expected the primary row aligned to the split deposit type, got %q", ptype)
|
|
}
|
|
if amount != 50.00 {
|
|
t.Errorf("expected the primary row aligned to the £50 split booking portion, got %.2f", amount)
|
|
}
|
|
if !isVAT {
|
|
t.Errorf("expected is_vat_applicable=TRUE on the rescue-recomputed primary, got false")
|
|
}
|
|
if !vatAmount.Valid || vatAmount.Float64 != 8.33 {
|
|
t.Errorf("expected vat_amount 8.33 (VAT on the £50 split, not 13.33 on the full £80), got %v", vatAmount)
|
|
}
|
|
if !netAmount.Valid || netAmount.Float64 != 41.67 {
|
|
t.Errorf("expected net_amount 41.67 on the £50 split primary, got %v", netAmount)
|
|
}
|
|
if !vatRate.Valid || vatRate.Float64 != 20.00 {
|
|
t.Errorf("expected vat_rate 20.00 on the rescue-recomputed primary, got %v", vatRate)
|
|
}
|
|
if pay.SquarePayID == "" {
|
|
t.Errorf("expected the mock charge to carry a square payment id")
|
|
}
|
|
// The carved £30 tip record is inserted and must carry NO VAT.
|
|
var tipCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed' AND vat_amount IS NULL`, bookingID).Scan(&tipCount); err != nil {
|
|
t.Fatalf("failed to count the carved tip record: %v", err)
|
|
}
|
|
if tipCount != 1 {
|
|
t.Errorf("expected exactly one VAT-free carved tip record, got %d", tipCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_RescuedAllTip_VATNull locks BUG 1's all-tip
|
|
// edge: a fully-paid booking's £55 overflow rescue carves the ENTIRE charge as
|
|
// the tip record, the primary row is aligned to it, and a tip must NEVER carry
|
|
// VAT. Pre-fix the completed primary kept vat_amount 9.17 (VAT on the £55 tip)
|
|
// because the align did not clear it and the tip guard had nothing to skip.
|
|
func TestSweepStalePendingPayments_RescuedAllTip_VATNull(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
// Pre-start £50 booking already fully paid by a completed £50 payment: the
|
|
// rescued £55 charge has zero booking obligation left, so buildSplitRecords
|
|
// carves the ENTIRE charge as the tip record (the tip-only branch).
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
if _, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed"); err != nil {
|
|
t.Fatalf("failed to fully pay the booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
|
|
t.Fatalf("failed to enable VAT in business_settings: %v", err)
|
|
}
|
|
|
|
const key = "key-round10-all-tip"
|
|
var payID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_by, created_at, updated_at)
|
|
VALUES ($1, 'deposit', 'online_square', 'pending', 55.00, $2, 'cnon:test-card', $3, NOW() - INTERVAL '23 hours', NOW())
|
|
RETURNING id
|
|
`, bookingID, key, userID).Scan(&payID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed stale keyed pending payment: %v", err)
|
|
}
|
|
// Step-1 path parity: the pending insert computed VAT on the full £55 →
|
|
// vat_amount 9.17. The pre-fix rescue left that on the tip-aligned primary.
|
|
ApplyVATToBookingPayment(ctx, tx, payID)
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
if _, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 5500,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: key,
|
|
}); err != nil {
|
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
|
}
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
|
|
})
|
|
|
|
pool := context.Background()
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status, ptype string
|
|
var amount float64
|
|
var isVAT bool
|
|
var vatAmount, vatRate, netAmount sql.NullFloat64
|
|
if err := db.Conn.QueryRow(pool, `SELECT status, payment_type, amount, is_vat_applicable, vat_amount, vat_rate, net_amount FROM payments WHERE id = $1`, payID).Scan(&status, &ptype, &amount, &isVAT, &vatAmount, &vatRate, &netAmount); err != nil {
|
|
t.Fatalf("failed to query rescued payment: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected the all-tip charge rescued to 'completed', got %q", status)
|
|
}
|
|
if ptype != "tip" {
|
|
t.Errorf("expected the all-tip primary row aligned to the carved tip record, got %q", ptype)
|
|
}
|
|
if amount != 55.00 {
|
|
t.Errorf("expected the primary row aligned to the full £55 tip amount, got %.2f", amount)
|
|
}
|
|
if isVAT {
|
|
t.Errorf("expected is_vat_applicable=FALSE on the all-tip rescue (a tip never carries VAT), got true")
|
|
}
|
|
if vatAmount.Valid {
|
|
t.Errorf("expected vat_amount NULL on the all-tip rescue (a tip never carries VAT), got %v", vatAmount)
|
|
}
|
|
if vatRate.Valid {
|
|
t.Errorf("expected vat_rate NULL on the all-tip rescue, got %v", vatRate)
|
|
}
|
|
if netAmount.Valid {
|
|
t.Errorf("expected net_amount NULL on the all-tip rescue, got %v", netAmount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_TillStatusFlippedAfterFetch_SkipsClawback locks
|
|
// BUG 2's re-read: a stale till sale whose row status flips to 'completed'
|
|
// between the sweep's fetch/reconcile and its lock acquisition (a same-key
|
|
// retry landed its charge while the sweep waited) must be SKIPPED — the sweep
|
|
// must not fail the sale or claw back the funded gift card.
|
|
func TestSweepStalePendingPayments_TillStatusFlippedAfterFetch_SkipsClawback(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
pool := context.Background()
|
|
|
|
saleID, giftCardID := seedKeyedStaleTillSale(t, ctx, tx, adminID, "key-round10-status-flip")
|
|
|
|
// The reconcile probe answers "no payment under the stored key" BUT flips
|
|
// the row to 'completed' first — the retry completed the sale while the
|
|
// sweep's probe was in flight, before the sweep's lock acquisition.
|
|
origClient := SquareClient
|
|
SquareClient = &flipTillSaleClient{SquareClient: square.NewDevClient(), t: t, saleID: saleID, flipTo: "completed"}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit setup tx: %v", err)
|
|
}
|
|
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// The sweep must leave the retry-completed row alone.
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected the retry-completed till sale left 'completed' (sweep skipped), got %q", status)
|
|
}
|
|
|
|
// The funded gift card must NOT have been clawed back (deleted).
|
|
var cardCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
|
|
t.Fatalf("failed to count gift cards: %v", err)
|
|
}
|
|
if cardCount != 1 {
|
|
t.Errorf("expected the funded gift card untouched when the row flipped to completed mid-sweep, got %d cards", cardCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_TillKeyChangedWhilePending_SkipsClawback locks
|
|
// the re-read's key-change check — the genuinely money-saving half of BUG 2: a
|
|
// stale till sale whose STORED idempotency key is rewritten while the row stays
|
|
// 'pending' between the sweep's fetch and its lock acquisition. The sweep's
|
|
// lock (on the stale key) no longer matches the row the retry is charging
|
|
// under, so the fail/clawback must be skipped: pre-fix the clawback's
|
|
// status='pending' claim would still match and DELETE the funded gift card +
|
|
// fail the sale while the retry's charge is mid-flight under the new key.
|
|
func TestSweepStalePendingPayments_TillKeyChangedWhilePending_SkipsClawback(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
pool := context.Background()
|
|
|
|
saleID, giftCardID := seedKeyedStaleTillSale(t, ctx, tx, adminID, "key-round10-key-change")
|
|
|
|
// The reconcile probe answers "no payment under the stored key" BUT rewrites
|
|
// the row's stored key first (still 'pending') — a same-key retry re-scanned
|
|
// its slot and is charging under the NEW key while the sweep holds a lock on
|
|
// the stale one.
|
|
const newKey = "key-round10-key-change-new"
|
|
origClient := SquareClient
|
|
SquareClient = &flipTillSaleClient{SquareClient: square.NewDevClient(), t: t, saleID: saleID, newKey: newKey}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit setup tx: %v", err)
|
|
}
|
|
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// The sweep must leave the still-pending row alone (it is reconciled again
|
|
// next sweep under its current key) — never claimed to 'failed'.
|
|
var status, storedKey string
|
|
if err := db.Conn.QueryRow(pool, `SELECT status, idempotency_key FROM till_sales WHERE id = $1`, saleID).Scan(&status, &storedKey); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected the re-keyed till sale left 'pending' (sweep skipped the fail/clawback), got %q", status)
|
|
}
|
|
if storedKey != newKey {
|
|
t.Errorf("expected the till sale's stored key untouched at %q, got %q", newKey, storedKey)
|
|
}
|
|
|
|
// The funded gift card must NOT have been clawed back (deleted).
|
|
var cardCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
|
|
t.Fatalf("failed to count gift cards: %v", err)
|
|
}
|
|
if cardCount != 1 {
|
|
t.Errorf("expected the funded gift card untouched when the stored key changed mid-sweep, got %d cards", cardCount)
|
|
}
|
|
}
|