//go:build test package jobs import ( "context" "crypto/sha256" "encoding/hex" "fmt" "os" "sync" "testing" "crussell/db" "crussell/handlers/payments" "crussell/internal/adminnotify" "crussell/internal/square" "crussell/testutils" "crussell/testutils/testdb" ) func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_jobs") db.Conn = db.NewPoolProxy(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_jobs") os.Exit(code) } // ============================================================ // SweepSquareWebhookEvents Tests // ============================================================ func TestSweepSquareWebhookEvents_DeletesOldRows(t *testing.T) { ctx := context.Background() // Recent event (NOW() — must be preserved) if _, err := db.Conn.Exec(ctx, "INSERT INTO square_webhook_events (event_id, received_at) VALUES ($1, NOW())", "evt_recent"); err != nil { t.Fatalf("failed to insert recent webhook event: %v", err) } // Old event (100 days ago — must be swept) if _, err := db.Conn.Exec(ctx, "INSERT INTO square_webhook_events (event_id, received_at) VALUES ($1, NOW() - INTERVAL '100 days')", "evt_old"); err != nil { t.Fatalf("failed to insert old webhook event: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM square_webhook_events WHERE event_id IN ('evt_recent', 'evt_old')") }) n, err := SweepSquareWebhookEvents(ctx) if err != nil { t.Fatalf("SweepSquareWebhookEvents failed: %v", err) } if n != 1 { t.Errorf("expected 1 row deleted, got %d", n) } // Old row must be gone var oldCount int if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM square_webhook_events WHERE event_id = 'evt_old'").Scan(&oldCount); err != nil { t.Fatalf("failed to count old event: %v", err) } if oldCount != 0 { t.Errorf("expected old event to be deleted, got %d rows", oldCount) } // Recent row must remain var recentCount int if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM square_webhook_events WHERE event_id = 'evt_recent'").Scan(&recentCount); err != nil { t.Fatalf("failed to count recent event: %v", err) } if recentCount != 1 { t.Errorf("expected recent event to be preserved, got %d rows", recentCount) } } func TestSweepSquareWebhookEvents_EmptyTable(t *testing.T) { ctx := context.Background() if _, err := db.Conn.Exec(ctx, "DELETE FROM square_webhook_events"); err != nil { t.Fatalf("failed to clear square_webhook_events: %v", err) } n, err := SweepSquareWebhookEvents(ctx) if err != nil { t.Fatalf("SweepSquareWebhookEvents failed on empty table: %v", err) } if n != 0 { t.Errorf("expected 0 rows deleted on empty table, got %d", n) } } // ============================================================ // ScanCriticalPaymentLogs Tests // ============================================================ // countCriticalPaymentLogs returns the number of 'critical_payment_log' // admin_notifications currently in the shared test database. func countCriticalPaymentLogs(ctx context.Context) int { var n int if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'").Scan(&n); err != nil { return -1 } return n } // seedStalePendingPayment inserts a payments row still 'pending' with no update // past the scan threshold (the "money may have moved at Square but the DB never // recorded it" situation). func seedStalePendingPayment(t *testing.T) string { t.Helper() ctx := context.Background() var id string if err := db.Conn.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at) VALUES (NULL, 'full', 'online_square', 'pending', 50.00, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '3 hours') RETURNING id`).Scan(&id); err != nil { t.Fatalf("failed to seed stale pending payment: %v", err) } return id } // TestScanCriticalPaymentLogs_StalePendingPayment verifies a payments row still // 'pending' with no update past the threshold surfaces one admin notification. func TestScanCriticalPaymentLogs_StalePendingPayment(t *testing.T) { ctx := context.Background() paymentID := seedStalePendingPayment(t) t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) n, err := ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("ScanCriticalPaymentLogs failed: %v", err) } if n != 1 { t.Errorf("expected 1 notification inserted, got %d", n) } if got := countCriticalPaymentLogs(ctx); got != 1 { t.Errorf("expected 1 critical_payment_log notification, got %d", got) } } // TestScanCriticalPaymentLogs_FreshPendingPaymentNotNotified verifies a pending // payment updated within the threshold is NOT surfaced — only rows stuck past // the threshold represent an unresolved money event. func TestScanCriticalPaymentLogs_FreshPendingPaymentNotNotified(t *testing.T) { ctx := context.Background() var paymentID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at) VALUES (NULL, 'full', 'online_square', 'pending', 50.00, NOW(), NOW()) RETURNING id`).Scan(&paymentID); err != nil { t.Fatalf("failed to seed fresh pending payment: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) n, err := ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("ScanCriticalPaymentLogs failed: %v", err) } if n != 0 { t.Errorf("expected 0 notifications for a fresh pending payment, got %d", n) } } // TestScanCriticalPaymentLogs_StalePendingTillSale verifies the till_sales scan // (no booking linkage — the notification carries a NULL booking_id). func TestScanCriticalPaymentLogs_StalePendingTillSale(t *testing.T) { ctx := context.Background() var userID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, phone, date_of_birth) VALUES ('Test', 'Till', '+447700900123', '1990-01-01') RETURNING id`).Scan(&userID); err != nil { t.Fatalf("failed to seed till user: %v", err) } var saleID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at) VALUES ('gift_card', 'gift card', 1, 25.00, 25.00, 'online_square', 'pending', $1, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '3 hours') RETURNING id`, userID).Scan(&saleID); err != nil { t.Fatalf("failed to seed stale pending till sale: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM till_sales WHERE id = $1", saleID) _, _ = db.Conn.Exec(ctx, "DELETE FROM users WHERE id = $1", userID) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) n, err := ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("ScanCriticalPaymentLogs failed: %v", err) } if n != 1 { t.Errorf("expected 1 notification inserted, got %d", n) } } // TestScanCriticalPaymentLogs_DedupAndReArm verifies the dedup guard: a second // scan while an unacknowledged notification exists inserts nothing, but // acknowledging the notification re-arms the scan while the row stays // unresolved (the problem is still present, so it is surfaced again). func TestScanCriticalPaymentLogs_DedupAndReArm(t *testing.T) { ctx := context.Background() paymentID := seedStalePendingPayment(t) t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) n, err := ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("first scan failed: %v", err) } if n != 1 { t.Fatalf("expected 1 notification on first scan, got %d", n) } // Unacknowledged notification present → the second scan must insert nothing. n, err = ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("second scan failed: %v", err) } if n != 0 { t.Errorf("expected 0 notifications on re-scan while unacknowledged, got %d", n) } if got := countCriticalPaymentLogs(ctx); got != 1 { t.Errorf("expected exactly 1 notification after dedup, got %d", got) } // Acknowledging re-arms the scan while the row stays unresolved. if _, err := db.Conn.Exec(ctx, "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log'"); err != nil { t.Fatalf("failed to acknowledge notification: %v", err) } n, err = ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("third scan failed: %v", err) } if n != 1 { t.Errorf("expected 1 notification re-inserted after acknowledge, got %d", n) } if got := countCriticalPaymentLogs(ctx); got != 2 { t.Errorf("expected 2 notifications after re-arm, got %d", got) } } // TestScanCriticalPaymentLogs_RefundAtAttemptCap verifies a refund still // 'pending' at the 3-attempt retry cap surfaces a notification (money may have // moved at Square but the refund outcome was never recorded). func TestScanCriticalPaymentLogs_RefundAtAttemptCap(t *testing.T) { ctx := context.Background() var paymentID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES (NULL, 'full', 'online_square', 'completed', 50.00) RETURNING id`).Scan(&paymentID); err != nil { t.Fatalf("failed to seed payment for refund: %v", err) } var refundID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO refunds (payment_id, amount, status, refund_attempts, reason) VALUES ($1, 10.00, 'pending', 3, 'customer request') RETURNING id`, paymentID).Scan(&refundID); err != nil { t.Fatalf("failed to seed refund at attempt cap: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM refunds WHERE id = $1", refundID) _, _ = db.Conn.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) n, err := ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("ScanCriticalPaymentLogs failed: %v", err) } if n != 1 { t.Errorf("expected 1 notification inserted, got %d", n) } if got := countCriticalPaymentLogs(ctx); got != 1 { t.Errorf("expected 1 critical_payment_log notification, got %d", got) } } // TestScanCriticalPaymentLogs_RefundBelowCapNotNotified verifies only refunds at // the 3-attempt cap are surfaced; a pending refund with retries remaining is // still being worked by the refund sweep. func TestScanCriticalPaymentLogs_RefundBelowCapNotNotified(t *testing.T) { ctx := context.Background() var paymentID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES (NULL, 'full', 'online_square', 'completed', 50.00) RETURNING id`).Scan(&paymentID); err != nil { t.Fatalf("failed to seed payment for refund: %v", err) } var refundID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO refunds (payment_id, amount, status, refund_attempts, reason) VALUES ($1, 10.00, 'pending', 1, 'customer request') RETURNING id`, paymentID).Scan(&refundID); err != nil { t.Fatalf("failed to seed refund below attempt cap: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM refunds WHERE id = $1", refundID) _, _ = db.Conn.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) n, err := ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("ScanCriticalPaymentLogs failed: %v", err) } if n != 0 { t.Errorf("expected 0 notifications for a refund below the attempt cap, got %d", n) } } // TestScanCriticalPaymentLogs_FloodCapped verifies the shared // 'critical_payment_log' flood cap (Round 2 Loop B finding 1): once the // unacknowledged queue reaches adminnotify.MaxUnacknowledgedCriticalLogs, the // scan inserts nothing, and acknowledging rows re-arms it. func TestScanCriticalPaymentLogs_FloodCapped(t *testing.T) { ctx := context.Background() paymentID := seedStalePendingPayment(t) t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) // Fill the unacknowledged queue to the cap. for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ { if _, err := db.Conn.Exec(ctx, ` INSERT INTO admin_notifications (reason, created_at) VALUES ('critical_payment_log', NOW()) `); err != nil { t.Fatalf("failed to fill the unacknowledged queue to the cap: %v", err) } } if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { t.Fatal("expected the unacknowledged critical_payment_log queue to be at the cap") } // At the cap the scan must insert nothing for the stale pending payment. n, err := ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("ScanCriticalPaymentLogs failed: %v", err) } if n != 0 { t.Errorf("expected 0 notifications inserted at the cap, got %d", n) } var got int if err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL`).Scan(&got); err != nil { t.Fatalf("failed to count unacknowledged notifications: %v", err) } if got != adminnotify.MaxUnacknowledgedCriticalLogs { t.Errorf("expected the queue to stay capped at %d, got %d", adminnotify.MaxUnacknowledgedCriticalLogs, got) } // Acknowledging re-arms the scan while the row stays unresolved. if _, err := db.Conn.Exec(ctx, "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL"); err != nil { t.Fatalf("failed to acknowledge the queue: %v", err) } n, err = ScanCriticalPaymentLogs(ctx) if err != nil { t.Fatalf("ScanCriticalPaymentLogs failed after acknowledge: %v", err) } if n != 1 { t.Errorf("expected 1 notification re-inserted after acknowledge, got %d", n) } } // ============================================================ // RetryPendingSquareErasures — GDPR Square outbox job (batch-1 fix) // ============================================================ // recordingErasureClient embeds the dev mock and records every Square erasure // call, with opt-in failure injection, so tests can assert exactly what the // retry-square-erasures job calls (and doesn't call) at Square. type recordingErasureClient struct { square.SquareClient mu sync.Mutex deletedCards []string deletedCustomers []string failCards bool failCustomers bool } func (c *recordingErasureClient) DeleteCardOnFile(ctx context.Context, cardID string) error { c.mu.Lock() c.deletedCards = append(c.deletedCards, cardID) c.mu.Unlock() if c.failCards { return fmt.Errorf("square: simulated card erasure failure") } return c.SquareClient.DeleteCardOnFile(ctx, cardID) } func (c *recordingErasureClient) DeleteCustomer(ctx context.Context, customerID string) error { c.mu.Lock() c.deletedCustomers = append(c.deletedCustomers, customerID) c.mu.Unlock() if c.failCustomers { return fmt.Errorf("square: simulated customer erasure failure") } return c.SquareClient.DeleteCustomer(ctx, customerID) } func (c *recordingErasureClient) cardDeletes() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.deletedCards...) } func (c *recordingErasureClient) customerDeletes() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.deletedCustomers...) } // newErasureTestClient builds a recording client over a prod-safe in-memory // Square stand-in (testutils.NewTestSquareClient compiles under both dev and // prod build tags, unlike the dev-only square.NewDevClient), forcing // SQUARE_ENVIRONMENT=mock so a developer's production env var can never panic // a dev NewDevClient mid-test. func newErasureTestClient(t *testing.T) *recordingErasureClient { t.Helper() t.Setenv("SQUARE_ENVIRONMENT", "mock") return &recordingErasureClient{SquareClient: testutils.NewTestSquareClient()} } // seedErasureOutboxRow inserts a soft-deleted user_saved_cards row that the // retry-square-erasures job treats as a pending Square erasure outbox entry // (deleted_at set + last_4 = 'XXXX' + at least one Square reference). Cleanup // removes the row and any critical notifications the job raised. func seedErasureOutboxRow(t *testing.T, squareCardID, squareCustomerID *string) string { t.Helper() ctx := context.Background() var cardID, customerID any if squareCardID != nil { cardID = *squareCardID } if squareCustomerID != nil { customerID = *squareCustomerID } var id string if err := db.Conn.QueryRow(ctx, ` INSERT INTO user_saved_cards (square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, deleted_at) VALUES ($1, $2, 'VISA', 'XXXX', 12, 2030, NOW()) RETURNING id `, cardID, customerID).Scan(&id); err != nil { t.Fatalf("failed to seed erasure outbox row: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", id) _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") }) return id } // querySquareCardID returns the square_card_id of a row, or nil when NULL. func querySquareCardID(ctx context.Context, t *testing.T, rowID string) *string { t.Helper() var id *string if err := db.Conn.QueryRow(ctx, "SELECT square_card_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil { t.Fatalf("failed to query square_card_id for row %s: %v", rowID, err) } return id } func querySquareCustomerID(ctx context.Context, t *testing.T, rowID string) *string { t.Helper() var id *string if err := db.Conn.QueryRow(ctx, "SELECT square_customer_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil { t.Fatalf("failed to query square_customer_id for row %s: %v", rowID, err) } return id } // erasureNotificationID mirrors handlers/user's deterministic notification id // scheme so the test can assert the exact alert row the job raised. func erasureNotificationID(key string) string { sum := sha256.Sum256([]byte("square-erasure-failure:" + key)) return "S" + hex.EncodeToString(sum[:])[:11] } // TestRetryPendingSquareErasures_DrainsCardOutboxRow verifies a pending card // erasure is retried at Square and, on success, the outbox row is drained // (square_card_id NULLed) and reported in the drained count. func TestRetryPendingSquareErasures_DrainsCardOutboxRow(t *testing.T) { ctx := context.Background() client := newErasureTestClient(t) card, err := client.CreateCardOnFile(ctx, "user-delete-me", "cnon:test-card", "cus_mock_seed") if err != nil { t.Fatalf("failed to seed mock card: %v", err) } cardID := card.CardID rowID := seedErasureOutboxRow(t, &cardID, nil) orig := payments.SquareClient payments.SquareClient = client t.Cleanup(func() { payments.SquareClient = orig }) n, err := RetryPendingSquareErasures(ctx) if err != nil { t.Fatalf("RetryPendingSquareErasures failed: %v", err) } if n != 1 { t.Errorf("expected 1 drained row, got %d", n) } if got := client.cardDeletes(); len(got) != 1 || got[0] != cardID { t.Errorf("expected exactly 1 card deletion call for %q, got %v", cardID, got) } if id := querySquareCardID(ctx, t, rowID); id != nil { t.Errorf("expected square_card_id to be NULL after drain, got %q", *id) } } // TestRetryPendingSquareErasures_DrainsCustomerOutboxRow verifies the same // drain for a customer-only outbox row. func TestRetryPendingSquareErasures_DrainsCustomerOutboxRow(t *testing.T) { ctx := context.Background() client := newErasureTestClient(t) cust, err := client.CreateCustomer(ctx, "Erasure Test", "erasure-test@example.com") if err != nil { t.Fatalf("failed to seed mock customer: %v", err) } customerID := cust.ID rowID := seedErasureOutboxRow(t, nil, &customerID) orig := payments.SquareClient payments.SquareClient = client t.Cleanup(func() { payments.SquareClient = orig }) n, err := RetryPendingSquareErasures(ctx) if err != nil { t.Fatalf("RetryPendingSquareErasures failed: %v", err) } if n != 1 { t.Errorf("expected 1 drained row, got %d", n) } if got := client.customerDeletes(); len(got) != 1 || got[0] != customerID { t.Errorf("expected exactly 1 customer deletion call for %q, got %v", customerID, got) } if id := querySquareCustomerID(ctx, t, rowID); id != nil { t.Errorf("expected square_customer_id to be NULL after drain, got %q", *id) } } // TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry verifies a failed // Square deletion leaves the outbox row armed for the next run and raises a // deduped critical notification (row-scoped key). func TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry(t *testing.T) { ctx := context.Background() client := newErasureTestClient(t) client.failCards = true cardID := "ccof:mock_missing_card" rowID := seedErasureOutboxRow(t, &cardID, nil) orig := payments.SquareClient payments.SquareClient = client t.Cleanup(func() { payments.SquareClient = orig }) n, err := RetryPendingSquareErasures(ctx) if err != nil { t.Fatalf("RetryPendingSquareErasures failed: %v", err) } if n != 0 { t.Errorf("expected 0 drained rows on failure, got %d", n) } if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID { t.Errorf("expected square_card_id %q to be retained for retry, got %v", cardID, id) } var gotID string if err := db.Conn.QueryRow(ctx, "SELECT id FROM admin_notifications WHERE reason = 'critical_payment_log'").Scan(&gotID); err != nil { t.Fatalf("expected a critical_payment_log notification to be raised: %v", err) } if want := erasureNotificationID("row:" + rowID); gotID != want { t.Errorf("expected notification id %q, got %q", want, gotID) } } // TestRetryPendingSquareErasures_NoOpWithoutSquareClient verifies the job is a // no-op when no Square client is configured: no external call, no drain, no // error. func TestRetryPendingSquareErasures_NoOpWithoutSquareClient(t *testing.T) { ctx := context.Background() cardID := "ccof:mock_card" rowID := seedErasureOutboxRow(t, &cardID, nil) orig := payments.SquareClient payments.SquareClient = nil t.Cleanup(func() { payments.SquareClient = orig }) n, err := RetryPendingSquareErasures(ctx) if err != nil { t.Fatalf("RetryPendingSquareErasures failed: %v", err) } if n != 0 { t.Errorf("expected 0 drained rows without a Square client, got %d", n) } if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID { t.Errorf("expected outbox row to be untouched, got square_card_id %v", id) } } // TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard // verifies a shared Square customer is NOT deleted (and its outbox row is // drained as deliberately-skipped) while any active card of another account // still references it. func TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard(t *testing.T) { ctx := context.Background() client := newErasureTestClient(t) cust, err := client.CreateCustomer(ctx, "Shared User", "shared-erasure@example.com") if err != nil { t.Fatalf("failed to seed mock customer: %v", err) } customerID := cust.ID outboxRowID := seedErasureOutboxRow(t, nil, &customerID) var activeUserID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, phone, date_of_birth) VALUES ('Active', 'User', '+447700900127', '1990-01-01') RETURNING id`).Scan(&activeUserID); err != nil { t.Fatalf("failed to seed active user: %v", err) } var activeRowID string if err := db.Conn.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year) VALUES ($1, 'ccof:mock_active', $2, 'VISA', '4242', 12, 2030) RETURNING id`, activeUserID, customerID).Scan(&activeRowID); err != nil { t.Fatalf("failed to seed active card row: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", activeRowID) _, _ = db.Conn.Exec(ctx, "DELETE FROM users WHERE id = $1", activeUserID) }) orig := payments.SquareClient payments.SquareClient = client t.Cleanup(func() { payments.SquareClient = orig }) n, err := RetryPendingSquareErasures(ctx) if err != nil { t.Fatalf("RetryPendingSquareErasures failed: %v", err) } if n != 1 { t.Errorf("expected 1 drained outbox row, got %d", n) } if got := client.customerDeletes(); len(got) != 0 { t.Errorf("expected NO DeleteCustomer call for a still-referenced customer, got %v", got) } if id := querySquareCustomerID(ctx, t, outboxRowID); id != nil { t.Errorf("expected outbox row square_customer_id to be drained, got %q", *id) } if id := querySquareCustomerID(ctx, t, activeRowID); id == nil || *id != customerID { t.Errorf("expected active row to keep its customer reference, got %v", id) } }