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:
@@ -9,15 +9,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"crussell/internal/validators"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
type DefaultHours struct {
|
type DefaultHours struct {
|
||||||
Weekday int `json:"weekday"`
|
Weekday int `json:"weekday" validate:"gte=0,lte=6"`
|
||||||
StartTime string `json:"startTime"`
|
StartTime string `json:"startTime" validate:"required"`
|
||||||
EndTime string `json:"endTime"`
|
EndTime string `json:"endTime" validate:"required"`
|
||||||
IsOpen bool `json:"isOpen"`
|
IsOpen bool `json:"isOpen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,16 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
for _, h := range hours {
|
||||||
if !isValidTime15Min(h.StartTime) {
|
if !isValidTime15Min(h.StartTime) {
|
||||||
http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest)
|
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
|
) sub), 0) AS total_duration
|
||||||
FROM bookings b
|
FROM bookings b
|
||||||
WHERE b.start_time >= $1 AND b.start_time <= $2
|
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
|
ORDER BY b.start_time
|
||||||
`, start, end)
|
`, start, end)
|
||||||
|
|
||||||
|
|||||||
@@ -7,23 +7,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"crussell/internal/validators"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExceptionalHours struct {
|
type ExceptionalHours struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
GroupID int `json:"groupId"`
|
GroupID int `json:"groupId"`
|
||||||
Weekday int `json:"weekday"`
|
Weekday int `json:"weekday" validate:"gte=0,lte=6"`
|
||||||
StartTime string `json:"startTime"`
|
StartTime string `json:"startTime" validate:"required"`
|
||||||
EndTime string `json:"endTime"`
|
EndTime string `json:"endTime" validate:"required"`
|
||||||
IsOpen bool `json:"isOpen"`
|
IsOpen bool `json:"isOpen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExceptionalGroup struct {
|
type ExceptionalGroup struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name" validate:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description" validate:"required"`
|
||||||
Hours []ExceptionalHours `json:"hours,omitempty"`
|
Hours []ExceptionalHours `json:"hours,omitempty" validate:"required,min=7,max=7,dive"`
|
||||||
WeekStarts []string `json:"weekStarts,omitempty"`
|
WeekStarts []string `json:"weekStarts,omitempty" validate:"required,min=1,dive,required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- List Groups with Hours and Applications ---
|
// --- List Groups with Hours and Applications ---
|
||||||
@@ -122,6 +123,14 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validators.Validate.Struct(&g); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// M8
|
||||||
|
// L5
|
||||||
|
|
||||||
if len(g.Hours) != 7 {
|
if len(g.Hours) != 7 {
|
||||||
http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
|
http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -262,8 +271,8 @@ func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
// --- Update Applied Weeks (replaces all applications for a group) ---
|
// --- Update Applied Weeks (replaces all applications for a group) ---
|
||||||
func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
|
func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
GroupID int `json:"groupId"`
|
GroupID int `json:"groupId" validate:"required"`
|
||||||
WeekStarts []string `json:"weekStarts"`
|
WeekStarts []string `json:"weekStarts" validate:"required,min=1,dive,required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -271,6 +280,14 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validators.Validate.Struct(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// M8
|
||||||
|
// L5
|
||||||
|
|
||||||
// Validate and parse weeks
|
// Validate and parse weeks
|
||||||
var parsedWeeks []time.Time
|
var parsedWeeks []time.Time
|
||||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
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
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users
|
||||||
// CAN see blocked time slots in the blockers field.
|
// CAN see blocked time slots in the blockers field.
|
||||||
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/testutils/testdb"
|
|
||||||
"crussell/testutils/jwt"
|
"crussell/testutils/jwt"
|
||||||
|
"crussell/testutils/testdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -23,4 +23,4 @@ func TestMain(m *testing.M) {
|
|||||||
code := m.Run()
|
code := m.Run()
|
||||||
pool.Close()
|
pool.Close()
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:walkin:%' AND created_at < $3)
|
||||||
OR (description LIKE 'RESERVATION:admin:callin:%' 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 '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)
|
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -581,11 +582,14 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
|
|||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupExpiredDeposits cancels bookings where the deposit deadline (24h before
|
// CleanupExpiredDeposits marks bookings as pending_release when the deposit
|
||||||
// start_time) has passed without payment. Confirmed bookings are set to 'no_deposit'
|
// deadline (24h before start_time) has passed without payment. The booking is
|
||||||
// with an admin notification. Pending bookings are silently cancelled.
|
// 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 {
|
func CleanupExpiredDeposits(ctx context.Context) error {
|
||||||
tx, err := db.DB.Begin(ctx)
|
tx, err := db.DB.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -593,10 +597,15 @@ func CleanupExpiredDeposits(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
// Confirmed bookings past deposit deadline → no_deposit
|
// Collect all evicted booking IDs from both updates so we can notify
|
||||||
_, err = tx.Exec(ctx, `
|
// 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
|
UPDATE bookings
|
||||||
SET status = 'no_deposit', updated_at = NOW()
|
SET status = 'pending_release', updated_at = NOW()
|
||||||
WHERE deposit_required = true
|
WHERE deposit_required = true
|
||||||
AND status = 'confirmed'
|
AND status = 'confirmed'
|
||||||
AND start_time - INTERVAL '24 hours' < NOW()
|
AND start_time - INTERVAL '24 hours' < NOW()
|
||||||
@@ -606,15 +615,25 @@ func CleanupExpiredDeposits(ctx context.Context) error {
|
|||||||
AND p.status = 'completed'
|
AND p.status = 'completed'
|
||||||
AND p.created_at < bookings.start_time
|
AND p.created_at < bookings.start_time
|
||||||
)
|
)
|
||||||
|
RETURNING id, user_id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to expire confirmed booking deposits: %w", err)
|
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
|
// Pending bookings past deposit deadline → pending_release
|
||||||
_, err = tx.Exec(ctx, `
|
rows, err = tx.Query(ctx, `
|
||||||
UPDATE bookings
|
UPDATE bookings
|
||||||
SET status = 'client_cancelled', updated_at = NOW()
|
SET status = 'pending_release', updated_at = NOW()
|
||||||
WHERE deposit_required = true
|
WHERE deposit_required = true
|
||||||
AND status = 'pending'
|
AND status = 'pending'
|
||||||
AND start_time - INTERVAL '24 hours' < NOW()
|
AND start_time - INTERVAL '24 hours' < NOW()
|
||||||
@@ -624,37 +643,56 @@ func CleanupExpiredDeposits(ctx context.Context) error {
|
|||||||
AND p.status = 'completed'
|
AND p.status = 'completed'
|
||||||
AND p.created_at < bookings.start_time
|
AND p.created_at < bookings.start_time
|
||||||
)
|
)
|
||||||
|
RETURNING id, user_id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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, `
|
_, err = tx.Exec(ctx, `
|
||||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
SELECT 'no_deposit', id, user_id
|
SELECT 'deposit_not_paid_by_deadline', unnest($1::text[]), unnest($2::text[])
|
||||||
FROM bookings
|
WHERE NOT EXISTS (
|
||||||
WHERE status = 'no_deposit'
|
SELECT 1 FROM admin_notifications an
|
||||||
AND updated_at = NOW()
|
WHERE an.booking_id = ANY($1) AND an.reason = 'deposit_not_paid_by_deadline'
|
||||||
AND NOT EXISTS (
|
)
|
||||||
SELECT 1 FROM admin_notifications an
|
`, ids, userIDs)
|
||||||
WHERE an.booking_id = bookings.id AND an.reason = 'no_deposit'
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
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, `
|
_, err = tx.Exec(ctx, `
|
||||||
DELETE FROM time_blockers tb
|
DELETE FROM time_blockers tb
|
||||||
USING bookings b
|
USING unnest($1::text[]) AS evicted_ids(id)
|
||||||
WHERE tb.description LIKE 'RESERVATION:user:' || b.user_id || '%'
|
WHERE tb.created_by = ANY($2::text[])
|
||||||
AND b.status IN ('no_deposit', 'client_cancelled')
|
OR tb.description ILIKE ANY(
|
||||||
AND b.updated_at = NOW()
|
SELECT 'RESERVATION:user:' || u || '%'
|
||||||
`)
|
FROM unnest($2::text[]) AS u
|
||||||
|
)
|
||||||
|
`, ids, userIDs)
|
||||||
if err != nil {
|
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)
|
return tx.Commit(ctx)
|
||||||
@@ -714,30 +752,34 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
|
|||||||
expiredCards = append(expiredCards, card)
|
expiredCards = append(expiredCards, card)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, card := range expiredCards {
|
if len(expiredCards) > 0 {
|
||||||
_, err = tx.Exec(ctx, `
|
ids := make([]string, len(expiredCards))
|
||||||
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
|
balances := make([]float64, len(expiredCards))
|
||||||
VALUES (NULL, $1, NOW())
|
for i, c := range expiredCards {
|
||||||
`, card.balance)
|
ids[i] = c.id
|
||||||
if err != nil {
|
balances[i] = c.balance
|
||||||
return fmt.Errorf("failed to insert expired balance for card %s: %w", card.id, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, 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
|
UPDATE gift_cards
|
||||||
SET amount_remaining = 0, last_used_at = NOW()
|
SET amount_remaining = 0, last_used_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = ANY($1)
|
||||||
`, card.id)
|
`, ids); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to batch zero expired cards: %w", err)
|
||||||
return fmt.Errorf("failed to zero out expired card %s: %w", card.id, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
if _, err = tx.Exec(ctx, `
|
||||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes)
|
||||||
VALUES ($1, 'expire', $2, 'system', NULL, NULL, 'card expired after 24 months unused')
|
SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused'
|
||||||
`, card.id, card.balance)
|
`, ids, balances); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to batch insert expire transactions: %w", err)
|
||||||
return fmt.Errorf("failed to record expire transaction for card %s: %w", card.id, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,9 +789,9 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
|
|||||||
// CleanupIdleAccounts deletes user accounts that have been idle for extended periods.
|
// CleanupIdleAccounts deletes user accounts that have been idle for extended periods.
|
||||||
//
|
//
|
||||||
// Legal basis (GDPR Article 5(1)(e) - Storage Limitation):
|
// Legal basis (GDPR Article 5(1)(e) - Storage Limitation):
|
||||||
// - No money: 2 years idle (legitimate interest in customer relationship weakens)
|
// - No money: 2 years idle (legitimate interest in customer relationship weakens)
|
||||||
// - With money: 5 years idle (Scottish prescriptive period for contract claims,
|
// - With money: 5 years idle (Scottish prescriptive period for contract claims,
|
||||||
// Prescription and Limitation (Scotland) Act 1973 s.6)
|
// Prescription and Limitation (Scotland) Act 1973 s.6)
|
||||||
//
|
//
|
||||||
// When an account with balance is deleted:
|
// When an account with balance is deleted:
|
||||||
// 1. Balance moves to gift_card_expired_balances for recovery claims
|
// 1. Balance moves to gift_card_expired_balances for recovery claims
|
||||||
@@ -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"
|
// - 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.
|
// 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
|
// 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')
|
// FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
|
||||||
// AND u.last_login_at < NOW() - INTERVAL '18 months'
|
// 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.
|
// Store sent warnings in account_email_warnings table to avoid duplicates.
|
||||||
func CleanupIdleAccounts(ctx context.Context) error {
|
func CleanupIdleAccounts(ctx context.Context) error {
|
||||||
tx, err := db.DB.Begin(ctx)
|
tx, err := db.DB.Begin(ctx)
|
||||||
@@ -809,27 +853,33 @@ func CleanupIdleAccounts(ctx context.Context) error {
|
|||||||
accountsWithBalance = append(accountsWithBalance, acc)
|
accountsWithBalance = append(accountsWithBalance, acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, acc := range accountsWithBalance {
|
if len(accountsWithBalance) > 0 {
|
||||||
_, err = tx.Exec(ctx, `
|
ids := make([]string, len(accountsWithBalance))
|
||||||
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
|
balances := make([]float64, len(accountsWithBalance))
|
||||||
VALUES ($1, $2, NOW())
|
for i, a := range accountsWithBalance {
|
||||||
`, acc.id, acc.balance)
|
ids[i] = a.id
|
||||||
if err != nil {
|
balances[i] = a.balance
|
||||||
return fmt.Errorf("failed to insert expired balance for account %s: %w", acc.id, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, 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
|
UPDATE user_giftcard_balances
|
||||||
SET balance = 0, updated_at = NOW()
|
SET balance = 0, updated_at = NOW()
|
||||||
WHERE user_id = $1
|
WHERE user_id = ANY($1)
|
||||||
`, acc.id)
|
`, ids); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to batch zero account balances: %w", err)
|
||||||
return fmt.Errorf("failed to zero balance for account %s: %w", acc.id, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "SELECT anonymize_user($1)", acc.id)
|
for _, id := range ids {
|
||||||
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", acc.id, err)
|
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()
|
defer rowsNoBalance.Close()
|
||||||
|
|
||||||
var accountsNoBalance []string
|
var accountsNoBalance []string
|
||||||
|
|
||||||
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 {
|
||||||
@@ -857,10 +906,10 @@ func CleanupIdleAccounts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
accountsNoBalance = append(accountsNoBalance, id)
|
accountsNoBalance = append(accountsNoBalance, id)
|
||||||
}
|
}
|
||||||
|
rowsNoBalance.Close()
|
||||||
|
|
||||||
for _, id := range accountsNoBalance {
|
for _, id := range accountsNoBalance {
|
||||||
_, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id)
|
if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to anonymize idle account %s: %w", id, err)
|
return fmt.Errorf("failed to anonymize idle account %s: %w", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1859,9 +1859,6 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) {
|
|||||||
|
|
||||||
// --- Tests for CleanupExpiredDeposits ---
|
// --- 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) {
|
func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
|
||||||
resetTestData(t)
|
resetTestData(t)
|
||||||
|
|
||||||
@@ -1872,7 +1869,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
|
|||||||
t.Fatalf("failed to create user: %v", err)
|
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)
|
startTime := time.Now().Add(12 * time.Hour)
|
||||||
var bookingID string
|
var bookingID string
|
||||||
err = db.DB.QueryRow(ctx, `
|
err = db.DB.QueryRow(ctx, `
|
||||||
@@ -1884,7 +1880,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
|
|||||||
t.Fatalf("failed to create booking: %v", err)
|
t.Fatalf("failed to create booking: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create reservation time blocker
|
|
||||||
_, err = db.DB.Exec(ctx, `
|
_, err = db.DB.Exec(ctx, `
|
||||||
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
||||||
VALUES ($1, 60, $2)
|
VALUES ($1, 60, $2)
|
||||||
@@ -1893,33 +1888,29 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify status updated to 'no_deposit'
|
|
||||||
var status string
|
var status string
|
||||||
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query booking: %v", err)
|
t.Fatalf("failed to query booking: %v", err)
|
||||||
}
|
}
|
||||||
if status != "no_deposit" {
|
if status != "pending_release" {
|
||||||
t.Errorf("expected status 'no_deposit', got '%s'", status)
|
t.Errorf("expected status 'pending_release', got '%s'", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify admin notification was created
|
|
||||||
var notifCount int
|
var notifCount int
|
||||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(¬ifCount)
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query admin notifications: %v", err)
|
t.Fatalf("failed to query admin notifications: %v", err)
|
||||||
}
|
}
|
||||||
if notifCount != 1 {
|
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
|
var tbCount int
|
||||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
||||||
if err != nil {
|
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) {
|
func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
|
||||||
resetTestData(t)
|
resetTestData(t)
|
||||||
|
|
||||||
@@ -1953,7 +1942,6 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
|
|||||||
t.Fatalf("failed to create booking: %v", err)
|
t.Fatalf("failed to create booking: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create reservation time blocker
|
|
||||||
_, err = db.DB.Exec(ctx, `
|
_, err = db.DB.Exec(ctx, `
|
||||||
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
||||||
VALUES ($1, 60, $2)
|
VALUES ($1, 60, $2)
|
||||||
@@ -1962,33 +1950,29 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify status updated to 'client_cancelled' (silent cancel)
|
|
||||||
var status string
|
var status string
|
||||||
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query booking: %v", err)
|
t.Fatalf("failed to query booking: %v", err)
|
||||||
}
|
}
|
||||||
if status != "client_cancelled" {
|
if status != "pending_release" {
|
||||||
t.Errorf("expected status 'client_cancelled', got '%s'", status)
|
t.Errorf("expected status 'pending_release', got '%s'", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify NO admin notification was created
|
|
||||||
var notifCount int
|
var notifCount int
|
||||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(¬ifCount)
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query admin notifications: %v", err)
|
t.Fatalf("failed to query admin notifications: %v", err)
|
||||||
}
|
}
|
||||||
if notifCount != 0 {
|
if notifCount != 1 {
|
||||||
t.Errorf("expected 0 no_deposit admin notifications, 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
|
var tbCount int
|
||||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
||||||
if err != nil {
|
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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user