Files
Crussell/backend/internal/jobs/cleanup_test.go
T
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

820 lines
30 KiB
Go

//go:build test
package jobs
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"sync"
"testing"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/adminnotify"
"crussell/internal/s3"
"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)
}
}
// ============================================================
// RetryPendingS3Deletions — S3/R2 outbox job (FIX 2)
// ============================================================
// failingS3Uploader always fails Delete with an over-long error so tests can
// verify the retry cap and last_error truncation.
type failingS3Uploader struct{}
func (failingS3Uploader) Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error {
return nil
}
func (failingS3Uploader) Download(ctx context.Context, bucket, key string, w io.Writer) error {
return nil
}
func (failingS3Uploader) Delete(ctx context.Context, bucket, key string) error {
return fmt.Errorf("simulated persistent S3 deletion failure: this error message is deliberately much longer than two hundred characters so the truncation bound in RetryPendingS3Deletions must cut it off; otherwise the last_error column grows without bound across every hourly retry run")
}
func (failingS3Uploader) GetURL(ctx context.Context, bucket, key string) (string, error) {
return "", nil
}
func (failingS3Uploader) HealthCheck(ctx context.Context) error {
return nil
}
// TestRetryPendingS3Deletions_AttemptCapRaisesNotification verifies FIX 2: a
// pending S3 deletion outbox row that fails maxS3DeletionRetries consecutive
// times stops being retried (the job's query filters attempts < cap), bumps
// attempts to the cap, truncates last_error to 200 chars, and raises the
// deduped critical admin notification so the operator investigates.
func TestRetryPendingS3Deletions_AttemptCapRaisesNotification(t *testing.T) {
ctx := context.Background()
var rowID string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO pending_s3_deletions (user_id, bucket, object_key, attempts, last_error)
VALUES (NULL, 'test-bucket', 'profiles/test.jpg', 9, 'previous error')
RETURNING id
`).Scan(&rowID); err != nil {
t.Fatalf("failed to seed pending S3 deletion: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM pending_s3_deletions WHERE id = $1", rowID)
_, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'")
})
origClient := s3.Client
s3.Client = failingS3Uploader{}
t.Cleanup(func() { s3.Client = origClient })
n, err := RetryPendingS3Deletions(ctx)
if err != nil {
t.Fatalf("RetryPendingS3Deletions failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 drained rows on persistent failure, got %d", n)
}
var attempts int
var lastError string
if err := db.Conn.QueryRow(ctx, `
SELECT attempts, last_error FROM pending_s3_deletions WHERE id = $1
`, rowID).Scan(&attempts, &lastError); err != nil {
t.Fatalf("failed to query outbox row: %v", err)
}
if attempts != maxS3DeletionRetries {
t.Errorf("expected attempts capped at %d, got %d", maxS3DeletionRetries, attempts)
}
if len(lastError) > 200 {
t.Errorf("expected last_error truncated to 200 chars, got %d", len(lastError))
}
// The critical notification must have been raised (deduped by id).
var notifCount int
if err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'
`).Scan(&notifCount); err != nil {
t.Fatalf("failed to count critical notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 critical notification after reaching the cap, got %d", notifCount)
}
// No more retries: a second run must not pick the row up (attempts < cap
// filters it out), so attempts stays at the cap.
n, err = RetryPendingS3Deletions(ctx)
if err != nil {
t.Fatalf("second RetryPendingS3Deletions failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 rows retried once at the cap, got %d", n)
}
var after int
if err := db.Conn.QueryRow(ctx, `SELECT attempts FROM pending_s3_deletions WHERE id = $1`, rowID).Scan(&after); err != nil {
t.Fatalf("failed to query attempts after second run: %v", err)
}
if after != maxS3DeletionRetries {
t.Errorf("expected attempts unchanged at %d after the cap, got %d", maxS3DeletionRetries, after)
}
}
// TestRetryPendingS3Deletions_NoOpWithoutClient verifies the job is a no-op when
// no object store client is configured — no outbox rows are read or modified.
func TestRetryPendingS3Deletions_NoOpWithoutClient(t *testing.T) {
ctx := context.Background()
var rowID string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO pending_s3_deletions (user_id, bucket, object_key)
VALUES (NULL, 'test-bucket', 'profiles/test.jpg')
RETURNING id
`).Scan(&rowID); err != nil {
t.Fatalf("failed to seed pending S3 deletion: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM pending_s3_deletions WHERE id = $1", rowID)
})
origClient := s3.Client
s3.Client = nil
t.Cleanup(func() { s3.Client = origClient })
n, err := RetryPendingS3Deletions(ctx)
if err != nil {
t.Fatalf("RetryPendingS3Deletions failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 drained rows without a client, got %d", n)
}
var attempts int
if err := db.Conn.QueryRow(ctx, `SELECT attempts FROM pending_s3_deletions WHERE id = $1`, rowID).Scan(&attempts); err != nil {
t.Fatalf("failed to query outbox row: %v", err)
}
if attempts != 0 {
t.Errorf("expected outbox row untouched without a client, got attempts=%d", attempts)
}
}