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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -15,13 +15,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var londonLocation = func() *time.Location {
|
var londonLocation = clock.London
|
||||||
loc, err := time.LoadLocation("Europe/London")
|
|
||||||
if err != nil {
|
|
||||||
panic("failed to load Europe/London timezone: " + err.Error())
|
|
||||||
}
|
|
||||||
return loc
|
|
||||||
}()
|
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
type DefaultHours struct {
|
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)
|
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)
|
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
|
// Load default hours
|
||||||
defaultMap := map[int]DefaultHours{}
|
defaultMap := map[int]DefaultHours{}
|
||||||
defRows, _ := db.Conn.Query(r.Context(), `SELECT weekday, start_time::text, end_time, is_open FROM working_hours`)
|
defRows, _ := db.Conn.Query(r.Context(), `SELECT weekday, start_time::text, end_time, is_open FROM working_hours`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -359,7 +359,7 @@ func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time,
|
|||||||
// - Payment in-flight (PAYMENT_IN_FLIGHT:%): TTL via duration_minutes column
|
// - Payment in-flight (PAYMENT_IN_FLIGHT:%): TTL via duration_minutes column
|
||||||
// (AcquirePaymentLock sets duration_minutes = PaymentLockDuration = 5min and
|
// (AcquirePaymentLock sets duration_minutes = PaymentLockDuration = 5min and
|
||||||
// start_time = NOW(), so the condition evaluates to "cleanup after 5 minutes".
|
// 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)
|
oneHourAgo := clock.Now().Add(-1 * time.Hour)
|
||||||
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
|
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
|
||||||
fifteenMinutesAgo := clock.Now().Add(-15 * 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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
DELETE FROM time_blockers
|
DELETE FROM time_blockers
|
||||||
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
|
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
|
||||||
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
|
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())
|
OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW())
|
||||||
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
|
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
|
||||||
if err != nil {
|
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
|
// AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts
|
||||||
// whose last booking was more than 6 months ago (UK GDPR storage limitation).
|
// whose last booking was more than 6 months ago (UK GDPR storage limitation).
|
||||||
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
|
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
|
||||||
// Active/pending bookings are excluded so the salon can still contact the guest.
|
// 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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
var totalRows int
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
UPDATE users SET
|
UPDATE users SET
|
||||||
n_first_name = 'Guest',
|
n_first_name = 'Guest',
|
||||||
n_last_name = 'Anonymized',
|
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')
|
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 {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
totalRows += int(tag.RowsAffected())
|
||||||
|
|
||||||
// Anonymize patch test records for stale guests (medical-adjacent PII)
|
// 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
|
UPDATE user_patch_tests SET user_id = NULL
|
||||||
WHERE user_id IN (
|
WHERE user_id IN (
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
@@ -429,11 +432,12 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
totalRows += int(tag.RowsAffected())
|
||||||
|
|
||||||
// Anonymize referral relationships for stale guests
|
// Anonymize referral relationships for stale guests
|
||||||
_, err = tx.Exec(ctx, `
|
tag, err = tx.Exec(ctx, `
|
||||||
UPDATE user_referrals SET referrer_id = NULL
|
UPDATE user_referrals SET referrer_id = NULL
|
||||||
WHERE referrer_id IN (
|
WHERE referrer_id IN (
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
@@ -443,10 +447,11 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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
|
UPDATE user_referrals SET referred_id = NULL
|
||||||
WHERE referred_id IN (
|
WHERE referred_id IN (
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
@@ -456,11 +461,12 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
totalRows += int(tag.RowsAffected())
|
||||||
|
|
||||||
// Anonymize admin notification references for stale guests
|
// Anonymize admin notification references for stale guests
|
||||||
_, err = tx.Exec(ctx, `
|
tag, err = tx.Exec(ctx, `
|
||||||
UPDATE admin_notifications SET user_id = NULL
|
UPDATE admin_notifications SET user_id = NULL
|
||||||
WHERE user_id IN (
|
WHERE user_id IN (
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
@@ -470,56 +476,67 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
DELETE FROM loyalty_redemptions
|
DELETE FROM loyalty_redemptions
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
AND expires_at < NOW()
|
AND expires_at < NOW()
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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
|
// CleanupExpiredFinancialRecords aggregates granular payment/refund records
|
||||||
// retention period has expired and replaces them with monthly aggregates.
|
// 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)
|
// Retention policy — dual threshold, keeps the record until the later of:
|
||||||
// - Walk-in records (no user): 7 years from payment creation
|
// - 7 years from payment creation (HMRC requirement)
|
||||||
// - Active users: 7 years from payment creation
|
// - 1 year from account GDPR anonymisation/deletion (Limitation Act 1980
|
||||||
// - Anonymized users: 7 years from payment creation AND 1 year since anonymization
|
// 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.
|
// The function is idempotent — running it twice produces the same result.
|
||||||
|
|
||||||
const retentionFilter = `AND (
|
const retentionFilter = `AND (
|
||||||
b.user_id IS NULL
|
b.user_id IS NULL
|
||||||
OR u.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 (
|
||||||
OR u.updated_at < NOW() - INTERVAL '1 year'
|
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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
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)
|
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
|
SELECT
|
||||||
DATE_TRUNC('month', p.created_at)::date AS month,
|
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
|
booking_count = financial_aggregates.booking_count + EXCLUDED.booking_count
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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)
|
INSERT INTO financial_aggregates (month, total_refunds)
|
||||||
SELECT
|
SELECT
|
||||||
DATE_TRUNC('month', r.created_at)::date AS month,
|
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
|
total_refunds = financial_aggregates.total_refunds + EXCLUDED.total_refunds
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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
|
DELETE FROM payments p
|
||||||
USING bookings b
|
USING bookings b
|
||||||
LEFT JOIN users u ON b.user_id = u.id
|
LEFT JOIN users u ON b.user_id = u.id
|
||||||
@@ -591,10 +610,11 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
|
|||||||
` + retentionFilter + `
|
` + retentionFilter + `
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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
|
DELETE FROM refunds r
|
||||||
USING payments p
|
USING payments p
|
||||||
LEFT JOIN bookings b ON p.booking_id = b.id
|
LEFT JOIN bookings b ON p.booking_id = b.id
|
||||||
@@ -604,10 +624,11 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
|
|||||||
` + retentionFilter + `
|
` + retentionFilter + `
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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
|
// 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
|
// If the deposit IS paid after the deadline but before the appointment, the
|
||||||
// booking flips back to confirmed.
|
// booking flips back to confirmed.
|
||||||
func CleanupExpiredDeposits(ctx context.Context) error {
|
func CleanupExpiredDeposits(ctx context.Context) (int, error) {
|
||||||
tx, err := db.Conn.Begin(ctx)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
@@ -646,13 +667,13 @@ func CleanupExpiredDeposits(ctx context.Context) error {
|
|||||||
RETURNING id, user_id
|
RETURNING id, user_id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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() {
|
for rows.Next() {
|
||||||
var b evictedBooking
|
var b evictedBooking
|
||||||
if err := rows.Scan(&b.id, &b.userID); err != nil {
|
if err := rows.Scan(&b.id, &b.userID); err != nil {
|
||||||
rows.Close()
|
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)
|
evicted = append(evicted, b)
|
||||||
}
|
}
|
||||||
@@ -674,21 +695,22 @@ func CleanupExpiredDeposits(ctx context.Context) error {
|
|||||||
RETURNING id, user_id
|
RETURNING id, user_id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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() {
|
for rows.Next() {
|
||||||
var b evictedBooking
|
var b evictedBooking
|
||||||
if err := rows.Scan(&b.id, &b.userID); err != nil {
|
if err := rows.Scan(&b.id, &b.userID); err != nil {
|
||||||
rows.Close()
|
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)
|
evicted = append(evicted, b)
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
if len(evicted) == 0 {
|
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.
|
// Create admin notifications and clean up time_blockers for evicted bookings.
|
||||||
ids := make([]string, len(evicted))
|
ids := make([]string, len(evicted))
|
||||||
@@ -707,7 +729,7 @@ func CleanupExpiredDeposits(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
`, ids, userIDs)
|
`, ids, userIDs)
|
||||||
if err != nil {
|
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, `
|
_, err = tx.Exec(ctx, `
|
||||||
@@ -720,10 +742,10 @@ func CleanupExpiredDeposits(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
`, ids, userIDs)
|
`, ids, userIDs)
|
||||||
if err != nil {
|
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).
|
// 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
|
// Pre-expiry email warnings are intentionally omitted: gift cards are unowned
|
||||||
// (bought as gifts, change hands) until redeemed to an account. After redemption,
|
// (bought as gifts, change hands) until redeemed to an account. After redemption,
|
||||||
// the balance is covered by CleanupIdleAccounts warnings.
|
// 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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
@@ -760,7 +782,7 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
|
|||||||
AND last_used_at < NOW() - INTERVAL '24 months'
|
AND last_used_at < NOW() - INTERVAL '24 months'
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -775,7 +797,7 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
|
|||||||
balance float64
|
balance float64
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&card.id, &card.balance); err != nil {
|
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)
|
expiredCards = append(expiredCards, card)
|
||||||
}
|
}
|
||||||
@@ -792,7 +814,7 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
|
|||||||
INSERT INTO gift_card_expired_balances (original_balance, expired_at)
|
INSERT INTO gift_card_expired_balances (original_balance, expired_at)
|
||||||
SELECT unnest($1::numeric[]), NOW()
|
SELECT unnest($1::numeric[]), NOW()
|
||||||
`, balances); err != nil {
|
`, 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, `
|
if _, err = tx.Exec(ctx, `
|
||||||
@@ -800,18 +822,18 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
|
|||||||
SET amount_remaining = 0, last_used_at = NOW()
|
SET amount_remaining = 0, last_used_at = NOW()
|
||||||
WHERE id = ANY($1)
|
WHERE id = ANY($1)
|
||||||
`, ids); err != nil {
|
`, 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, `
|
if _, err = tx.Exec(ctx, `
|
||||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes)
|
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'
|
SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused'
|
||||||
`, ids, balances); err != nil {
|
`, 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.
|
// 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'
|
// AND u.last_login_at < NOW() - INTERVAL '18 months'
|
||||||
//
|
//
|
||||||
// Store sent warnings in account_email_warnings table to avoid duplicates.
|
// 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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
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')
|
AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid')
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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()
|
defer rowsWithBalance.Close()
|
||||||
|
|
||||||
@@ -876,7 +898,7 @@ func CleanupIdleAccounts(ctx context.Context) error {
|
|||||||
balance float64
|
balance float64
|
||||||
}
|
}
|
||||||
if err := rowsWithBalance.Scan(&acc.id, &acc.balance); err != nil {
|
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)
|
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)
|
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
|
||||||
SELECT unnest($1::text[]), unnest($2::numeric[]), NOW()
|
SELECT unnest($1::text[]), unnest($2::numeric[]), NOW()
|
||||||
`, ids, balances); err != nil {
|
`, 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, `
|
if _, err = tx.Exec(ctx, `
|
||||||
@@ -901,13 +923,13 @@ func CleanupIdleAccounts(ctx context.Context) error {
|
|||||||
SET balance = 0, updated_at = NOW()
|
SET balance = 0, updated_at = NOW()
|
||||||
WHERE user_id = ANY($1)
|
WHERE user_id = ANY($1)
|
||||||
`, ids); err != nil {
|
`, 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, `
|
if _, err = tx.Exec(ctx, `
|
||||||
SELECT anonymize_user(unnest($1::text[]))
|
SELECT anonymize_user(unnest($1::text[]))
|
||||||
`, ids); err != nil {
|
`, 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')
|
AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid')
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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()
|
defer rowsNoBalance.Close()
|
||||||
|
|
||||||
@@ -930,7 +952,7 @@ func CleanupIdleAccounts(ctx context.Context) error {
|
|||||||
for rowsNoBalance.Next() {
|
for rowsNoBalance.Next() {
|
||||||
var id string
|
var id string
|
||||||
if err := rowsNoBalance.Scan(&id); err != nil {
|
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)
|
accountsNoBalance = append(accountsNoBalance, id)
|
||||||
}
|
}
|
||||||
@@ -939,23 +961,25 @@ func CleanupIdleAccounts(ctx context.Context) error {
|
|||||||
if _, err = tx.Exec(ctx, `
|
if _, err = tx.Exec(ctx, `
|
||||||
SELECT anonymize_user(unnest($1::text[]))
|
SELECT anonymize_user(unnest($1::text[]))
|
||||||
`, accountsNoBalance); err != nil {
|
`, 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
|
// CleanupOldIdempotencyKeys clears idempotency keys from bookings, payments, and
|
||||||
// till_sales that are older than 24 hours and no longer pending. This prevents
|
// 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.
|
// 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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
var totalRows int
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
UPDATE bookings
|
UPDATE bookings
|
||||||
SET idempotency_key = NULL
|
SET idempotency_key = NULL
|
||||||
WHERE idempotency_key IS NOT NULL
|
WHERE idempotency_key IS NOT NULL
|
||||||
@@ -963,10 +987,11 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
|
|||||||
AND status != 'pending'
|
AND status != 'pending'
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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
|
UPDATE payments
|
||||||
SET idempotency_key = NULL
|
SET idempotency_key = NULL
|
||||||
WHERE idempotency_key IS NOT NULL
|
WHERE idempotency_key IS NOT NULL
|
||||||
@@ -974,40 +999,42 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
|
|||||||
AND status != 'pending'
|
AND status != 'pending'
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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
|
UPDATE till_sales
|
||||||
SET idempotency_key = NULL
|
SET idempotency_key = NULL
|
||||||
WHERE idempotency_key IS NOT NULL
|
WHERE idempotency_key IS NOT NULL
|
||||||
AND created_at < NOW() - INTERVAL '24 hours'
|
AND created_at < NOW() - INTERVAL '24 hours'
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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.
|
// CleanupOldNameHistory removes name_history entries older than 6 months.
|
||||||
// WHY: GDPR requires data minimization — name change history doesn't need
|
// WHY: GDPR requires data minimization — name change history doesn't need
|
||||||
// to be retained indefinitely. 6 months provides a reasonable window for
|
// to be retained indefinitely. 6 months provides a reasonable window for
|
||||||
// displaying former names on booking receipts and admin views.
|
// 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)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
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)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
DELETE FROM name_history
|
DELETE FROM name_history
|
||||||
WHERE changed_at < NOW() - INTERVAL '6 months'
|
WHERE changed_at < NOW() - INTERVAL '6 months'
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ func TestCleanupOldReservations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupOldReservations(ctx)
|
_, err = CleanupOldReservations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -688,7 +688,7 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupOldReservations(ctx)
|
_, err = CleanupOldReservations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -744,7 +744,7 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupOldReservations(ctx)
|
_, err = CleanupOldReservations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -870,7 +870,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupOldReservations(ctx)
|
_, err = CleanupOldReservations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1154,7 +1154,7 @@ func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run anonymization
|
// Run anonymization
|
||||||
err = AnonymizeStaleGuestAccounts(ctx)
|
_, err = AnonymizeStaleGuestAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1216,7 @@ func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run anonymization
|
// Run anonymization
|
||||||
err = AnonymizeStaleGuestAccounts(ctx)
|
_, err = AnonymizeStaleGuestAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1254,7 +1254,7 @@ func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run anonymization
|
// Run anonymization
|
||||||
err = AnonymizeStaleGuestAccounts(ctx)
|
_, err = AnonymizeStaleGuestAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1305,8 +1305,17 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) {
|
|||||||
t.Fatalf("failed to create payment: %v", err)
|
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
|
// Run cleanup
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1393,7 +1402,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1451,8 +1460,17 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) {
|
|||||||
t.Fatalf("failed to create payment: %v", err)
|
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
|
// Run cleanup
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
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)
|
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
|
// Run cleanup
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1600,8 +1627,17 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) {
|
|||||||
t.Fatalf("failed to create payment: %v", err)
|
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
|
// First run
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first cleanup failed: %v", err)
|
t.Fatalf("first cleanup failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1620,7 +1656,7 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second run
|
// Second run
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second cleanup failed: %v", err)
|
t.Fatalf("second cleanup failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1694,7 +1730,7 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1770,7 +1806,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1842,8 +1878,17 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T)
|
|||||||
t.Fatalf("failed to create refund: %v", err)
|
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
|
// Run cleanup
|
||||||
err = CleanupExpiredFinancialRecords(ctx)
|
_, err = CleanupExpiredFinancialRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1915,7 +1960,7 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) {
|
|||||||
`, guest3ID)
|
`, guest3ID)
|
||||||
|
|
||||||
// Run anonymization
|
// Run anonymization
|
||||||
err := AnonymizeStaleGuestAccounts(ctx)
|
_, err := AnonymizeStaleGuestAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1979,7 +2024,7 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupOldReservations(ctx)
|
_, err = CleanupOldReservations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
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)
|
t.Fatalf("failed to create reservation time blocker: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupExpiredDeposits(ctx)
|
_, err = CleanupExpiredDeposits(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
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)
|
t.Fatalf("failed to create reservation time blocker: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupExpiredDeposits(ctx)
|
_, err = CleanupExpiredDeposits(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2173,7 +2218,7 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredDeposits(ctx)
|
_, err = CleanupExpiredDeposits(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2233,7 +2278,7 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredDeposits(ctx)
|
_, err = CleanupExpiredDeposits(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2288,7 +2333,7 @@ func TestCleanupExpiredGiftCards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredGiftCards(ctx)
|
_, err = CleanupExpiredGiftCards(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
|
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2360,7 +2405,7 @@ func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredGiftCards(ctx)
|
_, err = CleanupExpiredGiftCards(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
|
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2419,7 +2464,7 @@ func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupExpiredGiftCards(ctx)
|
_, err = CleanupExpiredGiftCards(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
|
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2476,7 +2521,7 @@ func TestCleanupIdleAccounts_WithBalance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupIdleAccounts(ctx)
|
_, err = CleanupIdleAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2536,7 +2581,7 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupIdleAccounts(ctx)
|
_, err = CleanupIdleAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2572,7 +2617,7 @@ func TestCleanupIdleAccounts_SkipActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupIdleAccounts(ctx)
|
_, err = CleanupIdleAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2616,7 +2661,7 @@ func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupIdleAccounts(ctx)
|
_, err = CleanupIdleAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2687,7 +2732,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup
|
// Run cleanup
|
||||||
err = CleanupOldIdempotencyKeys(ctx)
|
_, err = CleanupOldIdempotencyKeys(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
|
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)
|
t.Fatalf("failed to create old payment: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupOldIdempotencyKeys(ctx)
|
_, err = CleanupOldIdempotencyKeys(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
|
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)
|
t.Fatalf("failed to create old till_sale: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupOldIdempotencyKeys(ctx)
|
_, err = CleanupOldIdempotencyKeys(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
|
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2817,7 +2862,7 @@ func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) {
|
|||||||
t.Fatalf("failed to set booking: %v", err)
|
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)
|
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2867,7 +2912,7 @@ func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { fixtures.DeletePayment(tx, paymentID) })
|
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)
|
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)
|
t.Fatalf("failed to insert applied redemption: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupExpiredLoyaltyRedemptions(ctx)
|
_, err = CleanupExpiredLoyaltyRedemptions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
|
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)
|
t.Fatalf("failed to insert active redemption: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupExpiredLoyaltyRedemptions(ctx)
|
_, err = CleanupExpiredLoyaltyRedemptions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
|
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2976,7 +3021,7 @@ func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx, _ := resetTestData(t)
|
ctx, _ := resetTestData(t)
|
||||||
|
|
||||||
err := CleanupExpiredLoyaltyRedemptions(ctx)
|
_, err := CleanupExpiredLoyaltyRedemptions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
|
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)
|
t.Fatalf("failed to insert recent name_history: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupOldNameHistory(ctx)
|
_, err = CleanupOldNameHistory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldNameHistory failed: %v", err)
|
t.Fatalf("CleanupOldNameHistory failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -3053,7 +3098,7 @@ func TestCleanupOldNameHistory_EmptyTable(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx, _ := resetTestData(t)
|
ctx, _ := resetTestData(t)
|
||||||
|
|
||||||
err := CleanupOldNameHistory(ctx)
|
_, err := CleanupOldNameHistory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupOldNameHistory failed: %v", err)
|
t.Fatalf("CleanupOldNameHistory failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -3078,12 +3123,12 @@ func TestCleanupOldNameHistory_Idempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup twice
|
// Run cleanup twice
|
||||||
err = CleanupOldNameHistory(ctx)
|
_, err = CleanupOldNameHistory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first CleanupOldNameHistory failed: %v", err)
|
t.Fatalf("first CleanupOldNameHistory failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CleanupOldNameHistory(ctx)
|
_, err = CleanupOldNameHistory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second CleanupOldNameHistory failed: %v", err)
|
t.Fatalf("second CleanupOldNameHistory failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user