diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index 9a0d488..a7fcf02 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -39,6 +39,39 @@ import ( ) // ============================================================================= +func seedDefaultWorkingHours(t *testing.T) { + t.Helper() + + // Seed 7 days of working hours (Monday=0 to Sunday=6) + // Use wide hours to avoid test failures due to business logic time checks + hours := []struct { + weekday int + startTime string + endTime string + isOpen bool + }{ + {0, "08:00", "20:00", true}, // Monday + {1, "08:00", "20:00", true}, // Tuesday + {2, "08:00", "20:00", true}, // Wednesday + {3, "08:00", "20:00", true}, // Thursday + {4, "08:00", "20:00", true}, // Friday + {5, "08:00", "20:00", true}, // Saturday + {6, "08:00", "20:00", true}, // Sunday + } + + for _, h := range hours { + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO working_hours (weekday, start_time, end_time, is_open) + VALUES ($1, $2, $3, $4) + ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4 + `, h.weekday, h.startTime, h.endTime, h.isOpen) + if err != nil { + t.Fatalf("failed to seed working hours: %v", err) + } + } +} + + // List Admin Bookings Tests // ============================================================================= diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 2e98615..b0e483b 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -25,6 +26,7 @@ import ( "crussell/db" "crussell/mw" + "crussell/testutils/fixtures" "crussell/testutils/jwt" "crussell/testutils/testdb" @@ -94,10 +96,9 @@ func TestTimeBlockers_List(t *testing.T) { cleanup := setupTimeBlockersTestDB(t) defer cleanup() - // Create test time blockers ukLocation, _ := time.LoadLocation("Europe/London") - blockerTime1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation) - blockerTime2 := time.Date(2026, 3, 11, 14, 0, 0, 0, ukLocation) + blockerTime1 := time.Now().In(ukLocation).Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + blockerTime2 := time.Now().In(ukLocation).Add(8 * 24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) _, err := db.DB.Exec(context.Background(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) @@ -596,24 +597,47 @@ func TestCleanupOldReservations(t *testing.T) { ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") + // Create fixture users for the test + oldUserID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create old user: %v", err) + } + + recentUserID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create recent user: %v", err) + } + // Create old reservation (> 1 hour old) oldTime := time.Now().Add(-2 * time.Hour).In(ukLocation) - old_id, err := db.DB.Exec(ctx, ` + _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) - VALUES ($1, 60, $2, 'user-123')`, oldTime, "RESERVATION:user-123:timestamp-old") + VALUES ($1, 60, $2, $3)`, oldTime, fmt.Sprintf("RESERVATION:%s:timestamp-old", oldUserID), oldUserID) if err != nil { t.Fatalf("failed to create old reservation: %v", err) } + // Set old created_at to make it eligible for cleanup (> 1 hour old) + _, err = db.DB.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:%' AND start_time = $2`, time.Now().Add(-2*time.Hour), oldTime) + if err != nil { + t.Fatalf("failed to update old reservation created_at: %v", err) + } + // Create recent reservation (< 1 hour old) recentTime := time.Now().Add(-30 * time.Minute).In(ukLocation) - recent_id, err := db.DB.Exec(ctx, ` + _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) - VALUES ($1, 60, $2, 'user-456')`, recentTime, "RESERVATION:user-456:timestamp-recent") + VALUES ($1, 60, $2, $3)`, recentTime, fmt.Sprintf("RESERVATION:%s:timestamp-recent", recentUserID), recentUserID) if err != nil { t.Fatalf("failed to create recent reservation: %v", err) } + // Set recent created_at to recent (< 1 hour old) so it's NOT deleted + _, err = db.DB.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:%' AND start_time = $2`, time.Now().Add(-30*time.Minute), recentTime) + if err != nil { + t.Fatalf("failed to update recent reservation created_at: %v", err) + } + // Create non-reservation blocker (should never be deleted) nonResTime := time.Now().Add(-2 * time.Hour).In(ukLocation) _, err = db.DB.Exec(ctx, ` diff --git a/backend/testutils/testdb/testdb.go b/backend/testutils/testdb/testdb.go index 517a119..b3712f8 100644 --- a/backend/testutils/testdb/testdb.go +++ b/backend/testutils/testdb/testdb.go @@ -60,16 +60,33 @@ func Migrate(t *testing.T, pool *pgxpool.Pool) { ctx := context.Background() - // Check if database already has tables + // Check if database already has tables or types var err error - // Check if database already has tables - use information_schema which is more reliable - var tableCount int - err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'").Scan(&tableCount) - if err == nil && tableCount > 0 { + var typeCount int + err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM pg_type WHERE typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')").Scan(&typeCount) + if err == nil && typeCount > 0 { t.Log("Database already has types, dropping and recreating for clean state...") + // Drop TYPES FIRST (they have CASCADE dependencies on tables) + typeDrops := []string{ + "DROP TYPE IF EXISTS account_role CASCADE", + "DROP TYPE IF EXISTS account_type CASCADE", + "DROP TYPE IF EXISTS payment_type CASCADE", + "DROP TYPE IF EXISTS payment_method CASCADE", + "DROP TYPE IF EXISTS payment_status CASCADE", + "DROP TYPE IF EXISTS booking_status CASCADE", + "DROP TYPE IF EXISTS verification_purpose CASCADE", + "DROP TYPE IF EXISTS admin_notification_reason CASCADE", + } + for _, stmt := range typeDrops { + if _, err := pool.Exec(ctx, stmt); err != nil { + t.Logf("Warning dropping type: %v (expected if using IF EXISTS)", err) + } + } + // Drop all tables, sequences, and views in correct order dropOrder := []string{ + "forgiven_no_shows", "admin_notifications", "user_notification_preferences", "user_referrals", @@ -93,47 +110,23 @@ func Migrate(t *testing.T, pool *pgxpool.Pool) { "business_settings", } - // Drop all objects with error logging - allDropStmts := append([]string{}, dropOrder...) - allDropStmts = append(allDropStmts, - "DROP SEQUENCE IF EXISTS invoice_number_seq", - "DROP SEQUENCE IF EXISTS tags_id_seq", - "DROP SEQUENCE IF EXISTS exceptional_working_hours_id_seq", - "DROP SEQUENCE IF EXISTS exceptional_working_hours_groups_id_seq", - "DROP TYPE IF EXISTS account_role CASCADE", - "DROP TYPE IF EXISTS account_type CASCADE", - "DROP TYPE IF EXISTS payment_type CASCADE", - "DROP TYPE IF EXISTS payment_method CASCADE", - "DROP TYPE IF EXISTS payment_status CASCADE", - "DROP TYPE IF EXISTS booking_status CASCADE", - "DROP TYPE IF EXISTS verification_purpose CASCADE", - "DROP TYPE IF EXISTS admin_notification_reason CASCADE", - ) - - for _, item := range dropOrder { - stmt := fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", item) + for _, table := range dropOrder { + stmt := fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", table) if _, err := pool.Exec(ctx, stmt); err != nil { - t.Logf("Warning dropping table %s: %v (expected if using IF EXISTS)", item, err) + t.Logf("Warning dropping table %s: %v (expected if using IF EXISTS)", table, err) } } - // Drop sequences and types - for _, item := range []string{ + // Drop sequences last + seqDrops := []string{ "DROP SEQUENCE IF EXISTS invoice_number_seq", - "DROP SEQUENCE IF EXISTS tags_id_seq", + "DROP SEQUENCE IF EXISTS tags_id_seq", "DROP SEQUENCE IF EXISTS exceptional_working_hours_id_seq", "DROP SEQUENCE IF EXISTS exceptional_working_hours_groups_id_seq", - "DROP TYPE IF EXISTS account_role CASCADE", - "DROP TYPE IF EXISTS account_type CASCADE", - "DROP TYPE IF EXISTS payment_type CASCADE", - "DROP TYPE IF EXISTS payment_method CASCADE", - "DROP TYPE IF EXISTS payment_status CASCADE", - "DROP TYPE IF EXISTS booking_status CASCADE", - "DROP TYPE IF EXISTS verification_purpose CASCADE", - "DROP TYPE IF EXISTS admin_notification_reason CASCADE", - } { - if _, err := pool.Exec(ctx, item); err != nil { - t.Logf("Warning dropping item: %v", err) + } + for _, stmt := range seqDrops { + if _, err := pool.Exec(ctx, stmt); err != nil { + t.Logf("Warning dropping sequence: %v", err) } } } @@ -193,26 +186,27 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) { ctx := context.Background() - tables := []string{ - "user_social_logins", - "verification_codes", - "booking_services", - "payments", - "bookings", - "user_patch_tests", - "patch_tests", - "services", - "admin_notifications", - "user_referrals", - "user_notification_preferences", - "time_blockers", - "working_hours", - "exceptional_working_hours", - "exceptional_working_hours_groups", - "users", - "images", - "tags", - } + tables := []string{ + "forgiven_no_shows", + "user_social_logins", + "verification_codes", + "booking_services", + "payments", + "bookings", + "user_patch_tests", + "patch_tests", + "services", + "admin_notifications", + "user_referrals", + "user_notification_preferences", + "time_blockers", + "working_hours", + "exceptional_working_hours", + "exceptional_working_hours_groups", + "users", + "images", + "tags", + } for _, table := range tables { _, err := pool.Exec(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))