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"
|
||||
|
||||
"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) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils/testdb"
|
||||
"crussell/testutils/jwt"
|
||||
"crussell/testutils/testdb"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -23,4 +23,4 @@ func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
pool.Close()
|
||||
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: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 1 FROM admin_notifications an
|
||||
WHERE an.booking_id = bookings.id AND an.reason = 'no_deposit'
|
||||
)
|
||||
`)
|
||||
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 = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,9 +789,9 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
|
||||
// CleanupIdleAccounts deletes user accounts that have been idle for extended periods.
|
||||
//
|
||||
// Legal basis (GDPR Article 5(1)(e) - Storage Limitation):
|
||||
// - No money: 2 years idle (legitimate interest in customer relationship weakens)
|
||||
// - With money: 5 years idle (Scottish prescriptive period for contract claims,
|
||||
// Prescription and Limitation (Scotland) Act 1973 s.6)
|
||||
// - No money: 2 years idle (legitimate interest in customer relationship weakens)
|
||||
// - With money: 5 years idle (Scottish prescriptive period for contract claims,
|
||||
// Prescription and Limitation (Scotland) Act 1973 s.6)
|
||||
//
|
||||
// When an account with balance is deleted:
|
||||
// 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"
|
||||
// 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'
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬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 {
|
||||
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(¬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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user