feat(backend): update scheduling handlers

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:39 +01:00
co-authored by Sisyphus
parent 6ff35a5cc7
commit 6c89fea118
6 changed files with 367 additions and 111 deletions
+15 -3
View File
@@ -9,15 +9,16 @@ import (
"time"
"crussell/db"
"crussell/internal/validators"
"crussell/mw"
"log"
)
// --- Types ---
type DefaultHours struct {
Weekday int `json:"weekday"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Weekday int `json:"weekday" validate:"gte=0,lte=6"`
StartTime string `json:"startTime" validate:"required"`
EndTime string `json:"endTime" validate:"required"`
IsOpen bool `json:"isOpen"`
}
@@ -63,6 +64,16 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) {
return
}
for _, h := range hours {
if err := validators.Validate.Struct(&h); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// M8
// L5
for _, h := range hours {
if !isValidTime15Min(h.StartTime) {
http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest)
@@ -425,6 +436,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
) sub), 0) AS total_duration
FROM bookings b
WHERE b.start_time >= $1 AND b.start_time <= $2
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'pending_release', 'deposit_lapsed')
ORDER BY b.start_time
`, start, end)
@@ -7,23 +7,24 @@ import (
"time"
"crussell/db"
"crussell/internal/validators"
)
type ExceptionalHours struct {
ID int `json:"id"`
GroupID int `json:"groupId"`
Weekday int `json:"weekday"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Weekday int `json:"weekday" validate:"gte=0,lte=6"`
StartTime string `json:"startTime" validate:"required"`
EndTime string `json:"endTime" validate:"required"`
IsOpen bool `json:"isOpen"`
}
type ExceptionalGroup struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Hours []ExceptionalHours `json:"hours,omitempty"`
WeekStarts []string `json:"weekStarts,omitempty"`
Name string `json:"name" validate:"required"`
Description string `json:"description" validate:"required"`
Hours []ExceptionalHours `json:"hours,omitempty" validate:"required,min=7,max=7,dive"`
WeekStarts []string `json:"weekStarts,omitempty" validate:"required,min=1,dive,required"`
}
// --- List Groups with Hours and Applications ---
@@ -122,6 +123,14 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
return
}
if err := validators.Validate.Struct(&g); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if len(g.Hours) != 7 {
http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
return
@@ -262,8 +271,8 @@ func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
// --- Update Applied Weeks (replaces all applications for a group) ---
func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
var req struct {
GroupID int `json:"groupId"`
WeekStarts []string `json:"weekStarts"`
GroupID int `json:"groupId" validate:"required"`
WeekStarts []string `json:"weekStarts" validate:"required,min=1,dive,required"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -271,6 +280,14 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
// Validate and parse weeks
var parsedWeeks []time.Time
ukLocation, _ := time.LoadLocation("Europe/London")
@@ -705,7 +705,6 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) {
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users
// CAN see blocked time slots in the blockers field.
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"testing"
"crussell/db"
"crussell/testutils/testdb"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
)
func TestMain(m *testing.M) {
+112 -63
View File
@@ -358,6 +358,7 @@ func CleanupOldReservations(ctx context.Context) error {
OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3)
OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3)
OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4)
OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW())
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
return err
}
@@ -581,11 +582,14 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
return tx.Commit(ctx)
}
// CleanupExpiredDeposits cancels bookings where the deposit deadline (24h before
// start_time) has passed without payment. Confirmed bookings are set to 'no_deposit'
// with an admin notification. Pending bookings are silently cancelled.
// CleanupExpiredDeposits marks bookings as pending_release when the deposit
// deadline (24h before start_time) has passed without payment. The booking is
// kept alive in a vulnerable state — the slot becomes available for others to
// book, and if another booking claims it, the original is cancelled with
// deposit forfeited.
//
// TODO: notify the user once the notification system (email/SMS) is set up.
// If the deposit IS paid after the deadline but before the appointment, the
// booking flips back to confirmed.
func CleanupExpiredDeposits(ctx context.Context) error {
tx, err := db.DB.Begin(ctx)
if err != nil {
@@ -593,10 +597,15 @@ func CleanupExpiredDeposits(ctx context.Context) error {
}
defer tx.Rollback(ctx)
// Confirmed bookings past deposit deadline → no_deposit
_, err = tx.Exec(ctx, `
// Collect all evicted booking IDs from both updates so we can notify
// and clean up in one pass instead of re-scanning via updated_at = NOW().
type evictedBooking struct{ id, userID string }
var evicted []evictedBooking
// Confirmed bookings past deposit deadline → pending_release
rows, err := tx.Query(ctx, `
UPDATE bookings
SET status = 'no_deposit', updated_at = NOW()
SET status = 'pending_release', updated_at = NOW()
WHERE deposit_required = true
AND status = 'confirmed'
AND start_time - INTERVAL '24 hours' < NOW()
@@ -606,15 +615,25 @@ func CleanupExpiredDeposits(ctx context.Context) error {
AND p.status = 'completed'
AND p.created_at < bookings.start_time
)
RETURNING id, user_id
`)
if err != nil {
return fmt.Errorf("failed to expire confirmed booking deposits: %w", err)
}
for rows.Next() {
var b evictedBooking
if err := rows.Scan(&b.id, &b.userID); err != nil {
rows.Close()
return fmt.Errorf("failed to scan evicted confirmed booking: %w", err)
}
evicted = append(evicted, b)
}
rows.Close()
// Pending bookings past deposit deadline → silent cancel
_, err = tx.Exec(ctx, `
// Pending bookings past deposit deadline → pending_release
rows, err = tx.Query(ctx, `
UPDATE bookings
SET status = 'client_cancelled', updated_at = NOW()
SET status = 'pending_release', updated_at = NOW()
WHERE deposit_required = true
AND status = 'pending'
AND start_time - INTERVAL '24 hours' < NOW()
@@ -624,37 +643,56 @@ func CleanupExpiredDeposits(ctx context.Context) error {
AND p.status = 'completed'
AND p.created_at < bookings.start_time
)
RETURNING id, user_id
`)
if err != nil {
return fmt.Errorf("failed to cancel pending expired bookings: %w", err)
return fmt.Errorf("failed to expire pending expired bookings: %w", err)
}
for rows.Next() {
var b evictedBooking
if err := rows.Scan(&b.id, &b.userID); err != nil {
rows.Close()
return fmt.Errorf("failed to scan evicted pending booking: %w", err)
}
evicted = append(evicted, b)
}
rows.Close()
if len(evicted) == 0 {
return tx.Commit(ctx)
}
// Create admin notifications and clean up time_blockers for evicted bookings.
ids := make([]string, len(evicted))
userIDs := make([]string, len(evicted))
for i, b := range evicted {
ids[i] = b.id
userIDs[i] = b.userID
}
// Create admin notifications for no_deposit bookings
_, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, user_id)
SELECT 'no_deposit', id, user_id
FROM bookings
WHERE status = 'no_deposit'
AND updated_at = NOW()
AND NOT EXISTS (
SELECT 'deposit_not_paid_by_deadline', unnest($1::text[]), unnest($2::text[])
WHERE NOT EXISTS (
SELECT 1 FROM admin_notifications an
WHERE an.booking_id = bookings.id AND an.reason = 'no_deposit'
WHERE an.booking_id = ANY($1) AND an.reason = 'deposit_not_paid_by_deadline'
)
`)
`, ids, userIDs)
if err != nil {
return fmt.Errorf("failed to create no_deposit notifications: %w", err)
return fmt.Errorf("failed to create pending_release notifications: %w", err)
}
// Clean up reservation time_blockers for affected bookings
_, err = tx.Exec(ctx, `
DELETE FROM time_blockers tb
USING bookings b
WHERE tb.description LIKE 'RESERVATION:user:' || b.user_id || '%'
AND b.status IN ('no_deposit', 'client_cancelled')
AND b.updated_at = NOW()
`)
USING unnest($1::text[]) AS evicted_ids(id)
WHERE tb.created_by = ANY($2::text[])
OR tb.description ILIKE ANY(
SELECT 'RESERVATION:user:' || u || '%'
FROM unnest($2::text[]) AS u
)
`, ids, userIDs)
if err != nil {
return fmt.Errorf("failed to cleanup time_blockers for expired deposits: %w", err)
return fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err)
}
return tx.Commit(ctx)
@@ -714,30 +752,34 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
expiredCards = append(expiredCards, card)
}
for _, card := range expiredCards {
_, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
VALUES (NULL, $1, NOW())
`, card.balance)
if err != nil {
return fmt.Errorf("failed to insert expired balance for card %s: %w", card.id, err)
if len(expiredCards) > 0 {
ids := make([]string, len(expiredCards))
balances := make([]float64, len(expiredCards))
for i, c := range expiredCards {
ids[i] = c.id
balances[i] = c.balance
}
_, err = tx.Exec(ctx, `
if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (original_balance, expired_at)
SELECT unnest($1::numeric[]), NOW()
`, balances); err != nil {
return fmt.Errorf("failed to batch insert expired balances: %w", err)
}
if _, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0, last_used_at = NOW()
WHERE id = $1
`, card.id)
if err != nil {
return fmt.Errorf("failed to zero out expired card %s: %w", card.id, err)
WHERE id = ANY($1)
`, ids); err != nil {
return fmt.Errorf("failed to batch zero expired cards: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'expire', $2, 'system', NULL, NULL, 'card expired after 24 months unused')
`, card.id, card.balance)
if err != nil {
return fmt.Errorf("failed to record expire transaction for card %s: %w", card.id, err)
if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes)
SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused'
`, ids, balances); err != nil {
return fmt.Errorf("failed to batch insert expire transactions: %w", err)
}
}
@@ -767,9 +809,11 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
// - 59 months idle (with balance): "Your account will be deleted in 30 days. Balance: £X"
// Include account ID in all emails for future recovery claims.
// Query: SELECT u.id, u.email, u.last_login_at, COALESCE(b.balance, 0) as balance
//
// FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
// WHERE u.account_role NOT IN ('admin', 'guest')
// AND u.last_login_at < NOW() - INTERVAL '18 months'
//
// Store sent warnings in account_email_warnings table to avoid duplicates.
func CleanupIdleAccounts(ctx context.Context) error {
tx, err := db.DB.Begin(ctx)
@@ -809,27 +853,33 @@ func CleanupIdleAccounts(ctx context.Context) error {
accountsWithBalance = append(accountsWithBalance, acc)
}
for _, acc := range accountsWithBalance {
_, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
VALUES ($1, $2, NOW())
`, acc.id, acc.balance)
if err != nil {
return fmt.Errorf("failed to insert expired balance for account %s: %w", acc.id, err)
if len(accountsWithBalance) > 0 {
ids := make([]string, len(accountsWithBalance))
balances := make([]float64, len(accountsWithBalance))
for i, a := range accountsWithBalance {
ids[i] = a.id
balances[i] = a.balance
}
_, err = tx.Exec(ctx, `
if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
SELECT unnest($1::text[]), unnest($2::numeric[]), NOW()
`, ids, balances); err != nil {
return fmt.Errorf("failed to batch insert expired balances: %w", err)
}
if _, err = tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = 0, updated_at = NOW()
WHERE user_id = $1
`, acc.id)
if err != nil {
return fmt.Errorf("failed to zero balance for account %s: %w", acc.id, err)
WHERE user_id = ANY($1)
`, ids); err != nil {
return fmt.Errorf("failed to batch zero account balances: %w", err)
}
_, err = tx.Exec(ctx, "SELECT anonymize_user($1)", acc.id)
if err != nil {
return fmt.Errorf("failed to anonymize idle account %s: %w", acc.id, err)
for _, id := range ids {
if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil {
return fmt.Errorf("failed to anonymize idle account %s: %w", id, err)
}
}
}
@@ -849,7 +899,6 @@ func CleanupIdleAccounts(ctx context.Context) error {
defer rowsNoBalance.Close()
var accountsNoBalance []string
for rowsNoBalance.Next() {
var id string
if err := rowsNoBalance.Scan(&id); err != nil {
@@ -857,10 +906,10 @@ func CleanupIdleAccounts(ctx context.Context) error {
}
accountsNoBalance = append(accountsNoBalance, id)
}
rowsNoBalance.Close()
for _, id := range accountsNoBalance {
_, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id)
if err != nil {
if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil {
return fmt.Errorf("failed to anonymize idle account %s: %w", id, err)
}
}
+204 -25
View File
@@ -1859,9 +1859,6 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) {
// --- Tests for CleanupExpiredDeposits ---
// TestCleanupExpiredDeposits_ExpiredConfirmed verifies that a confirmed booking
// past its deposit deadline with no payment gets set to 'no_deposit', creates
// an admin notification, and cleans up the reservation time blocker.
func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
resetTestData(t)
@@ -1872,7 +1869,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
// Create booking past deposit deadline (e.g. starting 12h from now, deadline was 12h ago)
startTime := time.Now().Add(12 * time.Hour)
var bookingID string
err = db.DB.QueryRow(ctx, `
@@ -1884,7 +1880,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Fatalf("failed to create booking: %v", err)
}
// Create reservation time blocker
_, err = db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2)
@@ -1893,33 +1888,29 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Fatalf("failed to create reservation time blocker: %v", err)
}
// Run cleanup
err = CleanupExpiredDeposits(ctx)
if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
// Verify status updated to 'no_deposit'
var status string
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "no_deposit" {
t.Errorf("expected status 'no_deposit', got '%s'", status)
if status != "pending_release" {
t.Errorf("expected status 'pending_release', got '%s'", status)
}
// Verify admin notification was created
var notifCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(&notifCount)
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 no_deposit admin notification, got %d", notifCount)
t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount)
}
// Verify reservation time blocker was deleted
var tbCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil {
@@ -1930,8 +1921,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
}
}
// TestCleanupExpiredDeposits_ExpiredPending verifies that a pending booking
// past its deposit deadline with no payment gets silently cancelled.
func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
resetTestData(t)
@@ -1953,7 +1942,6 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
t.Fatalf("failed to create booking: %v", err)
}
// Create reservation time blocker
_, err = db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2)
@@ -1962,33 +1950,29 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
t.Fatalf("failed to create reservation time blocker: %v", err)
}
// Run cleanup
err = CleanupExpiredDeposits(ctx)
if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
// Verify status updated to 'client_cancelled' (silent cancel)
var status string
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "client_cancelled" {
t.Errorf("expected status 'client_cancelled', got '%s'", status)
if status != "pending_release" {
t.Errorf("expected status 'pending_release', got '%s'", status)
}
// Verify NO admin notification was created
var notifCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(&notifCount)
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected 0 no_deposit admin notifications, got %d", notifCount)
if notifCount != 1 {
t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount)
}
// Verify reservation time blocker was deleted
var tbCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil {
@@ -2651,3 +2635,198 @@ func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) {
t.Error("expected old till_sale's idempotency_key to be cleared")
}
}
// =============================================================================
// CleanupExpiredDeposits - pending_release status
// =============================================================================
func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
soon := time.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking: %v", err)
}
if err := CleanupExpiredDeposits(context.Background()); err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "pending_release" {
t.Errorf("expected status 'pending_release', got %q", status)
}
}
func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
soon := time.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
if err := CleanupExpiredDeposits(context.Background()); err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "confirmed" {
t.Errorf("expected status 'confirmed' (deposit was paid), got %q", status)
}
}
// =============================================================================
// CleanupExpiredLoyaltyRedemptions Tests
// =============================================================================
func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
// Insert expired pending redemption
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() - INTERVAL '1 day')
`, userID)
if err != nil {
t.Fatalf("failed to insert expired redemption: %v", err)
}
// Insert non-expired pending redemption (should be preserved)
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() + INTERVAL '7 days')
`, userID)
if err != nil {
t.Fatalf("failed to insert active redemption: %v", err)
}
// Insert applied redemption (different status, should be preserved)
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'applied', NOW() - INTERVAL '1 day')
`, userID)
if err != nil {
t.Fatalf("failed to insert applied redemption: %v", err)
}
err = CleanupExpiredLoyaltyRedemptions(ctx)
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
var remaining int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&remaining)
if err != nil {
t.Fatalf("failed to count redemptions: %v", err)
}
if remaining != 2 {
t.Errorf("expected 2 remaining redemptions (active + applied), got %d", remaining)
}
}
func TestCleanupExpiredLoyaltyRedemptions_NoExpired(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
// Only active redemptions
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() + INTERVAL '7 days')
`, userID)
if err != nil {
t.Fatalf("failed to insert active redemption: %v", err)
}
err = CleanupExpiredLoyaltyRedemptions(ctx)
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
var count int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&count)
if err != nil {
t.Fatalf("failed to count: %v", err)
}
if count != 1 {
t.Errorf("expected 1 remaining (no expired), got %d", count)
}
}
func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) {
resetTestData(t)
err := CleanupExpiredLoyaltyRedemptions(context.Background())
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
}