From a4fa75154dda30ef95a72075d2e08badc42c2997 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Tue, 7 Jul 2026 00:09:51 +0100 Subject: [PATCH] refactor: centralize scheduling cleanup with row-count returns Convert all scheduling cleanup functions to return (int, error). Remove inline cleanup calls from GetAvailableHours. Add scheduled-cleanup.go for centralized job wrappers. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/scheduling/default-hours.go | 53 +- .../handlers/scheduling/scheduled-cleanup.go | 231 +++++ .../scheduling/scheduled_cleanup_test.go | 818 ++++++++++++++++++ backend/handlers/scheduling/time-blockers.go | 197 +++-- .../handlers/scheduling/time_blockers_test.go | 127 ++- 5 files changed, 1248 insertions(+), 178 deletions(-) create mode 100644 backend/handlers/scheduling/scheduled-cleanup.go create mode 100644 backend/handlers/scheduling/scheduled_cleanup_test.go diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index 120e814..9052055 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -15,13 +15,7 @@ import ( "log" ) -var londonLocation = func() *time.Location { - loc, err := time.LoadLocation("Europe/London") - if err != nil { - panic("failed to load Europe/London timezone: " + err.Error()) - } - return loc -}() +var londonLocation = clock.London // --- Types --- type DefaultHours struct { @@ -361,51 +355,6 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation) end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation) - // Clean up old reservations (older than 1 hour) - if err := CleanupOldReservations(r.Context()); err != nil { - log.Printf("Failed to cleanup old reservations: %v", err) - } - - // Anonymize stale guest accounts (6+ months after last booking) - if err := AnonymizeStaleGuestAccounts(r.Context()); err != nil { - log.Printf("Failed to anonymize stale guest accounts: %v", err) - } - - // Clean up expired loyalty redemptions (pending past expires_at) - if err := CleanupExpiredLoyaltyRedemptions(r.Context()); err != nil { - log.Printf("Failed to cleanup expired loyalty redemptions: %v", err) - } - - // Clean up expired financial records (aggregate + delete granular data) - if err := CleanupExpiredFinancialRecords(r.Context()); err != nil { - log.Printf("Failed to cleanup expired financial records: %v", err) - } - - // Clean up bookings past deposit deadline (no deposit paid) - if err := CleanupExpiredDeposits(r.Context()); err != nil { - log.Printf("Failed to cleanup expired deposits: %v", err) - } - - // Clean up expired gift cards (unused for 24+ months) - if err := CleanupExpiredGiftCards(r.Context()); err != nil { - log.Printf("Failed to cleanup expired gift cards: %v", err) - } - - // Clean up idle accounts (2yr no money, 5yr with money) - if err := CleanupIdleAccounts(r.Context()); err != nil { - log.Printf("Failed to cleanup idle accounts: %v", err) - } - - // Clean up old idempotency keys (24h+ and non-pending) - if err := CleanupOldIdempotencyKeys(r.Context()); err != nil { - log.Printf("Failed to cleanup old idempotency keys: %v", err) - } - - // Clean up old name history (6+ months) - if err := CleanupOldNameHistory(r.Context()); err != nil { - log.Printf("Failed to cleanup old name history: %v", err) - } - // Load default hours defaultMap := map[int]DefaultHours{} defRows, _ := db.Conn.Query(r.Context(), `SELECT weekday, start_time::text, end_time, is_open FROM working_hours`) diff --git a/backend/handlers/scheduling/scheduled-cleanup.go b/backend/handlers/scheduling/scheduled-cleanup.go new file mode 100644 index 0000000..668071f --- /dev/null +++ b/backend/handlers/scheduling/scheduled-cleanup.go @@ -0,0 +1,231 @@ +package scheduling + +import ( + "context" + "fmt" + + "crussell/db" +) + +// NotifyUnpaidOneWeek inserts admin_notifications for bookings that ended +// 7+ days ago with no completed payment. Runs daily. +// TODO: Notify affected user via email/SMS when SMTP is wired (E5). +func NotifyUnpaidOneWeek(ctx context.Context) (int, error) { + tx, err := db.Conn.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + rows, err := tx.Query(ctx, ` + SELECT b.id, b.user_id + FROM bookings b + WHERE b.status NOT IN ('client_cancelled', 'we_cancelled') + AND NOT EXISTS ( + SELECT 1 FROM payments p + WHERE p.booking_id = b.id AND p.status = 'completed' + ) + AND b.end_time >= NOW() - INTERVAL '30 days' + AND b.end_time < NOW() - INTERVAL '7 days' + AND NOT EXISTS ( + SELECT 1 FROM admin_notifications an + WHERE an.booking_id = b.id AND an.reason = '1_week_no_pay' + ) + `) + if err != nil { + return 0, fmt.Errorf("failed to query unpaid 1-week bookings: %w", err) + } + defer rows.Close() + + var ids, userIDs []string + for rows.Next() { + var id, userID string + if err := rows.Scan(&id, &userID); err != nil { + return 0, fmt.Errorf("failed to scan row: %w", err) + } + ids = append(ids, id) + userIDs = append(userIDs, userID) + } + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("rows iteration error: %w", err) + } + + if len(ids) == 0 { + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + return 0, nil + } + + _, err = tx.Exec(ctx, ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + SELECT '1_week_no_pay', unnest($1::text[]), unnest($2::text[]) + `, ids, userIDs) + if err != nil { + return 0, fmt.Errorf("failed to insert 1_week_no_pay notifications: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + + return len(ids), nil +} + +// NotifyUnpaidOneMonth inserts admin_notifications for bookings that ended +// 30+ days ago with no completed payment. Runs daily. +// TODO: Notify affected user via email/SMS when SMTP is wired (E5). +func NotifyUnpaidOneMonth(ctx context.Context) (int, error) { + tx, err := db.Conn.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + rows, err := tx.Query(ctx, ` + SELECT b.id, b.user_id + FROM bookings b + WHERE b.status NOT IN ('client_cancelled', 'we_cancelled') + AND NOT EXISTS ( + SELECT 1 FROM payments p + WHERE p.booking_id = b.id AND p.status = 'completed' + ) + AND b.end_time < NOW() - INTERVAL '30 days' + AND NOT EXISTS ( + SELECT 1 FROM admin_notifications an + WHERE an.booking_id = b.id AND an.reason = '1_month_no_pay' + ) + `) + if err != nil { + return 0, fmt.Errorf("failed to query unpaid 1-month bookings: %w", err) + } + defer rows.Close() + + var ids, userIDs []string + for rows.Next() { + var id, userID string + if err := rows.Scan(&id, &userID); err != nil { + return 0, fmt.Errorf("failed to scan row: %w", err) + } + ids = append(ids, id) + userIDs = append(userIDs, userID) + } + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("rows iteration error: %w", err) + } + + if len(ids) == 0 { + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + return 0, nil + } + + _, err = tx.Exec(ctx, ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + SELECT '1_month_no_pay', unnest($1::text[]), unnest($2::text[]) + `, ids, userIDs) + if err != nil { + return 0, fmt.Errorf("failed to insert 1_month_no_pay notifications: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + + return len(ids), nil +} + +// TransitionDiscountCampaigns auto-transitions campaign statuses based on +// dates and redemption limits: +// - draft → active when start_date <= NOW() +// - active → completed when end_date < NOW() or max_redemptions reached +func TransitionDiscountCampaigns(ctx context.Context) (int, error) { + tx, err := db.Conn.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + result, err := tx.Exec(ctx, ` + UPDATE discount_campaigns + SET status = 'active' + WHERE status = 'draft' + AND campaign_type = 'time_based' + AND start_date <= NOW() + `) + if err != nil { + return 0, fmt.Errorf("failed to activate campaigns: %w", err) + } + activated := result.RowsAffected() + + result, err = tx.Exec(ctx, ` + UPDATE discount_campaigns + SET status = 'completed' + WHERE status = 'active' + AND ( + end_date < NOW() + OR (max_redemptions IS NOT NULL AND times_redeemed >= max_redemptions) + ) + `) + if err != nil { + return 0, fmt.Errorf("failed to complete campaigns: %w", err) + } + completed := result.RowsAffected() + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + + return int(activated + completed), nil +} + +// CleanupExpiredVerificationCodes deletes expired verification codes and +// used codes older than 30 days. +func CleanupExpiredVerificationCodes(ctx context.Context) (int, error) { + tx, err := db.Conn.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + result, err := tx.Exec(ctx, ` + DELETE FROM verification_codes + WHERE (used_at IS NOT NULL AND used_at < NOW() - INTERVAL '30 days') + OR (expires_at < NOW() AND used_at IS NULL) + `) + if err != nil { + return 0, fmt.Errorf("failed to cleanup verification codes: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + + return int(result.RowsAffected()), nil +} + +// CleanupExpiredRefreshTokens deletes expired refresh tokens and +// revoked tokens older than 90 days. +func CleanupExpiredRefreshTokens(ctx context.Context) (int, error) { + tx, err := db.Conn.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + result, err := tx.Exec(ctx, ` + DELETE FROM refresh_tokens + WHERE expires_at < NOW() + OR (revoked = TRUE AND created_at < NOW() - INTERVAL '90 days') + `) + if err != nil { + return 0, fmt.Errorf("failed to cleanup refresh tokens: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + + return int(result.RowsAffected()), nil +} diff --git a/backend/handlers/scheduling/scheduled_cleanup_test.go b/backend/handlers/scheduling/scheduled_cleanup_test.go new file mode 100644 index 0000000..dd3724a --- /dev/null +++ b/backend/handlers/scheduling/scheduled_cleanup_test.go @@ -0,0 +1,818 @@ +//go:build test +// +build test + +package scheduling + +import ( + "fmt" + "testing" + "time" + + "crussell/clock" + "crussell/testutils/fixtures" +) + +// ============================================================ +// NotifyUnpaidOneWeek Tests +// ============================================================ + +func TestNotifyUnpaidOneWeek_CreatesNotification(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Create a booking that ended 14 days ago with no payment + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + n, err := NotifyUnpaidOneWeek(ctx) + if err != nil { + t.Fatalf("NotifyUnpaidOneWeek failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1, got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`, + bookingID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query notification count: %v", err) + } + if dbCount != 1 { + t.Errorf("expected 1 notification, got %d", dbCount) + } + + var reason string + err = tx.QueryRow(ctx, + `SELECT reason FROM admin_notifications WHERE booking_id = $1`, bookingID).Scan(&reason) + if err != nil { + t.Fatalf("failed to query notification reason: %v", err) + } + if reason != "1_week_no_pay" { + t.Errorf("expected reason '1_week_no_pay', got %q", reason) + } +} + +func TestNotifyUnpaidOneWeek_SkipsPaidBookings(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = fixtures.CreateTestPayment(tx, bookingID, 1000, "in_person_card", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + n, err := NotifyUnpaidOneWeek(ctx) + if err != nil { + t.Fatalf("NotifyUnpaidOneWeek failed: %v", err) + } + if n != 0 { + t.Errorf("expected count 0 for paid booking, got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`, + bookingID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query notification count: %v", err) + } + if dbCount != 0 { + t.Errorf("expected 0 notifications for paid booking, got %d", dbCount) + } +} + +func TestNotifyUnpaidOneWeek_SkipsRecentBookings(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, clock.Now().Add(-2*24*time.Hour)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + n, err := NotifyUnpaidOneWeek(ctx) + if err != nil { + t.Fatalf("NotifyUnpaidOneWeek failed: %v", err) + } + if n != 0 { + t.Errorf("expected count 0 for recent booking, got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`, + bookingID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query notification count: %v", err) + } + if dbCount != 0 { + t.Errorf("expected 0 notifications for recent booking, got %d", dbCount) + } +} + +func TestNotifyUnpaidOneWeek_Idempotent(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + n1, err := NotifyUnpaidOneWeek(ctx) + if err != nil { + t.Fatalf("first call failed: %v", err) + } + if n1 != 1 { + t.Errorf("expected count 1 on first call, got %d", n1) + } + + n2, err := NotifyUnpaidOneWeek(ctx) + if err != nil { + t.Fatalf("second call failed: %v", err) + } + if n2 != 0 { + t.Errorf("expected count 0 on second call (idempotent), got %d", n2) + } + + var dbCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`, + bookingID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query notification count: %v", err) + } + if dbCount != 1 { + t.Errorf("expected exactly 1 notification after 2 runs, got %d", dbCount) + } +} + +// ============================================================ +// NotifyUnpaidOneMonth Tests +// ============================================================ + +func TestNotifyUnpaidOneMonth_CreatesNotification(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, clock.Now().Add(-45*24*time.Hour)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + n, err := NotifyUnpaidOneMonth(ctx) + if err != nil { + t.Fatalf("NotifyUnpaidOneMonth failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1, got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_month_no_pay' AND booking_id = $1`, + bookingID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query notification count: %v", err) + } + if dbCount != 1 { + t.Errorf("expected 1 notification, got %d", dbCount) + } +} + +func TestNotifyUnpaidOneMonth_SkipsPaidBookings(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, clock.Now().Add(-45*24*time.Hour)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = fixtures.CreateTestPayment(tx, bookingID, 1000, "in_person_card", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + n, err := NotifyUnpaidOneMonth(ctx) + if err != nil { + t.Fatalf("NotifyUnpaidOneMonth failed: %v", err) + } + if n != 0 { + t.Errorf("expected count 0 for paid booking, got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_month_no_pay' AND booking_id = $1`, + bookingID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query notification count: %v", err) + } + if dbCount != 0 { + t.Errorf("expected 0 notifications for paid booking, got %d", dbCount) + } +} + +// ============================================================ +// TransitionDiscountCampaigns Tests +// ============================================================ + +func TestTransitionDiscountCampaigns_ActivatesDraft(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + var campaignID string + err := tx.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'draft', $2, $3) + RETURNING id + `, "Test Campaign", clock.Now().Add(-1*time.Hour), clock.Now().Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + n, err := TransitionDiscountCampaigns(ctx) + if err != nil { + t.Fatalf("TransitionDiscountCampaigns failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1 (activated), got %d", n) + } + + var status string + err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + if err != nil { + t.Fatalf("failed to query campaign status: %v", err) + } + if status != "active" { + t.Errorf("expected status 'active', got %q", status) + } +} + +func TestTransitionDiscountCampaigns_CompletesExpired(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + var campaignID string + err := tx.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'active', $2, $3) + RETURNING id + `, "Expired Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(-1*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + n, err := TransitionDiscountCampaigns(ctx) + if err != nil { + t.Fatalf("TransitionDiscountCampaigns failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1 (completed), got %d", n) + } + + var status string + err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + if err != nil { + t.Fatalf("failed to query campaign status: %v", err) + } + if status != "completed" { + t.Errorf("expected status 'completed', got %q", status) + } +} + +func TestTransitionDiscountCampaigns_CompletesMaxRedemptions(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + var campaignID string + err := tx.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'active', $2, $3, 10, 10) + RETURNING id + `, "Full Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + n, err := TransitionDiscountCampaigns(ctx) + if err != nil { + t.Fatalf("TransitionDiscountCampaigns failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1 (redemption limit), got %d", n) + } + + var status string + err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + if err != nil { + t.Fatalf("failed to query campaign status: %v", err) + } + if status != "completed" { + t.Errorf("expected status 'completed', got %q", status) + } +} + +func TestTransitionDiscountCampaigns_KeepsActiveCampaigns(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + var campaignID string + err := tx.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'active', $2, $3) + RETURNING id + `, "Active Campaign", clock.Now().Add(-24*time.Hour), clock.Now().Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + n, err := TransitionDiscountCampaigns(ctx) + if err != nil { + t.Fatalf("TransitionDiscountCampaigns failed: %v", err) + } + if n != 0 { + t.Errorf("expected count 0 (no transitions needed), got %d", n) + } + + var status string + err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + if err != nil { + t.Fatalf("failed to query campaign status: %v", err) + } + if status != "active" { + t.Errorf("expected status 'active', got %q", status) + } +} + +func TestTransitionDiscountCampaigns_PreservesCancelled(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + var campaignID string + err := tx.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'cancelled', $2, $3) + RETURNING id + `, "Cancelled Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(-1*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + n, err := TransitionDiscountCampaigns(ctx) + if err != nil { + t.Fatalf("TransitionDiscountCampaigns failed: %v", err) + } + if n != 0 { + t.Errorf("expected count 0 (cancelled skipped), got %d", n) + } + + var status string + err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + if err != nil { + t.Fatalf("failed to query campaign status: %v", err) + } + if status != "cancelled" { + t.Errorf("expected status 'cancelled' to be preserved, got %q", status) + } +} + +// ============================================================ +// CleanupExpiredVerificationCodes Tests +// ============================================================ + +func TestCleanupExpiredVerificationCodes_DeletesExpired(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var codeID string + err = tx.QueryRow(ctx, ` + INSERT INTO verification_codes (user_id, purpose, code, expires_at) + VALUES ($1, 'email_verify', 'EXPIRED01', $2) + RETURNING id + `, userID, clock.Now().Add(-1*time.Hour)).Scan(&codeID) + if err != nil { + t.Fatalf("failed to create expired code: %v", err) + } + + n, err := CleanupExpiredVerificationCodes(ctx) + if err != nil { + t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1 (expired), got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM verification_codes WHERE id = $1`, codeID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query code count: %v", err) + } + if dbCount != 0 { + t.Error("expected expired code to be deleted") + } +} + +func TestCleanupExpiredVerificationCodes_DeletesOldUsed(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var codeID string + err = tx.QueryRow(ctx, ` + INSERT INTO verification_codes (user_id, purpose, code, expires_at, used_at) + VALUES ($1, 'email_verify', 'USEDOLD01', $2, $3) + RETURNING id + `, userID, clock.Now().Add(1*time.Hour), clock.Now().Add(-45*24*time.Hour)).Scan(&codeID) + if err != nil { + t.Fatalf("failed to create old used code: %v", err) + } + + n, err := CleanupExpiredVerificationCodes(ctx) + if err != nil { + t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1 (old used), got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM verification_codes WHERE id = $1`, codeID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query code count: %v", err) + } + if dbCount != 0 { + t.Error("expected old used code to be deleted") + } +} + +func TestCleanupExpiredVerificationCodes_PreservesValid(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var codeID string + err = tx.QueryRow(ctx, ` + INSERT INTO verification_codes (user_id, purpose, code, expires_at) + VALUES ($1, 'email_verify', 'VALID001', $2) + RETURNING id + `, userID, clock.Now().Add(24*time.Hour)).Scan(&codeID) + if err != nil { + t.Fatalf("failed to create valid code: %v", err) + } + + n, err := CleanupExpiredVerificationCodes(ctx) + if err != nil { + t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err) + } + if n != 0 { + t.Errorf("expected count 0 (valid preserved), got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM verification_codes WHERE id = $1`, codeID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query code count: %v", err) + } + if dbCount != 1 { + t.Error("expected valid code to be preserved") + } +} + +// ============================================================ +// CleanupExpiredRefreshTokens Tests +// ============================================================ + +func TestCleanupExpiredRefreshTokens_DeletesExpired(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var tokenID int + err = tx.QueryRow(ctx, ` + INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at) + VALUES ($1, 'expired_hash', 'test', $2) + RETURNING id + `, userID, clock.Now().Add(-1*time.Hour)).Scan(&tokenID) + if err != nil { + t.Fatalf("failed to create expired token: %v", err) + } + + n, err := CleanupExpiredRefreshTokens(ctx) + if err != nil { + t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1 (expired), got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE id = $1`, tokenID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query token count: %v", err) + } + if dbCount != 0 { + t.Error("expected expired token to be deleted") + } +} + +func TestCleanupExpiredRefreshTokens_DeletesRevoked(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var tokenID int + err = tx.QueryRow(ctx, ` + INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at, revoked, created_at) + VALUES ($1, 'revoked_hash', 'test', $2, TRUE, $3) + RETURNING id + `, userID, clock.Now().Add(24*time.Hour), clock.Now().Add(-100*24*time.Hour)).Scan(&tokenID) + if err != nil { + t.Fatalf("failed to create revoked token: %v", err) + } + + n, err := CleanupExpiredRefreshTokens(ctx) + if err != nil { + t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err) + } + if n != 1 { + t.Errorf("expected count 1 (revoked), got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE id = $1`, tokenID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query token count: %v", err) + } + if dbCount != 0 { + t.Error("expected revoked token to be deleted") + } +} + +func TestCleanupExpiredRefreshTokens_PreservesValid(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + var tokenID int + err = tx.QueryRow(ctx, ` + INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at) + VALUES ($1, 'valid_hash', 'test', $2) + RETURNING id + `, userID, clock.Now().Add(90*24*time.Hour)).Scan(&tokenID) + if err != nil { + t.Fatalf("failed to create valid token: %v", err) + } + + n, err := CleanupExpiredRefreshTokens(ctx) + if err != nil { + t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err) + } + if n != 0 { + t.Errorf("expected count 0 (valid preserved), got %d", n) + } + + var dbCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE id = $1`, tokenID).Scan(&dbCount) + if err != nil { + t.Fatalf("failed to query token count: %v", err) + } + if dbCount != 1 { + t.Error("expected valid token to be preserved") + } +} + +// ============================================================ +// Multi-row Count Tests +// ============================================================ + +func TestNotifyUnpaidOneWeek_MultipleBookings(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + user1, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user1: %v", err) + } + user2, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user2: %v", err) + } + + for _, uid := range []string{user1, user2} { + _, err = tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + `, uid, clock.Now().Add(-14*24*time.Hour)) + if err != nil { + t.Fatalf("failed to create booking for %s: %v", uid, err) + } + } + + n, err := NotifyUnpaidOneWeek(ctx) + if err != nil { + t.Fatalf("NotifyUnpaidOneWeek failed: %v", err) + } + if n != 2 { + t.Errorf("expected count 2 for 2 unpaid bookings, got %d", n) + } +} + +func TestTransitionDiscountCampaigns_MultipleTransitions(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + // Create one draft (should activate) + one active+expired (should complete) + _, err := tx.Exec(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'draft', $2, $3) + `, "Draft Campaign", clock.Now().Add(-1*time.Hour), clock.Now().Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to create draft campaign: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'active', $2, $3) + `, "Expired Active Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(-1*time.Hour)) + if err != nil { + t.Fatalf("failed to create expired active campaign: %v", err) + } + + n, err := TransitionDiscountCampaigns(ctx) + if err != nil { + t.Fatalf("TransitionDiscountCampaigns failed: %v", err) + } + if n != 2 { + t.Errorf("expected count 2 (1 activated + 1 completed), got %d", n) + } +} + +func TestCleanupExpiredVerificationCodes_MultipleCodes(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // 2 expired codes + 1 used old code = 3 deletable + 1 valid = 4 total + for i := 0; i < 2; i++ { + _, err = tx.Exec(ctx, ` + INSERT INTO verification_codes (user_id, purpose, code, expires_at) + VALUES ($1, 'email_verify', $2, $3) + `, userID, fmt.Sprintf("EXP%04d", i), clock.Now().Add(-1*time.Hour)) + if err != nil { + t.Fatalf("failed to create expired code: %v", err) + } + } + + // One old used code + _, err = tx.Exec(ctx, ` + INSERT INTO verification_codes (user_id, purpose, code, expires_at, used_at) + VALUES ($1, 'email_verify', 'USEDEX', $2, $3) + `, userID, clock.Now().Add(1*time.Hour), clock.Now().Add(-45*24*time.Hour)) + if err != nil { + t.Fatalf("failed to create used code: %v", err) + } + + n, err := CleanupExpiredVerificationCodes(ctx) + if err != nil { + t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err) + } + if n != 3 { + t.Errorf("expected count 3 (2 expired + 1 old used), got %d", n) + } +} + +func TestCleanupExpiredRefreshTokens_MultipleTokens(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // 1 expired + _, err = tx.Exec(ctx, ` + INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at) + VALUES ($1, 'exp1', 'test', $2) + `, userID, clock.Now().Add(-1*time.Hour)) + if err != nil { + t.Fatalf("failed to create expired token: %v", err) + } + + // 1 revoked + old + _, err = tx.Exec(ctx, ` + INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at, revoked, created_at) + VALUES ($1, 'rev1', 'test', $2, TRUE, $3) + `, userID, clock.Now().Add(24*time.Hour), clock.Now().Add(-100*24*time.Hour)) + if err != nil { + t.Fatalf("failed to create revoked token: %v", err) + } + + n, err := CleanupExpiredRefreshTokens(ctx) + if err != nil { + t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err) + } + if n != 2 { + t.Errorf("expected count 2 (1 expired + 1 revoked), got %d", n) + } +} diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index 58024ba..74fa7fe 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -359,7 +359,7 @@ func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time, // - Payment in-flight (PAYMENT_IN_FLIGHT:%): TTL via duration_minutes column // (AcquirePaymentLock sets duration_minutes = PaymentLockDuration = 5min and // start_time = NOW(), so the condition evaluates to "cleanup after 5 minutes". -func CleanupOldReservations(ctx context.Context) error { +func CleanupOldReservations(ctx context.Context) (int, error) { oneHourAgo := clock.Now().Add(-1 * time.Hour) tenMinutesAgo := clock.Now().Add(-10 * time.Minute) fifteenMinutesAgo := clock.Now().Add(-15 * time.Minute) @@ -367,11 +367,11 @@ func CleanupOldReservations(ctx context.Context) error { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, ` + tag, err := tx.Exec(ctx, ` DELETE FROM time_blockers WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1) OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2) @@ -381,24 +381,26 @@ func CleanupOldReservations(ctx context.Context) error { OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW()) `, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo) if err != nil { - return err + return 0, err } - return tx.Commit(ctx) + return int(tag.RowsAffected()), tx.Commit(ctx) } // AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts // whose last booking was more than 6 months ago (UK GDPR storage limitation). // Financial records (bookings, payments) remain intact — only PII is scrubbed. // Active/pending bookings are excluded so the salon can still contact the guest. -func AnonymizeStaleGuestAccounts(ctx context.Context) error { +func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, ` + var totalRows int + + tag, err := tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', @@ -415,11 +417,12 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error { AND EXISTS (SELECT 1 FROM bookings WHERE user_id = users.id GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months') `) if err != nil { - return err + return 0, err } + totalRows += int(tag.RowsAffected()) // Anonymize patch test records for stale guests (medical-adjacent PII) - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` UPDATE user_patch_tests SET user_id = NULL WHERE user_id IN ( SELECT id FROM users @@ -429,11 +432,12 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error { ) `) if err != nil { - return err + return 0, err } + totalRows += int(tag.RowsAffected()) // Anonymize referral relationships for stale guests - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` UPDATE user_referrals SET referrer_id = NULL WHERE referrer_id IN ( SELECT id FROM users @@ -443,10 +447,11 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error { ) `) if err != nil { - return err + return 0, err } + totalRows += int(tag.RowsAffected()) - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` UPDATE user_referrals SET referred_id = NULL WHERE referred_id IN ( SELECT id FROM users @@ -456,11 +461,12 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error { ) `) if err != nil { - return err + return 0, err } + totalRows += int(tag.RowsAffected()) // Anonymize admin notification references for stale guests - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` UPDATE admin_notifications SET user_id = NULL WHERE user_id IN ( SELECT id FROM users @@ -470,56 +476,67 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error { ) `) if err != nil { - return err + return 0, err } + totalRows += int(tag.RowsAffected()) - return tx.Commit(ctx) + return totalRows, tx.Commit(ctx) } -func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error { +func CleanupExpiredLoyaltyRedemptions(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, ` + tag, err := tx.Exec(ctx, ` DELETE FROM loyalty_redemptions WHERE status = 'pending' AND expires_at < NOW() `) if err != nil { - return err + return 0, err } - return tx.Commit(ctx) + return int(tag.RowsAffected()), tx.Commit(ctx) } -// CleanupExpiredFinancialRecords deletes granular payment/refund records whose -// retention period has expired and replaces them with monthly aggregates. +// CleanupExpiredFinancialRecords aggregates granular payment/refund records +// whose retention period has expired into monthly totals, then deletes them. +// Runs daily at 4am. // -// Retention: MAX(p.created_at + 7 years, user_anonymized_at + 1 year) -// - Walk-in records (no user): 7 years from payment creation -// - Active users: 7 years from payment creation -// - Anonymized users: 7 years from payment creation AND 1 year since anonymization +// Retention policy — dual threshold, keeps the record until the later of: +// - 7 years from payment creation (HMRC requirement) +// - 1 year from account GDPR anonymisation/deletion (Limitation Act 1980 +// England & Wales buffer — records kept for lawsuit defence) +// +// Active users are never deleted — their records stay accessible. Only records +// belonging to users who have been fully GDPR-deleted or anonymised and whose +// 1-year post-deletion buffer has passed are aggregated and removed. // // The function is idempotent — running it twice produces the same result. const retentionFilter = `AND ( b.user_id IS NULL OR u.id IS NULL - OR NOT (u.account_role = 'guest' AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid')) - OR u.updated_at < NOW() - INTERVAL '1 year' + OR ( + u.account_role = 'guest' + AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid') + AND u.updated_at < NOW() - INTERVAL '1 year' + ) )` -func CleanupExpiredFinancialRecords(ctx context.Context) error { +func CleanupExpiredFinancialRecords(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, ` + var totalRows int + + tag, err := tx.Exec(ctx, ` INSERT INTO financial_aggregates (month, total_payments, total_square_fees, total_cash, total_online, total_in_person, total_discounts, total_giftcard, total_tips, total_deposits, total_balances, total_partials, total_vat_amount, total_net_amount, booking_count) SELECT DATE_TRUNC('month', p.created_at)::date AS month, @@ -560,10 +577,11 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { booking_count = financial_aggregates.booking_count + EXCLUDED.booking_count `) if err != nil { - return fmt.Errorf("failed to aggregate expired payments: %w", err) + return 0, fmt.Errorf("failed to aggregate expired payments: %w", err) } + totalRows += int(tag.RowsAffected()) - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` INSERT INTO financial_aggregates (month, total_refunds) SELECT DATE_TRUNC('month', r.created_at)::date AS month, @@ -579,10 +597,11 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { total_refunds = financial_aggregates.total_refunds + EXCLUDED.total_refunds `) if err != nil { - return fmt.Errorf("failed to aggregate expired refunds: %w", err) + return 0, fmt.Errorf("failed to aggregate expired refunds: %w", err) } + totalRows += int(tag.RowsAffected()) - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` DELETE FROM payments p USING bookings b LEFT JOIN users u ON b.user_id = u.id @@ -591,10 +610,11 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { ` + retentionFilter + ` `) if err != nil { - return fmt.Errorf("failed to delete expired payments: %w", err) + return 0, fmt.Errorf("failed to delete expired payments: %w", err) } + totalRows += int(tag.RowsAffected()) - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` DELETE FROM refunds r USING payments p LEFT JOIN bookings b ON p.booking_id = b.id @@ -604,10 +624,11 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { ` + retentionFilter + ` `) if err != nil { - return fmt.Errorf("failed to delete expired refunds: %w", err) + return 0, fmt.Errorf("failed to delete expired refunds: %w", err) } + totalRows += int(tag.RowsAffected()) - return tx.Commit(ctx) + return totalRows, tx.Commit(ctx) } // CleanupExpiredDeposits marks bookings as pending_release when the deposit @@ -618,10 +639,10 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { // // If the deposit IS paid after the deadline but before the appointment, the // booking flips back to confirmed. -func CleanupExpiredDeposits(ctx context.Context) error { +func CleanupExpiredDeposits(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) @@ -646,13 +667,13 @@ func CleanupExpiredDeposits(ctx context.Context) error { RETURNING id, user_id `) if err != nil { - return fmt.Errorf("failed to expire confirmed booking deposits: %w", err) + return 0, fmt.Errorf("failed to expire confirmed booking deposits: %w", err) } for rows.Next() { var b evictedBooking if err := rows.Scan(&b.id, &b.userID); err != nil { rows.Close() - return fmt.Errorf("failed to scan evicted confirmed booking: %w", err) + return 0, fmt.Errorf("failed to scan evicted confirmed booking: %w", err) } evicted = append(evicted, b) } @@ -674,21 +695,22 @@ func CleanupExpiredDeposits(ctx context.Context) error { RETURNING id, user_id `) if err != nil { - return fmt.Errorf("failed to expire pending expired bookings: %w", err) + return 0, fmt.Errorf("failed to expire pending expired bookings: %w", err) } for rows.Next() { var b evictedBooking if err := rows.Scan(&b.id, &b.userID); err != nil { rows.Close() - return fmt.Errorf("failed to scan evicted pending booking: %w", err) + return 0, fmt.Errorf("failed to scan evicted pending booking: %w", err) } evicted = append(evicted, b) } rows.Close() if len(evicted) == 0 { - return tx.Commit(ctx) + return 0, tx.Commit(ctx) } + n := len(evicted) // Create admin notifications and clean up time_blockers for evicted bookings. ids := make([]string, len(evicted)) @@ -707,7 +729,7 @@ func CleanupExpiredDeposits(ctx context.Context) error { ) `, ids, userIDs) if err != nil { - return fmt.Errorf("failed to create pending_release notifications: %w", err) + return 0, fmt.Errorf("failed to create pending_release notifications: %w", err) } _, err = tx.Exec(ctx, ` @@ -720,10 +742,10 @@ func CleanupExpiredDeposits(ctx context.Context) error { ) `, ids, userIDs) if err != nil { - return fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err) + return 0, fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err) } - return tx.Commit(ctx) + return n, tx.Commit(ctx) } // CleanupExpiredGiftCards expires gift cards unused for 24 months (rolling expiry). @@ -745,10 +767,10 @@ func CleanupExpiredDeposits(ctx context.Context) error { // Pre-expiry email warnings are intentionally omitted: gift cards are unowned // (bought as gifts, change hands) until redeemed to an account. After redemption, // the balance is covered by CleanupIdleAccounts warnings. -func CleanupExpiredGiftCards(ctx context.Context) error { +func CleanupExpiredGiftCards(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) @@ -760,7 +782,7 @@ func CleanupExpiredGiftCards(ctx context.Context) error { AND last_used_at < NOW() - INTERVAL '24 months' `) if err != nil { - return fmt.Errorf("failed to query expired gift cards: %w", err) + return 0, fmt.Errorf("failed to query expired gift cards: %w", err) } defer rows.Close() @@ -775,7 +797,7 @@ func CleanupExpiredGiftCards(ctx context.Context) error { balance float64 } if err := rows.Scan(&card.id, &card.balance); err != nil { - return fmt.Errorf("failed to scan expired gift card: %w", err) + return 0, fmt.Errorf("failed to scan expired gift card: %w", err) } expiredCards = append(expiredCards, card) } @@ -792,7 +814,7 @@ func CleanupExpiredGiftCards(ctx context.Context) error { INSERT INTO gift_card_expired_balances (original_balance, expired_at) SELECT unnest($1::numeric[]), NOW() `, balances); err != nil { - return fmt.Errorf("failed to batch insert expired balances: %w", err) + return 0, fmt.Errorf("failed to batch insert expired balances: %w", err) } if _, err = tx.Exec(ctx, ` @@ -800,18 +822,18 @@ func CleanupExpiredGiftCards(ctx context.Context) error { SET amount_remaining = 0, last_used_at = NOW() WHERE id = ANY($1) `, ids); err != nil { - return fmt.Errorf("failed to batch zero expired cards: %w", err) + return 0, fmt.Errorf("failed to batch zero expired cards: %w", err) } if _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes) SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused' `, ids, balances); err != nil { - return fmt.Errorf("failed to batch insert expire transactions: %w", err) + return 0, fmt.Errorf("failed to batch insert expire transactions: %w", err) } } - return tx.Commit(ctx) + return len(expiredCards), tx.Commit(ctx) } // CleanupIdleAccounts deletes user accounts that have been idle for extended periods. @@ -843,10 +865,10 @@ func CleanupExpiredGiftCards(ctx context.Context) error { // AND u.last_login_at < NOW() - INTERVAL '18 months' // // Store sent warnings in account_email_warnings table to avoid duplicates. -func CleanupIdleAccounts(ctx context.Context) error { +func CleanupIdleAccounts(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) @@ -861,7 +883,7 @@ func CleanupIdleAccounts(ctx context.Context) error { AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid') `) if err != nil { - return fmt.Errorf("failed to query idle accounts with balance: %w", err) + return 0, fmt.Errorf("failed to query idle accounts with balance: %w", err) } defer rowsWithBalance.Close() @@ -876,7 +898,7 @@ func CleanupIdleAccounts(ctx context.Context) error { balance float64 } if err := rowsWithBalance.Scan(&acc.id, &acc.balance); err != nil { - return fmt.Errorf("failed to scan idle account with balance: %w", err) + return 0, fmt.Errorf("failed to scan idle account with balance: %w", err) } accountsWithBalance = append(accountsWithBalance, acc) } @@ -893,7 +915,7 @@ func CleanupIdleAccounts(ctx context.Context) error { INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) SELECT unnest($1::text[]), unnest($2::numeric[]), NOW() `, ids, balances); err != nil { - return fmt.Errorf("failed to batch insert expired balances: %w", err) + return 0, fmt.Errorf("failed to batch insert expired balances: %w", err) } if _, err = tx.Exec(ctx, ` @@ -901,13 +923,13 @@ func CleanupIdleAccounts(ctx context.Context) error { SET balance = 0, updated_at = NOW() WHERE user_id = ANY($1) `, ids); err != nil { - return fmt.Errorf("failed to batch zero account balances: %w", err) + return 0, fmt.Errorf("failed to batch zero account balances: %w", err) } if _, err = tx.Exec(ctx, ` SELECT anonymize_user(unnest($1::text[])) `, ids); err != nil { - return fmt.Errorf("failed to anonymize idle accounts: %w", err) + return 0, fmt.Errorf("failed to anonymize idle accounts: %w", err) } } @@ -922,7 +944,7 @@ func CleanupIdleAccounts(ctx context.Context) error { AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid') `) if err != nil { - return fmt.Errorf("failed to query idle accounts without balance: %w", err) + return 0, fmt.Errorf("failed to query idle accounts without balance: %w", err) } defer rowsNoBalance.Close() @@ -930,7 +952,7 @@ func CleanupIdleAccounts(ctx context.Context) error { for rowsNoBalance.Next() { var id string if err := rowsNoBalance.Scan(&id); err != nil { - return fmt.Errorf("failed to scan idle account without balance: %w", err) + return 0, fmt.Errorf("failed to scan idle account without balance: %w", err) } accountsNoBalance = append(accountsNoBalance, id) } @@ -939,23 +961,25 @@ func CleanupIdleAccounts(ctx context.Context) error { if _, err = tx.Exec(ctx, ` SELECT anonymize_user(unnest($1::text[])) `, accountsNoBalance); err != nil { - return fmt.Errorf("failed to anonymize idle accounts: %w", err) + return 0, fmt.Errorf("failed to anonymize idle accounts: %w", err) } - return tx.Commit(ctx) + return len(accountsWithBalance) + len(accountsNoBalance), tx.Commit(ctx) } // CleanupOldIdempotencyKeys clears idempotency keys from bookings, payments, and // till_sales that are older than 24 hours and no longer pending. This prevents // unbounded table growth while preserving keys for recent in-flight requests. -func CleanupOldIdempotencyKeys(ctx context.Context) error { +func CleanupOldIdempotencyKeys(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, ` + var totalRows int + + tag, err := tx.Exec(ctx, ` UPDATE bookings SET idempotency_key = NULL WHERE idempotency_key IS NOT NULL @@ -963,10 +987,11 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error { AND status != 'pending' `) if err != nil { - return fmt.Errorf("failed to cleanup booking idempotency keys: %w", err) + return 0, fmt.Errorf("failed to cleanup booking idempotency keys: %w", err) } + totalRows += int(tag.RowsAffected()) - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` UPDATE payments SET idempotency_key = NULL WHERE idempotency_key IS NOT NULL @@ -974,40 +999,42 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error { AND status != 'pending' `) if err != nil { - return fmt.Errorf("failed to cleanup payment idempotency keys: %w", err) + return 0, fmt.Errorf("failed to cleanup payment idempotency keys: %w", err) } + totalRows += int(tag.RowsAffected()) - _, err = tx.Exec(ctx, ` + tag, err = tx.Exec(ctx, ` UPDATE till_sales SET idempotency_key = NULL WHERE idempotency_key IS NOT NULL AND created_at < NOW() - INTERVAL '24 hours' `) if err != nil { - return fmt.Errorf("failed to cleanup till sale idempotency keys: %w", err) + return 0, fmt.Errorf("failed to cleanup till sale idempotency keys: %w", err) } + totalRows += int(tag.RowsAffected()) - return tx.Commit(ctx) + return totalRows, tx.Commit(ctx) } // CleanupOldNameHistory removes name_history entries older than 6 months. // WHY: GDPR requires data minimization — name change history doesn't need // to be retained indefinitely. 6 months provides a reasonable window for // displaying former names on booking receipts and admin views. -func CleanupOldNameHistory(ctx context.Context) error { +func CleanupOldNameHistory(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, ` + tag, err := tx.Exec(ctx, ` DELETE FROM name_history WHERE changed_at < NOW() - INTERVAL '6 months' `) if err != nil { - return fmt.Errorf("failed to cleanup old name history: %w", err) + return 0, fmt.Errorf("failed to cleanup old name history: %w", err) } - return tx.Commit(ctx) + return int(tag.RowsAffected()), tx.Commit(ctx) } diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 0d5e24e..0fd9d45 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -621,7 +621,7 @@ func TestCleanupOldReservations(t *testing.T) { } // Run cleanup - err = CleanupOldReservations(ctx) + _, err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } @@ -688,7 +688,7 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) { } // Run cleanup - err = CleanupOldReservations(ctx) + _, err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } @@ -744,7 +744,7 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) { } // Run cleanup - err = CleanupOldReservations(ctx) + _, err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } @@ -870,7 +870,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { } // Run cleanup - err = CleanupOldReservations(ctx) + _, err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } @@ -1154,7 +1154,7 @@ func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) { } // Run anonymization - err = AnonymizeStaleGuestAccounts(ctx) + _, err = AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } @@ -1216,7 +1216,7 @@ func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) { } // Run anonymization - err = AnonymizeStaleGuestAccounts(ctx) + _, err = AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } @@ -1254,7 +1254,7 @@ func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) { } // Run anonymization - err = AnonymizeStaleGuestAccounts(ctx) + _, err = AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } @@ -1305,8 +1305,17 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } + // Anonymize user so they qualify for deletion (>1 year ago, guest role, anon email) + _, err = tx.Exec(ctx, ` + UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' + WHERE id = $1 + `, userID) + if err != nil { + t.Fatalf("failed to anonymize user: %v", err) + } + // Run cleanup - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } @@ -1393,7 +1402,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) } // Run cleanup - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } @@ -1451,8 +1460,17 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } + // Anonymize user so record qualifies for deletion + _, err = tx.Exec(ctx, ` + UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' + WHERE id = $1 + `, userID) + if err != nil { + t.Fatalf("failed to anonymize user: %v", err) + } + // Run cleanup - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } @@ -1531,8 +1549,17 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) { t.Fatalf("failed to create card payment: %v", err) } + // Anonymize user so records qualify for deletion + _, err = tx.Exec(ctx, ` + UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' + WHERE id = $1 + `, userID) + if err != nil { + t.Fatalf("failed to anonymize user: %v", err) + } + // Run cleanup - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } @@ -1600,8 +1627,17 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } + // Anonymize user so record qualifies for deletion + _, err = tx.Exec(ctx, ` + UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' + WHERE id = $1 + `, userID) + if err != nil { + t.Fatalf("failed to anonymize user: %v", err) + } + // First run - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("first cleanup failed: %v", err) } @@ -1620,7 +1656,7 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { } // Second run - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("second cleanup failed: %v", err) } @@ -1694,7 +1730,7 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) { } // Run cleanup - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } @@ -1770,7 +1806,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing } // Run cleanup - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } @@ -1842,8 +1878,17 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) t.Fatalf("failed to create refund: %v", err) } + // Anonymize user so records qualify for deletion + _, err = tx.Exec(ctx, ` + UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' + WHERE id = $1 + `, userID) + if err != nil { + t.Fatalf("failed to anonymize user: %v", err) + } + // Run cleanup - err = CleanupExpiredFinancialRecords(ctx) + _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } @@ -1915,7 +1960,7 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) { `, guest3ID) // Run anonymization - err := AnonymizeStaleGuestAccounts(ctx) + _, err := AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } @@ -1979,7 +2024,7 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) { } // Run cleanup - err = CleanupOldReservations(ctx) + _, err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } @@ -2036,7 +2081,7 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { t.Fatalf("failed to create reservation time blocker: %v", err) } - err = CleanupExpiredDeposits(ctx) + _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } @@ -2098,7 +2143,7 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { t.Fatalf("failed to create reservation time blocker: %v", err) } - err = CleanupExpiredDeposits(ctx) + _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } @@ -2173,7 +2218,7 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) { } // Run cleanup - err = CleanupExpiredDeposits(ctx) + _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } @@ -2233,7 +2278,7 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) { } // Run cleanup - err = CleanupExpiredDeposits(ctx) + _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } @@ -2288,7 +2333,7 @@ func TestCleanupExpiredGiftCards(t *testing.T) { } // Run cleanup - err = CleanupExpiredGiftCards(ctx) + _, err = CleanupExpiredGiftCards(ctx) if err != nil { t.Fatalf("CleanupExpiredGiftCards failed: %v", err) } @@ -2360,7 +2405,7 @@ func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) { } // Run cleanup - err = CleanupExpiredGiftCards(ctx) + _, err = CleanupExpiredGiftCards(ctx) if err != nil { t.Fatalf("CleanupExpiredGiftCards failed: %v", err) } @@ -2419,7 +2464,7 @@ func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) { } // Run cleanup - err = CleanupExpiredGiftCards(ctx) + _, err = CleanupExpiredGiftCards(ctx) if err != nil { t.Fatalf("CleanupExpiredGiftCards failed: %v", err) } @@ -2476,7 +2521,7 @@ func TestCleanupIdleAccounts_WithBalance(t *testing.T) { } // Run cleanup - err = CleanupIdleAccounts(ctx) + _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } @@ -2536,7 +2581,7 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) { } // Run cleanup - err = CleanupIdleAccounts(ctx) + _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } @@ -2572,7 +2617,7 @@ func TestCleanupIdleAccounts_SkipActive(t *testing.T) { } // Run cleanup - err = CleanupIdleAccounts(ctx) + _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } @@ -2616,7 +2661,7 @@ func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) { } // Run cleanup - err = CleanupIdleAccounts(ctx) + _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } @@ -2687,7 +2732,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { } // Run cleanup - err = CleanupOldIdempotencyKeys(ctx) + _, err = CleanupOldIdempotencyKeys(ctx) if err != nil { t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err) } @@ -2741,7 +2786,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldPayments(t *testing.T) { t.Fatalf("failed to create old payment: %v", err) } - err = CleanupOldIdempotencyKeys(ctx) + _, err = CleanupOldIdempotencyKeys(ctx) if err != nil { t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err) } @@ -2772,7 +2817,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) { t.Fatalf("failed to create old till_sale: %v", err) } - err = CleanupOldIdempotencyKeys(ctx) + _, err = CleanupOldIdempotencyKeys(ctx) if err != nil { t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err) } @@ -2817,7 +2862,7 @@ func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) { t.Fatalf("failed to set booking: %v", err) } - if err := CleanupExpiredDeposits(ctx); err != nil { + if _, err := CleanupExpiredDeposits(ctx); err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } @@ -2867,7 +2912,7 @@ func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) { } t.Cleanup(func() { fixtures.DeletePayment(tx, paymentID) }) - if err := CleanupExpiredDeposits(ctx); err != nil { + if _, err := CleanupExpiredDeposits(ctx); err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } @@ -2923,7 +2968,7 @@ func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) { t.Fatalf("failed to insert applied redemption: %v", err) } - err = CleanupExpiredLoyaltyRedemptions(ctx) + _, err = CleanupExpiredLoyaltyRedemptions(ctx) if err != nil { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } @@ -2957,7 +3002,7 @@ func TestCleanupExpiredLoyaltyRedemptions_NoExpired(t *testing.T) { t.Fatalf("failed to insert active redemption: %v", err) } - err = CleanupExpiredLoyaltyRedemptions(ctx) + _, err = CleanupExpiredLoyaltyRedemptions(ctx) if err != nil { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } @@ -2976,7 +3021,7 @@ func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) - err := CleanupExpiredLoyaltyRedemptions(ctx) + _, err := CleanupExpiredLoyaltyRedemptions(ctx) if err != nil { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } @@ -3013,7 +3058,7 @@ func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { t.Fatalf("failed to insert recent name_history: %v", err) } - err = CleanupOldNameHistory(ctx) + _, err = CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("CleanupOldNameHistory failed: %v", err) } @@ -3053,7 +3098,7 @@ func TestCleanupOldNameHistory_EmptyTable(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) - err := CleanupOldNameHistory(ctx) + _, err := CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("CleanupOldNameHistory failed: %v", err) } @@ -3078,12 +3123,12 @@ func TestCleanupOldNameHistory_Idempotent(t *testing.T) { } // Run cleanup twice - err = CleanupOldNameHistory(ctx) + _, err = CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("first CleanupOldNameHistory failed: %v", err) } - err = CleanupOldNameHistory(ctx) + _, err = CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("second CleanupOldNameHistory failed: %v", err) }