Adds the scan-critical-payment-logs job (daily 2:45am) that surfaces stale pending payments/till-sales and refunds at the retry cap as admin_notifications with reason='critical_payment_log' — the app has no log/alert pipeline (Gap Backlog T14), so money events that would otherwise sit in un-watched CRITICAL log lines now reach the owner's in-app notification centre. Dedup is NULL-safe (IS NOT DISTINCT FROM) and re-surfaces acknowledged-but-still-unresolved rows. Stopgap until a real alerting pipeline lands.
326 lines
12 KiB
Go
326 lines
12 KiB
Go
//go:build test
|
|
|
|
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"crussell/db"
|
|
"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)
|
|
}
|
|
}
|