diff --git a/backend/auth/jwt_test.go b/backend/auth/jwt_test.go index 2159578..fe8c536 100644 --- a/backend/auth/jwt_test.go +++ b/backend/auth/jwt_test.go @@ -18,19 +18,9 @@ import ( "testing" "time" - "crussell/db" + "crussell/testutils/testtx" ) -// requiresDB skips the test if the database is not available (e.g. running -// tests standalone without test DB setup). DB-backed JTI revocation tests -// need a connection to the revoked_jtis table. -func requiresDB(t *testing.T) { - t.Helper() - if db.DB == nil { - t.Skip("skipping: no database connection") - } -} - // ============================================================================= // generateJTI Tests // ============================================================================= @@ -139,12 +129,13 @@ func TestGenerateToken_JTIInClaims(t *testing.T) { // TestVerifyToken_ReturnsJTI creates a token, verifies it, and checks the // returned user_id, role, and JTI match the expected values. func TestVerifyToken_ReturnsJTI(t *testing.T) { + ctx, _ := testtx.SetupTestTx(t) token, jti, err := GenerateToken("user-003", "verified_email") if err != nil { t.Fatalf("GenerateToken() failed: %v", err) } - userID, role, returnedJTI, err := VerifyToken(token, context.Background()) + userID, role, returnedJTI, err := VerifyToken(token, ctx) if err != nil { t.Fatalf("VerifyToken() failed: %v", err) } @@ -163,15 +154,15 @@ func TestVerifyToken_ReturnsJTI(t *testing.T) { // TestVerifyToken_RevokedJTI creates a token, revokes its JTI, and verifies // that VerifyToken returns an error containing "token revoked". func TestVerifyToken_RevokedJTI(t *testing.T) { - requiresDB(t) + ctx, _ := testtx.SetupTestTx(t) token, jti, err := GenerateToken("user-004", "verified_email") if err != nil { t.Fatalf("GenerateToken() failed: %v", err) } - RevokeJTI(jti, time.Now().Add(30*24*time.Hour)) + RevokeJTI(ctx, jti, time.Now().Add(30*24*time.Hour)) - _, _, _, err = VerifyToken(token, context.Background()) + _, _, _, err = VerifyToken(token, ctx) if err == nil { t.Fatal("expected error for revoked JTI, got nil") } @@ -209,19 +200,19 @@ func TestVerifyToken_MissingJTI(t *testing.T) { // TestRevokeJTI_AddsToSet verifies that calling RevokeJTI adds the JTI to the // revoked set, and IsJTIRevoked returns true for it. func TestRevokeJTI_AddsToSet(t *testing.T) { - requiresDB(t) + ctx, _ := testtx.SetupTestTx(t) _, jti, err := GenerateToken("user-006", "verified_email") if err != nil { t.Fatalf("GenerateToken() failed: %v", err) } - if IsJTIRevoked(jti) { + if IsJTIRevoked(ctx, jti) { t.Fatal("JTI should not be revoked before calling RevokeJTI") } - RevokeJTI(jti, time.Now().Add(30*24*time.Hour)) + RevokeJTI(ctx, jti, time.Now().Add(30*24*time.Hour)) - if !IsJTIRevoked(jti) { + if !IsJTIRevoked(ctx, jti) { t.Error("expected IsJTIRevoked to return true after RevokeJTI") } } @@ -229,7 +220,8 @@ func TestRevokeJTI_AddsToSet(t *testing.T) { // TestIsJTIRevoked_NonExistent verifies that checking a non-existent JTI // returns false. func TestIsJTIRevoked_NonExistent(t *testing.T) { - if IsJTIRevoked("nonexistent-jti-12345") { + ctx, _ := testtx.SetupTestTx(t) + if IsJTIRevoked(ctx, "nonexistent-jti-12345") { t.Error("expected IsJTIRevoked to return false for non-existent JTI") } } @@ -241,29 +233,29 @@ func TestIsJTIRevoked_NonExistent(t *testing.T) { // TestCleanupRevokedJTIs_RemovesExpired adds a JTI with a past expiry time, // runs CleanupRevokedJTIs, and verifies the JTI is removed from the set. func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) { - requiresDB(t) + ctx, tx := testtx.SetupTestTx(t) _, jti, err := GenerateToken("user-007", "verified_email") if err != nil { t.Fatalf("GenerateToken() failed: %v", err) } // Add with future expiry so IsJTIRevoked sees it - RevokeJTI(jti, time.Now().Add(1*time.Hour)) + RevokeJTI(ctx, jti, time.Now().Add(1*time.Hour)) - if !IsJTIRevoked(jti) { + if !IsJTIRevoked(ctx, jti) { t.Fatal("JTI should be in revoked set after RevokeJTI") } // Directly update the DB to set expiry in the past - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE revoked_jtis SET expires_at = NOW() - INTERVAL '1 hour' WHERE jti = $1", jti) if err != nil { t.Fatalf("failed to expire JTI: %v", err) } - CleanupRevokedJTIs() + CleanupRevokedJTIs(ctx) - if IsJTIRevoked(jti) { + if IsJTIRevoked(ctx, jti) { t.Error("expected expired JTI to be removed after cleanup") } } @@ -271,22 +263,22 @@ func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) { // TestCleanupRevokedJTIs_KeepsValid adds a JTI with a future expiry time, // runs CleanupRevokedJTIs, and verifies the JTI is still in the set. func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) { - requiresDB(t) + ctx, _ := testtx.SetupTestTx(t) _, jti, err := GenerateToken("user-008", "verified_email") if err != nil { t.Fatalf("GenerateToken() failed: %v", err) } // Add with future expiry - RevokeJTI(jti, time.Now().Add(30*24*time.Hour)) + RevokeJTI(ctx, jti, time.Now().Add(30*24*time.Hour)) - if !IsJTIRevoked(jti) { + if !IsJTIRevoked(ctx, jti) { t.Fatal("JTI should be in revoked set before cleanup") } - CleanupRevokedJTIs() + CleanupRevokedJTIs(ctx) - if !IsJTIRevoked(jti) { + if !IsJTIRevoked(ctx, jti) { t.Error("expected valid (future expiry) JTI to remain after cleanup") } } diff --git a/backend/auth/testmain_test.go b/backend/auth/testmain_test.go index 46c4f80..37dcf38 100644 --- a/backend/auth/testmain_test.go +++ b/backend/auth/testmain_test.go @@ -15,7 +15,7 @@ func TestMain(m *testing.M) { InitJWT("test-secret-key-for-jwt-test") pool := testdb.CreateTestDatabase("crussell_test_auth") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_auth") diff --git a/backend/db/db_test.go b/backend/db/db_test.go index 1a6d926..7f74c8f 100644 --- a/backend/db/db_test.go +++ b/backend/db/db_test.go @@ -19,9 +19,9 @@ func resetEnv() { } func closePool() { - if DB != nil { - DB.Close() - DB = nil + if Conn != nil { + Conn.Pool().Close() + Conn = nil } } @@ -41,8 +41,8 @@ func TestConnect_Success(t *testing.T) { } defer closePool() - if DB == nil { - t.Fatal("DB is nil after successful Connect") + if Conn == nil { + t.Fatal("Conn is nil after successful Connect") } } @@ -56,14 +56,14 @@ func TestConnect_PingViaTestDB(t *testing.T) { } defer closePool() - conn, err := DB.Acquire(context.Background()) + poolConn, err := Conn.Acquire(context.Background()) if err != nil { t.Fatalf("Acquire failed: %v", err) } - defer conn.Release() + defer poolConn.Release() var result int - err = conn.QueryRow(context.Background(), "SELECT 1").Scan(&result) + err = poolConn.QueryRow(context.Background(), "SELECT 1").Scan(&result) if err != nil { t.Fatalf("Ping query failed: %v", err) } @@ -127,15 +127,15 @@ func TestConcurrentQueries(t *testing.T) { wg.Add(1) go func(id int) { defer wg.Done() - conn, err := DB.Acquire(context.Background()) + poolConn, err := Conn.Acquire(context.Background()) if err != nil { errs <- fmt.Errorf("goroutine %d: acquire: %w", id, err) return } - defer conn.Release() + defer poolConn.Release() var result int - err = conn.QueryRow(context.Background(), "SELECT $1::int", id).Scan(&result) + err = poolConn.QueryRow(context.Background(), "SELECT $1::int", id).Scan(&result) if err != nil { errs <- fmt.Errorf("goroutine %d: query: %w", id, err) return diff --git a/backend/handlers/admin/bookings_extra_test.go b/backend/handlers/admin/bookings_extra_test.go index 5c8023c..e69b24f 100644 --- a/backend/handlers/admin/bookings_extra_test.go +++ b/backend/handlers/admin/bookings_extra_test.go @@ -4,12 +4,10 @@ package admin import ( - "context" "net/http" "testing" "time" - "crussell/db" "crussell/testutils" "crussell/handlers/bookings" "crussell/testutils/fixtures" @@ -17,35 +15,31 @@ import ( // TestGetOverlappingBookingsByTime verifies the new overlapping bookings endpoint func TestGetOverlappingBookingsByTime(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) startTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime) + _, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.GetOverlappingBookingsByTimeHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/overlapping?start=2026-03-16T09:00:00Z&end=2026-03-16T11:00:00Z", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/overlapping?start=2026-03-16T09:00:00Z&end=2026-03-16T11:00:00Z", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -63,35 +57,31 @@ func TestGetOverlappingBookingsByTime(t *testing.T) { // TestGetBookingsByDateRange verifies the new bookings by date range endpoint func TestGetBookingsByDateRange(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) startTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime) + _, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.GetBookingsByDateRangeHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-date-range?start=2026-03-16&end=2026-03-16", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-date-range?start=2026-03-16&end=2026-03-16", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -109,32 +99,28 @@ func TestGetBookingsByDateRange(t *testing.T) { // TestAdminRescheduleBooking verifies the new reschedule endpoint func TestAdminRescheduleBooking(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) startTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) newStartTime := startTime.Add(2 * time.Hour) req := map[string]interface{}{ @@ -142,14 +128,14 @@ func TestAdminRescheduleBooking(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminRescheduleBookingHandler) - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", req) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", req, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var dbStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) diff --git a/backend/handlers/admin/bookings_fields_test.go b/backend/handlers/admin/bookings_fields_test.go index 5f75b49..78abff7 100644 --- a/backend/handlers/admin/bookings_fields_test.go +++ b/backend/handlers/admin/bookings_fields_test.go @@ -8,53 +8,48 @@ import ( "net/http" "testing" - "crussell/db" - "crussell/testutils" "crussell/handlers/bookings" + "crussell/testutils" "crussell/testutils/fixtures" ) // TestAdminBookings_Get_EnrichedFields verifies that CreatedByName and User.DateOfBirth are populated. func TestAdminBookings_Get_EnrichedFields(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) dob := "1990-01-01" - _, err = db.DB.Exec(context.Background(), "UPDATE users SET date_of_birth = $1 WHERE id = $2", dob, userID) + _, err = tx.Exec(context.Background(), "UPDATE users SET date_of_birth = $1 WHERE id = $2", dob, userID) if err != nil { t.Fatalf("failed to update user dob: %v", err) } - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET created_by = $1 WHERE id = $2", adminID, bookingID) + _, err = tx.Exec(context.Background(), "UPDATE bookings SET created_by = $1 WHERE id = $2", adminID, bookingID) if err != nil { t.Fatalf("failed to update booking created_by: %v", err) } handler := http.HandlerFunc(bookings.GetAdminBookingHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index 1640c1e..cf0d667 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -41,66 +41,32 @@ 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 // ============================================================================= // TestAdminBookings_List verifies that an admin can list all bookings in the // system with pagination support. func TestAdminBookings_List(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Insert name history for the user - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'OldFirst', 'OldLast') `, userID) @@ -108,20 +74,18 @@ func TestAdminBookings_List(t *testing.T) { t.Fatalf("failed to insert name_history: %v", err) } - bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 1: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID1) - bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 2: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID2) handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -158,38 +122,35 @@ func TestAdminBookings_List(t *testing.T) { // is accepted (was previously capped at 100, causing per_page=500 to fall // back to default 10 and miss bookings on page 2+). func TestAdminBookings_List_PerPageCap(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create 3 bookings so we can verify per_page=500 returns all of them for i := 0; i < 3; i++ { - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking %d: %v", i+1, err) } - defer fixtures.DeleteBooking(db.DB, bookingID) } // Test per_page=500 (should be accepted and return all 3 bookings) handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=500", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=500", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -211,7 +172,7 @@ func TestAdminBookings_List_PerPageCap(t *testing.T) { } // Test per_page=600 (should be rejected and fall back to default 10) - w2 := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=600", nil) + w2 := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=600", nil, ctx) if w2.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w2.Code) } @@ -227,34 +188,31 @@ func TestAdminBookings_List_PerPageCap(t *testing.T) { // TestAdminBookings_List_FilterByStatus tests that an admin can filter // bookings by status (e.g., pending, confirmed, completed). func TestAdminBookings_List_FilterByStatus(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings?status=pending", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings?status=pending", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -269,7 +227,7 @@ func TestAdminBookings_List_FilterByStatus(t *testing.T) { t.Errorf("expected 1 pending booking, got %d", len(resp.Bookings)) } - w = makeAdminRequest(handler, "GET", "/api/admin/bookings?status=completed", nil) + w = makeAdminRequest(handler, "GET", "/api/admin/bookings?status=completed", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -291,25 +249,23 @@ func TestAdminBookings_List_FilterByStatus(t *testing.T) { // TestAdminBookings_Create verifies that an admin can create a booking // on behalf of a user. The booking is created with 'confirmed' status. func TestAdminBookings_Create(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) req := bookings.AdminCreateBookingForUserRequest{ @@ -319,7 +275,7 @@ func TestAdminBookings_Create(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -345,7 +301,7 @@ func TestAdminBookings_Create(t *testing.T) { } var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) @@ -359,19 +315,18 @@ func TestAdminBookings_Create(t *testing.T) { // creation fails with HTTP 400 when required fields (userID, startTime, serviceIDs) // are missing or invalid. func TestAdminBookings_Create_InvalidInput(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) tests := []struct { name string @@ -411,7 +366,7 @@ func TestAdminBookings_Create_InvalidInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", tt.req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", tt.req, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) @@ -427,40 +382,37 @@ func TestAdminBookings_Create_InvalidInput(t *testing.T) { // TestAdminBookings_Search tests that an admin can search bookings by // notes, customer name, or other text fields. func TestAdminBookings_Search(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'Test booking for search' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking notes: %v", err) } handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Test", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Test", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -479,16 +431,16 @@ func TestAdminBookings_Search(t *testing.T) { // TestAdminBookings_Search_MissingQuery verifies that searching without // a query parameter returns HTTP 400 Bad Request. func TestAdminBookings_Search_MissingQuery(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) @@ -502,34 +454,31 @@ func TestAdminBookings_Search_MissingQuery(t *testing.T) { // TestAdminBookings_Get verifies that an admin can retrieve a single booking by ID and that // the response includes populated user and services relationships. func TestAdminBookings_Get(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.GetAdminBookingHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -555,16 +504,16 @@ func TestAdminBookings_Get(t *testing.T) { // TestAdminBookings_Get_NotFound verifies that requesting a non-existent booking returns 404. func TestAdminBookings_Get_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) handler := http.HandlerFunc(bookings.GetAdminBookingHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/nonexistent-id", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/nonexistent-id", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -577,40 +526,36 @@ func TestAdminBookings_Get_NotFound(t *testing.T) { // TestAdminBookings_GetUserBookings verifies that an admin can retrieve all bookings for a specific user. func TestAdminBookings_GetUserBookings(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 1: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID1) - bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 2: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID2) handler := http.HandlerFunc(bookings.GetAllBookingsByUserHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/user/"+userID, nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/user/"+userID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -637,38 +582,35 @@ func TestAdminBookings_GetUserBookings(t *testing.T) { // TestAdminBookings_Progress verifies that an admin can change a booking's status (e.g., pending to confirmed), // and that the status is correctly updated in both the response and database. func TestAdminBookings_Progress(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) req := bookings.ProgressBookingRequest{ Status: "confirmed", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -684,7 +626,7 @@ func TestAdminBookings_Progress(t *testing.T) { } var dbStatus string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -696,38 +638,35 @@ func TestAdminBookings_Progress(t *testing.T) { // TestAdminBookings_Progress_InvalidStatus verifies that providing an invalid status value returns 400 Bad Request. func TestAdminBookings_Progress_InvalidStatus(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) req := bookings.ProgressBookingRequest{ Status: "invalid_status", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) @@ -736,20 +675,20 @@ func TestAdminBookings_Progress_InvalidStatus(t *testing.T) { // TestAdminBookings_Progress_NotFound verifies that attempting to progress a non-existent booking returns 404. func TestAdminBookings_Progress_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) req := bookings.ProgressBookingRequest{ Status: "confirmed", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/nonexistent-id/progress", req) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/nonexistent-id/progress", req, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -762,36 +701,33 @@ func TestAdminBookings_Progress_NotFound(t *testing.T) { // TestAdminBookings_Confirm verifies that an admin can confirm a pending booking, updating its status to 'confirmed'. func TestAdminBookings_Confirm(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) req := bookings.ConfirmBookingRequest{} handler := http.HandlerFunc(bookings.ConfirmBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -810,34 +746,30 @@ func TestAdminBookings_Confirm(t *testing.T) { // TestAdminBookings_Confirm_AlreadyConfirmed verifies idempotency - attempting to confirm // an already-confirmed booking returns 404 Not Found. func TestAdminBookings_Confirm_AlreadyConfirmed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) - _, err = db.DB.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } @@ -845,7 +777,7 @@ func TestAdminBookings_Confirm_AlreadyConfirmed(t *testing.T) { req := bookings.ConfirmBookingRequest{} handler := http.HandlerFunc(bookings.ConfirmBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -858,47 +790,43 @@ func TestAdminBookings_Confirm_AlreadyConfirmed(t *testing.T) { // TestAdminBookings_Cancel verifies that an admin can cancel a booking, updating its status to 'we_cancelled'. func TestAdminBookings_Cancel(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) - _, err = db.DB.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } var dbStatus string - err = db.DB.QueryRow(ctx, + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -910,16 +838,16 @@ func TestAdminBookings_Cancel(t *testing.T) { // TestAdminBookings_Cancel_NotFound verifies that attempting to cancel a non-existent booking returns 404. func TestAdminBookings_Cancel_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/nonexistent-id/cancel", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/nonexistent-id/cancel", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -929,37 +857,33 @@ func TestAdminBookings_Cancel_NotFound(t *testing.T) { // TestAdminBookings_Cancel_PendingStatus verifies that admin cancellations of pending bookings // do NOT create admin notifications (pending cancellations don't require staff attention). func TestAdminBookings_Cancel_PendingStatus(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Booking stays in 'pending' status (no confirmation) handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -967,7 +891,7 @@ func TestAdminBookings_Cancel_PendingStatus(t *testing.T) { // Verify status changed to we_cancelled var dbStatus string - err = db.DB.QueryRow(ctx, + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -978,7 +902,7 @@ func TestAdminBookings_Cancel_PendingStatus(t *testing.T) { // Verify NO admin notification was created for pending cancellations var notifCount int - err = db.DB.QueryRow(ctx, + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1`, bookingID).Scan(¬ifCount) if err != nil { @@ -992,41 +916,37 @@ func TestAdminBookings_Cancel_PendingStatus(t *testing.T) { // TestAdminBookings_Cancel_ConfirmedCreatesNotification verifies that cancelling a confirmed // booking creates an admin notification for staff awareness. func TestAdminBookings_Cancel_ConfirmedCreatesNotification(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking - _, err = db.DB.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -1034,7 +954,7 @@ func TestAdminBookings_Cancel_ConfirmedCreatesNotification(t *testing.T) { // Verify status changed to we_cancelled var dbStatus string - err = db.DB.QueryRow(ctx, + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -1045,7 +965,7 @@ func TestAdminBookings_Cancel_ConfirmedCreatesNotification(t *testing.T) { // Verify admin notification WAS created for confirmed->cancelled var notifCount int - err = db.DB.QueryRow(ctx, + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) if err != nil { @@ -1058,41 +978,37 @@ func TestAdminBookings_Cancel_ConfirmedCreatesNotification(t *testing.T) { // TestAdminBookings_Cancel_InProgressStatus verifies cancellation of in-progress bookings. func TestAdminBookings_Cancel_InProgressStatus(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Set booking to in-progress status - _, err = db.DB.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set in_progress status: %v", err) } handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -1100,7 +1016,7 @@ func TestAdminBookings_Cancel_InProgressStatus(t *testing.T) { // Verify status changed to we_cancelled var dbStatus string - err = db.DB.QueryRow(ctx, + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -1113,42 +1029,38 @@ func TestAdminBookings_Cancel_InProgressStatus(t *testing.T) { // TestAdminBookings_Cancel_AlreadyCancelledRejectsCancellation verifies that attempting to // cancel an already-cancelled booking returns 404 Not Found (idempotency guard). func TestAdminBookings_Cancel_AlreadyCancelledRejectsCancellation(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Set to already cancelled - _, err = db.DB.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set we_cancelled status: %v", err) } // Try to cancel again handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for already-cancelled booking, got %d", w.Code) @@ -1158,42 +1070,38 @@ func TestAdminBookings_Cancel_AlreadyCancelledRejectsCancellation(t *testing.T) // TestAdminBookings_Cancel_CompletedRejectsCancellation verifies that attempting to cancel // a completed booking returns 404 Not Found (cannot cancel finished appointments). func TestAdminBookings_Cancel_CompletedRejectsCancellation(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Set to completed - _, err = db.DB.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set completed status: %v", err) } // Try to cancel handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for completed booking, got %d", w.Code) @@ -1207,32 +1115,30 @@ func TestAdminBookings_Cancel_CompletedRejectsCancellation(t *testing.T) { // TestAdminBookings_NonAdmin verifies that regular users receive 403 Forbidden when attempting // to access any admin booking endpoints (list, get, create, progress, confirm, cancel, search). func TestAdminBookings_NonAdmin(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := fixtures.CreateTestUser(db.DB) + _, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) - w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAllAdminBookingsHandler)), "GET", "/api/admin/bookings", nil) + w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAllAdminBookingsHandler)), "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("LIST: expected status 403, got %d", w.Code) } @@ -1242,34 +1148,34 @@ func TestAdminBookings_NonAdmin(t *testing.T) { StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{serviceID}, } - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)), "POST", "/api/admin/bookings", req) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)), "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusForbidden { t.Errorf("CREATE: expected status 403, got %d", w.Code) } - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.SearchAdminBookingsHandler)), "GET", "/api/admin/bookings/search?q=test", nil) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.SearchAdminBookingsHandler)), "GET", "/api/admin/bookings/search?q=test", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("SEARCH: expected status 403, got %d", w.Code) } - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAdminBookingHandler)), "GET", "/api/admin/bookings/"+bookingID, nil) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAdminBookingHandler)), "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("GET: expected status 403, got %d", w.Code) } progressReq := bookings.ProgressBookingRequest{Status: "confirmed"} - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ProgressBookingHandler)), "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ProgressBookingHandler)), "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx) if w.Code != http.StatusForbidden { t.Errorf("PROGRESS: expected status 403, got %d", w.Code) } confirmReq := bookings.ConfirmBookingRequest{} - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ConfirmBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ConfirmBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq, ctx) if w.Code != http.StatusForbidden { t.Errorf("CONFIRM: expected status 403, got %d", w.Code) } - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCancelBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCancelBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("CANCEL: expected status 403, got %d", w.Code) } @@ -1282,31 +1188,29 @@ func TestAdminBookings_NonAdmin(t *testing.T) { // TestAdminBookings_Create_DuringHolidayHours_Rejected verifies that an admin cannot create a booking // during hours marked as closed in the exceptional working hours (holiday) system. func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create an exceptional (holiday) hours group for a fixed date (Thursday) // Using absolute date - no timezone conversions targetDate := time.Date(2026, 2, 26, 0, 0, 0, 0, time.UTC) // Thursday Feb 26, 2026 var groupID int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id @@ -1314,12 +1218,11 @@ func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) { if err != nil { t.Fatalf("failed to create holiday group: %v", err) } - defer db.DB.Exec(context.Background(), "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID) // Add closed hours for targetDate (closed all day) // DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert. dbWeekday := (int(targetDate.Weekday()) + 6) % 7 - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, $3, $4, $5) `, groupID, dbWeekday, "00:00:00", "23:59:59", false) @@ -1334,7 +1237,7 @@ func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) { daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) @@ -1352,7 +1255,7 @@ func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should be rejected (400 or 409 depending on implementation) if w.Code == http.StatusCreated { @@ -1367,34 +1270,31 @@ func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) { // TestAdminBookings_Search_CaseInsensitive verifies that the admin booking search is case-insensitive, // matching booking notes regardless of uppercase/lowercase differences. func TestAdminBookings_Search_CaseInsensitive(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Update notes with mixed case - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'TestBooking With MixedCase' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking notes: %v", err) @@ -1403,7 +1303,7 @@ func TestAdminBookings_Search_CaseInsensitive(t *testing.T) { handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) // Test 1: Uppercase search should find mixed case notes - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=TESTBOOKING", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=TESTBOOKING", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } @@ -1416,7 +1316,7 @@ func TestAdminBookings_Search_CaseInsensitive(t *testing.T) { } // Test 2: Lowercase search should also find - w = makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=testbooking", nil) + w = makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=testbooking", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } @@ -1431,36 +1331,33 @@ func TestAdminBookings_Search_CaseInsensitive(t *testing.T) { // TestAdminBookings_Search_NoResults verifies that searching with a query that matches no bookings // returns an empty list with total count of 0. func TestAdminBookings_Search_NoResults(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) // Search with non-matching query - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NONEXISTENT_QUERY_XYZ123", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NONEXISTENT_QUERY_XYZ123", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 for no results, got %d", w.Code) @@ -1483,47 +1380,43 @@ func TestAdminBookings_Search_NoResults(t *testing.T) { // TestAdminBookings_Search_MultipleResults verifies that search returns all bookings whose notes // contain the search query, with correct total count. func TestAdminBookings_Search_MultipleResults(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create multiple bookings with similar notes - bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID1, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 1: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID1) - bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID2, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 2: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID2) // Update both with searchable notes - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'Search Query Pattern' WHERE id = $1", bookingID1) if err != nil { t.Fatalf("failed to update booking 1: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'Another Search Query' WHERE id = $1", bookingID2) if err != nil { t.Fatalf("failed to update booking 2: %v", err) @@ -1532,7 +1425,7 @@ func TestAdminBookings_Search_MultipleResults(t *testing.T) { handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) // Search for "Query" - should match both - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Query", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Query", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -1559,41 +1452,38 @@ func TestAdminBookings_Search_MultipleResults(t *testing.T) { // TestAdminBookings_ListEditRequests verifies that an admin can list all pending edit requests // for a specific booking, and that the response includes the correct count and request details. func TestAdminBookings_ListEditRequests(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Update booking status to confirmed (required for edit requests) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } // Clean up ALL existing edit requests in DB to ensure clean state - _, err = db.DB.Exec(context.Background(), "DELETE FROM booking_edit_requests") + _, err = tx.Exec(ctx, "DELETE FROM booking_edit_requests") if err != nil { t.Fatalf("failed to clean up edit requests: %v", err) } @@ -1601,7 +1491,7 @@ func TestAdminBookings_ListEditRequests(t *testing.T) { // Create 3 edit requests via direct SQL insert var emptyServices []string for i := 1; i <= 3; i++ { - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides) VALUES ($1, $2, $3, $4, $5, $6)`, bookingID, userID, time.Now().Add(time.Duration(i)*24*time.Hour), @@ -1612,7 +1502,7 @@ func TestAdminBookings_ListEditRequests(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminListEditRequestsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID+"/edit-requests", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID+"/edit-requests", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1649,42 +1539,39 @@ func TestAdminBookings_ListEditRequests(t *testing.T) { // TestAdminBookings_DenyEditRequest verifies that denying an edit request deletes the request // while keeping the original booking time unchanged, and returns success. func TestAdminBookings_DenyEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create confirmed booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Get original start_time var originalStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime) if err != nil { t.Fatalf("failed to get original start_time: %v", err) @@ -1693,7 +1580,7 @@ func TestAdminBookings_DenyEditRequest(t *testing.T) { // Create edit request with new_start_time via direct SQL newStartTime := originalStartTime.Add(24 * time.Hour).Truncate(time.Minute) var editRequestID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes) VALUES ($1, $2, $3, 'Please change time') RETURNING id`, @@ -1711,10 +1598,10 @@ func TestAdminBookings_DenyEditRequest(t *testing.T) { rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -1725,7 +1612,7 @@ func TestAdminBookings_DenyEditRequest(t *testing.T) { // Verify edit request is deleted var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -1736,7 +1623,7 @@ func TestAdminBookings_DenyEditRequest(t *testing.T) { // Verify booking start_time unchanged var finalStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&finalStartTime) if err != nil { t.Fatalf("failed to get final start_time: %v", err) @@ -1749,44 +1636,41 @@ func TestAdminBookings_DenyEditRequest(t *testing.T) { // TestAdminBookings_ApproveEditRequest verifies that approving an edit request updates the booking's // start_time to the requested time, deletes the edit request, and acknowledges the admin notification. func TestAdminBookings_ApproveEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create admin user - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) // Create regular user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } // Create service - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create confirmed booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) @@ -1794,7 +1678,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { // Get original booking start_time var originalStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime) if err != nil { t.Fatalf("failed to get original start_time: %v", err) @@ -1804,7 +1688,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Minute) var editRequestID string var emptyServices []string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes) VALUES ($1, $2, $3, $4, 'Please change time') RETURNING id`, @@ -1814,7 +1698,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { } // Create admin notification - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) @@ -1826,18 +1710,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { handler := http.HandlerFunc(bookings.AdminApproveEditRequestHandler) path := fmt.Sprintf("/api/admin/bookings/%s/edit-requests/%s/approve", bookingID, editRequestID) - req := httptest.NewRequest("POST", path, nil) - - rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", bookingID) - rctx.URLParams.Add("request_id", editRequestID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - req = req.WithContext(ctx) - - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) + w := makeAdminRequest(handler, "POST", path, nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -1845,7 +1718,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { // Verify edit request was deleted (approved) var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -1856,7 +1729,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { // Verify booking start_time was updated to new_start_time var updatedStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -1867,7 +1740,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { // Verify admin notification was acknowledged var ackTime *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) @@ -1887,30 +1760,28 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { // deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline) // for bookings that have deposit_required=true. func TestAdminBookings_Get_DepositFields(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create booking via SQL with deposit_required=true (simulating user-created booking) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id @@ -1918,10 +1789,9 @@ func TestAdminBookings_Get_DepositFields(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Link service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -1930,7 +1800,7 @@ func TestAdminBookings_Get_DepositFields(t *testing.T) { } // GET single booking via admin endpoint - w := makeAdminRequest(http.HandlerFunc(bookings.GetAdminBookingHandler), "GET", "/api/admin/bookings/"+bookingID, nil) + w := makeAdminRequest(http.HandlerFunc(bookings.GetAdminBookingHandler), "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1958,30 +1828,28 @@ func TestAdminBookings_Get_DepositFields(t *testing.T) { // TestAdminBookings_List_DepositFields verifies that admin booking list returns // deposit-related fields for each booking. func TestAdminBookings_List_DepositFields(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create booking via SQL with deposit_required=true futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'pending', true) RETURNING id @@ -1989,10 +1857,9 @@ func TestAdminBookings_List_DepositFields(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Link service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -2001,7 +1868,7 @@ func TestAdminBookings_List_DepositFields(t *testing.T) { } // GET all bookings via admin endpoint - w := makeAdminRequest(http.HandlerFunc(bookings.GetAllAdminBookingsHandler), "GET", "/api/admin/bookings", nil) + w := makeAdminRequest(http.HandlerFunc(bookings.GetAllAdminBookingsHandler), "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2035,30 +1902,28 @@ func TestAdminBookings_List_DepositFields(t *testing.T) { // create bookings that overlap with time blockers, but receive a warning. // The booking is still created (201 Created), unlike regular users who get 409. func TestAdminBookings_Create_OverlappingBlocker_WithWarning(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create a time blocker for a specific time ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', $2) `, blockerTime, adminID) @@ -2074,7 +1939,7 @@ func TestAdminBookings_Create_OverlappingBlocker_WithWarning(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Admin should get 201 Created (not 409 Conflict) if w.Code != http.StatusCreated { @@ -2115,37 +1980,34 @@ func TestAdminBookings_Create_OverlappingBlocker_WithWarning(t *testing.T) { // edit bookings to overlap with time blockers, but receive a warning. // The booking is still updated (200 OK with warnings), unlike regular users who get 409. func TestAdminBookings_Edit_OverlappingBlocker_WithWarning(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create a booking first - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Create a time blocker for a specific time ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2099, 12, 31, 14, 0, 0, 0, ukLocation) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, blockerTime) @@ -2168,9 +2030,9 @@ func TestAdminBookings_Edit_OverlappingBlocker_WithWarning(t *testing.T) { reqHTTP.Header.Set("Content-Type", "application/json") // Add admin context - ctx := context.WithValue(reqHTTP.Context(), mw.UserIDKey, adminID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - reqHTTP = reqHTTP.WithContext(ctx) + reqCtx := context.WithValue(ctx, mw.UserIDKey, adminID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + reqHTTP = reqHTTP.WithContext(reqCtx) w := httptest.NewRecorder() r.ServeHTTP(w, reqHTTP) @@ -2204,28 +2066,26 @@ func TestAdminBookings_Edit_OverlappingBlocker_WithWarning(t *testing.T) { // TestAdminBookings_Create_EnforceDeposits_Bypass tests that admin can create bookings // for users with outstanding deposits by setting enforce_deposits=false. func TestAdminBookings_Create_EnforceDeposits_Bypass(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Set user to have outstanding deposits (deposits_required = 3) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -2240,7 +2100,7 @@ func TestAdminBookings_Create_EnforceDeposits_Bypass(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed (not 409 Conflict) because deposits check was bypassed if w.Code != http.StatusCreated { @@ -2251,28 +2111,26 @@ func TestAdminBookings_Create_EnforceDeposits_Bypass(t *testing.T) { // TestAdminBookings_Create_EnforceDeposits_Enforced tests that by default (or when enforce_deposits=true), // admin bookings respect the deposit requirement rules. func TestAdminBookings_Create_EnforceDeposits_Enforced(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Set user to have outstanding deposits - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -2287,7 +2145,7 @@ func TestAdminBookings_Create_EnforceDeposits_Enforced(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String()) @@ -2302,7 +2160,7 @@ func TestAdminBookings_Create_EnforceDeposits_Enforced(t *testing.T) { EnforceDeposits: nil, // Default: enforce (deposits_required > 0 still active) } - w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq) + w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq, ctx) // Should get 409 Conflict because user has active booking and deposits outstanding if w.Code != http.StatusConflict { @@ -2313,28 +2171,26 @@ func TestAdminBookings_Create_EnforceDeposits_Enforced(t *testing.T) { // TestAdminBookings_Create_WalkIn tests that admins can create walk-in bookings // (no advance time requirement), including immediate/past times if needed. func TestAdminBookings_Create_WalkIn(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Seed working hours so we have a valid booking window - seedDefaultWorkingHours(t) + // Try to create booking with walk-in time (30 minutes from now - less than 1h requirement) // Regular users would be rejected, but admin should succeed @@ -2348,7 +2204,7 @@ func TestAdminBookings_Create_WalkIn(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed - admins can bypass the 1-hour minimum requirement if w.Code != http.StatusCreated { @@ -2359,31 +2215,29 @@ func TestAdminBookings_Create_WalkIn(t *testing.T) { // TestAdminBookings_Create_WalkInWithDeposits tests that admins can create walk-ins // even when user has outstanding deposits and enforce_deposits=false. func TestAdminBookings_Create_WalkInWithDeposits(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Seed working hours - seedDefaultWorkingHours(t) + // Set user to have outstanding deposits - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits: %v", err) } @@ -2401,7 +2255,7 @@ func TestAdminBookings_Create_WalkInWithDeposits(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed - admin walk-in with deposit bypass if w.Code != http.StatusCreated { @@ -2417,30 +2271,27 @@ func TestAdminBookings_Create_WalkInWithDeposits(t *testing.T) { // confirmed booking is progressed to completed with at least one payment, the user's // deposits_required is reduced by 1. func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() // Set user to have deposits_required = 2 - _, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -2448,7 +2299,7 @@ func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T // Create confirmed booking futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id @@ -2456,10 +2307,9 @@ func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Link service to booking - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -2468,7 +2318,7 @@ func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T } // Add a payment for the booking - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, amount, payment_type, payment_method, status) VALUES ($1, 50.00, 'deposit', 'online_square', 'completed') `, bookingID) @@ -2482,7 +2332,7 @@ func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T } handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2490,7 +2340,7 @@ func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T // Verify deposits_required was reduced from 2 to 1 var depositsRequired int - err = db.DB.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) if err != nil { t.Fatalf("failed to query deposits_required: %v", err) } @@ -2502,30 +2352,27 @@ func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T // TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction verifies that when a // booking is completed without any payments, the user's deposits_required is NOT reduced. func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() // Set user to have deposits_required = 2 - _, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -2533,7 +2380,7 @@ func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) // Create confirmed booking futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id @@ -2541,10 +2388,9 @@ func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Link service to booking - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -2560,7 +2406,7 @@ func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) } handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2568,7 +2414,7 @@ func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) // Verify deposits_required is still 2 (no reduction because no payment) var depositsRequired int - err = db.DB.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) if err != nil { t.Fatalf("failed to query deposits_required: %v", err) } @@ -2581,30 +2427,27 @@ func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) // enforce_deposits is set to false, the admin can create a second booking for a user // who already has an active booking, bypassing the one-active-booking limit. func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() // Set user to have deposits_required = 3 - _, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -2618,7 +2461,7 @@ func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String()) @@ -2634,7 +2477,7 @@ func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(t *testing.T) { EnforceDeposits: &falseVal, // Bypass deposit checks } - w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq) + w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq, ctx) // Should succeed (201 Created) because enforce_deposits=false bypasses the limit if w.Code != http.StatusCreated { @@ -2646,33 +2489,30 @@ func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(t *testing.T) { // enforce_deposits is set to false, the admin can create a booking within 24 hours // for a user with outstanding deposits. func TestAdminBookings_Create_EnforceDepositsFalse_Within24h(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - ctx := context.Background() // Seed working hours - seedDefaultWorkingHours(t) + // Set user to have deposits_required = 3 - _, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -2692,7 +2532,7 @@ func TestAdminBookings_Create_EnforceDepositsFalse_Within24h(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed (201 Created) because enforce_deposits=false bypasses the 24h check if w.Code != http.StatusCreated { @@ -2703,28 +2543,26 @@ func TestAdminBookings_Create_EnforceDepositsFalse_Within24h(t *testing.T) { // TestAdminBookings_Create_WalkInGuestUser verifies that an admin can create a booking // for a guest user (created via fixtures.CreateTestGuestUser). func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - guestID, err := fixtures.CreateTestGuestUser(db.DB) + guestID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } - defer fixtures.DeleteUser(db.DB, guestID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Seed working hours - seedDefaultWorkingHours(t) + // Create booking for tomorrow tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second) @@ -2737,16 +2575,15 @@ func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking exists with correct user_id - ctx := context.Background() var foundUserID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` SELECT user_id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1 `, guestID).Scan(&foundUserID) if err != nil { @@ -2765,34 +2602,31 @@ func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) { // update a booking's time to fall within a closed exceptional hours period, receiving // a warning but proceeding with the update. func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) originalTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET start_time = $1 WHERE id = $2", originalTime, bookingID) if err != nil { t.Fatalf("failed to update booking time: %v", err) @@ -2801,7 +2635,7 @@ func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) { targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour) var groupID int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id @@ -2812,7 +2646,7 @@ func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) { // DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert. dbWeekday := (int(targetDate.Weekday()) + 6) % 7 - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, $3, $4, $5) `, groupID, dbWeekday, "00:00:00", "23:59:59", false) @@ -2825,7 +2659,7 @@ func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) { daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) @@ -2839,7 +2673,7 @@ func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminEditBookingHandler) - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, req) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, req, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2863,7 +2697,7 @@ func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) { } var updatedStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -2877,30 +2711,28 @@ func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) { // cannot create a booking for a user during hours marked as closed in the exceptional // working hours (holiday) system. func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour) var groupID int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id @@ -2911,7 +2743,7 @@ func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) // DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert. dbWeekday := (int(targetDate.Weekday()) + 6) % 7 - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, $3, $4, $5) `, groupID, dbWeekday, "00:00:00", "23:59:59", false) @@ -2924,7 +2756,7 @@ func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) @@ -2940,7 +2772,7 @@ func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) @@ -2957,52 +2789,48 @@ func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) // TestGetBookingsByCreatedRange verifies that the endpoint returns bookings // created within the specified created_at range. func TestGetBookingsByCreatedRange(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create bookings with specific created_at timestamps - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000001', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-01-15 09:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 1: %v", err) } - defer fixtures.DeleteBooking(db.DB, "book00000001") - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000001', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 1: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000002', $1, '2099-12-31 11:00:00+00', 'pending', '2025-01-15 14:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 2: %v", err) } - defer fixtures.DeleteBooking(db.DB, "book00000002") - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000002', $1) `, serviceID) if err != nil { @@ -3010,15 +2838,14 @@ func TestGetBookingsByCreatedRange(t *testing.T) { } // Booking outside the range (created before) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000003', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-01-10 09:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 3: %v", err) } - defer fixtures.DeleteBooking(db.DB, "book00000003") - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000003', $1) `, serviceID) if err != nil { @@ -3026,7 +2853,7 @@ func TestGetBookingsByCreatedRange(t *testing.T) { } handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-15T00:00:00Z&end=2025-01-16T00:00:00Z", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-15T00:00:00Z&end=2025-01-16T00:00:00Z", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -3045,17 +2872,17 @@ func TestGetBookingsByCreatedRange(t *testing.T) { // TestGetBookingsByCreatedRange_Empty verifies that the endpoint returns an // empty array when no bookings fall within the created_at range. func TestGetBookingsByCreatedRange_Empty(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -3074,30 +2901,30 @@ func TestGetBookingsByCreatedRange_Empty(t *testing.T) { // TestGetBookingsByCreatedRange_MissingParams verifies that the endpoint // returns 400 when start or end query parameters are missing. func TestGetBookingsByCreatedRange_MissingParams(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) // Missing both params - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 (missing both), got %d", w.Code) } // Missing end param - w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z", nil) + w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 (missing end), got %d", w.Code) } // Missing start param - w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?end=2025-01-02T00:00:00Z", nil) + w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?end=2025-01-02T00:00:00Z", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 (missing start), got %d", w.Code) } @@ -3106,16 +2933,16 @@ func TestGetBookingsByCreatedRange_MissingParams(t *testing.T) { // TestGetBookingsByCreatedRange_InvalidFormat verifies that the endpoint // returns 400 when the date format is invalid. func TestGetBookingsByCreatedRange_InvalidFormat(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=not-a-date&end=2025-01-02T00:00:00Z", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=not-a-date&end=2025-01-02T00:00:00Z", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -3125,67 +2952,62 @@ func TestGetBookingsByCreatedRange_InvalidFormat(t *testing.T) { // TestGetBookingsByCreatedRange_OrderedByCreatedAt verifies that results // are returned in ascending order by created_at. func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create bookings with created_at in reverse order - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000010', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-03-01 15:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 10: %v", err) } - defer fixtures.DeleteBooking(db.DB, "book00000010") - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000010', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 10: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000011', $1, '2099-12-31 11:00:00+00', 'pending', '2025-03-01 10:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 11: %v", err) } - defer fixtures.DeleteBooking(db.DB, "book00000011") - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000011', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 11: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000012', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-03-01 12:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 12: %v", err) } - defer fixtures.DeleteBooking(db.DB, "book00000012") - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000012', $1) `, serviceID) if err != nil { @@ -3193,7 +3015,7 @@ func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) { } handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-03-01T00:00:00Z&end=2025-03-02T00:00:00Z", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-03-01T00:00:00Z&end=2025-03-02T00:00:00Z", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -3218,26 +3040,27 @@ func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) { } func TestGetAdminBooking_WithDiscounts(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create completed booking bookingTime := time.Now().Add(-24 * time.Hour) - bookingID := createCompletedBookingWithTimeForAdmin(t, userID, serviceID, bookingTime, 50.00) + bookingID := createCompletedBookingWithTimeForAdmin(t, ctx, tx, userID, serviceID, bookingTime, 50.00) // Create a discount campaign and apply it var campaignID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() + INTERVAL '2 days') RETURNING id @@ -3246,7 +3069,7 @@ func TestGetAdminBooking_WithDiscounts(t *testing.T) { t.Fatalf("failed to create campaign: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00) `, bookingID, userID, campaignID) @@ -3256,7 +3079,7 @@ func TestGetAdminBooking_WithDiscounts(t *testing.T) { // Call GetAdminBookingHandler handler := http.HandlerFunc(bookings.GetAdminBookingHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -3280,10 +3103,10 @@ func TestGetAdminBooking_WithDiscounts(t *testing.T) { } } -func createCompletedBookingWithTimeForAdmin(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { +func createCompletedBookingWithTimeForAdmin(t *testing.T, ctx context.Context, tx db.Querier, userID, serviceID string, startTime time.Time, price float64) string { t.Helper() var bookingID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id @@ -3292,7 +3115,7 @@ func createCompletedBookingWithTimeForAdmin(t *testing.T, userID, serviceID stri t.Fatalf("failed to create completed booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, price) @@ -3312,26 +3135,24 @@ func createCompletedBookingWithTimeForAdmin(t *testing.T, userID, serviceID stri // It checks that the booking_custom_services entry is created and the custom // service usage_count is incremented. func TestAdminBookings_CreateWithCustomServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - customServiceID, err := fixtures.CreateTestCustomService(db.DB) + customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, customServiceID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) req := bookings.AdminCreateBookingForUserRequest{ @@ -3341,7 +3162,7 @@ func TestAdminBookings_CreateWithCustomServices(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -3361,11 +3182,10 @@ func TestAdminBookings_CreateWithCustomServices(t *testing.T) { if !ok || bookingID == "" { t.Fatal("expected booking id in response") } - defer fixtures.DeleteBooking(db.DB, bookingID) // Verify booking_custom_services has the custom service linked var bcsCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", bookingID, customServiceID).Scan(&bcsCount) if err != nil { @@ -3377,7 +3197,7 @@ func TestAdminBookings_CreateWithCustomServices(t *testing.T) { // Verify custom_services usage_count was incremented var usageCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT usage_count FROM custom_services WHERE id = $1", customServiceID).Scan(&usageCount) if err != nil { t.Fatalf("failed to query custom_service usage_count: %v", err) @@ -3392,32 +3212,29 @@ func TestAdminBookings_CreateWithCustomServices(t *testing.T) { // It checks that entries are created in both booking_services and // booking_custom_services. func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - customServiceID, err := fixtures.CreateTestCustomService(db.DB) + customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, customServiceID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) req := bookings.AdminCreateBookingForUserRequest{ @@ -3428,7 +3245,7 @@ func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -3448,11 +3265,10 @@ func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) { if !ok || bookingID == "" { t.Fatal("expected booking id in response") } - defer fixtures.DeleteBooking(db.DB, bookingID) // Verify booking_services has the regular service var svcCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = $2", bookingID, serviceID).Scan(&svcCount) if err != nil { @@ -3464,7 +3280,7 @@ func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) { // Verify booking_custom_services has the custom service var csCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", bookingID, customServiceID).Scan(&csCount) if err != nil { @@ -3479,19 +3295,18 @@ func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) { // rejects requests with neither service_ids nor custom_service_ids, testing // both nil and empty arrays. func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) @@ -3501,7 +3316,7 @@ func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) { UserID: userID, StartTime: time.Now().Add(72 * time.Hour), } - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } @@ -3515,7 +3330,7 @@ func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) { ServiceIDs: []string{}, CustomServiceIDs: []string{}, } - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } @@ -3523,13 +3338,12 @@ func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) { // Test 3: custom_service_ids is provided (should succeed — need working hours) t.Run("provides custom_service_ids only", func(t *testing.T) { - seedDefaultWorkingHours(t) + - customServiceID, err := fixtures.CreateTestCustomService(db.DB) + customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, customServiceID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) req := bookings.AdminCreateBookingForUserRequest{ @@ -3537,7 +3351,7 @@ func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) { StartTime: futureTime, CustomServiceIDs: []string{customServiceID}, } - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } @@ -3549,41 +3363,37 @@ func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) { // It checks that the override_price and override_duration_minutes are stored // in booking_custom_services. func TestAdminBookings_Confirm_WithCustomOverrides(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) - customServiceID, err := fixtures.CreateTestCustomService(db.DB) + customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, customServiceID) // Create a pending booking with a regular service - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) // Link a custom service to the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2)", bookingID, customServiceID) if err != nil { @@ -3604,7 +3414,7 @@ func TestAdminBookings_Confirm_WithCustomOverrides(t *testing.T) { } handler := http.HandlerFunc(bookings.ConfirmBookingHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -3622,7 +3432,7 @@ func TestAdminBookings_Confirm_WithCustomOverrides(t *testing.T) { // Verify override was applied to booking_custom_services var actualPrice *float64 var actualDuration *int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", bookingID, customServiceID).Scan(&actualPrice, &actualDuration) if err != nil { @@ -3640,26 +3450,24 @@ func TestAdminBookings_Confirm_WithCustomOverrides(t *testing.T) { // can create a booking with custom services and apply price/duration overrides // at creation time via AdminCreateBookingForUserHandler. func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - customServiceID, err := fixtures.CreateTestCustomService(db.DB) + customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, customServiceID) overridePrice := 60.00 overrideDuration := 30 @@ -3678,7 +3486,7 @@ func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) - w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -3698,12 +3506,11 @@ func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) { if !ok || bookingID == "" { t.Fatal("expected booking id in response") } - defer fixtures.DeleteBooking(db.DB, bookingID) // Verify override was applied to booking_custom_services var actualPrice *float64 var actualDuration *int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", bookingID, customServiceID).Scan(&actualPrice, &actualDuration) if err != nil { @@ -3722,29 +3529,27 @@ func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) { // It verifies the response duration matches the custom service duration and // that the time_blocker is created reflecting the custom service duration. func TestAdminBookings_AdminReserve_WithCustomServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - customServiceID, err := fixtures.CreateTestCustomService(db.DB) + customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, customServiceID) // Set custom service duration to a known value for assertion - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE custom_services SET duration_minutes = 45 WHERE id = $1", customServiceID) if err != nil { t.Fatalf("failed to update custom service duration: %v", err) @@ -3762,7 +3567,7 @@ func TestAdminBookings_AdminReserve_WithCustomServices(t *testing.T) { } handler := http.HandlerFunc(bookings.AdminReserveSlotHandler) - w := makeRequestWithContext(handler, "POST", "/api/admin/bookings/reserve", req, adminID, "admin") + w := makeRequestWithContext(handler, "POST", "/api/admin/bookings/reserve", req, adminID, "admin", ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -3780,7 +3585,7 @@ func TestAdminBookings_AdminReserve_WithCustomServices(t *testing.T) { // Verify time_blocker was created with correct description pattern var desc string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'", ).Scan(&desc) if err != nil { diff --git a/backend/handlers/admin/custom_services_test.go b/backend/handlers/admin/custom_services_test.go index 0c5823a..a3fef48 100644 --- a/backend/handlers/admin/custom_services_test.go +++ b/backend/handlers/admin/custom_services_test.go @@ -24,7 +24,6 @@ import ( "strings" "testing" - "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" @@ -34,11 +33,10 @@ import ( // testAdminID is set by each test after creating an admin user via fixtures, // so that handlers referencing created_by (which has a FK to users) work correctly. -var testAdminID string // makeCustomServiceRequest creates an admin request with chi URL params for custom-services paths. // Uses testAdminID (must be set by the calling test). -func makeCustomServiceRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { +func makeCustomServiceRequest(handler http.Handler, method, path string, body interface{}, adminID string, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -58,8 +56,8 @@ func makeCustomServiceRequest(handler http.Handler, method, path string, body in } } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, testAdminID) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) @@ -75,28 +73,28 @@ func makeCustomServiceRequest(handler http.Handler, method, path string, body in // TestCustomServices_List verifies that an admin can list all custom services // with pagination metadata. func TestCustomServices_List(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) - csID1, err := fixtures.CreateTestCustomService(db.DB) + csID1, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service 1: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID1) + defer fixtures.DeleteCustomService(tx, csID1) - csID2, err := fixtures.CreateTestCustomService(db.DB) + csID2, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service 2: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID2) + defer fixtures.DeleteCustomService(tx, csID2) handler := http.HandlerFunc(GetCustomServices) - w := makeAdminRequest(handler, "GET", "/api/admin/custom-services", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -119,22 +117,22 @@ func TestCustomServices_List(t *testing.T) { // TestCustomServices_List_Search verifies search filtering via the q parameter, // including case-insensitive matching and no-results scenarios. func TestCustomServices_List_Search(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) // Set a unique name for search testing - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(context.Background(), "UPDATE custom_services SET name = 'SearchableServiceName' WHERE id = $1", csID) if err != nil { t.Fatalf("failed to update custom service name: %v", err) @@ -143,7 +141,7 @@ func TestCustomServices_List_Search(t *testing.T) { handler := http.HandlerFunc(GetCustomServices) // Matching search (case-insensitive) - w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?q=searchableservicename", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?q=searchableservicename", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -162,7 +160,7 @@ func TestCustomServices_List_Search(t *testing.T) { } // Non-matching search - w = makeAdminRequest(handler, "GET", "/api/admin/custom-services?q=NONEXISTENT_QUERY_XYZ", nil) + w = makeAdminRequest(handler, "GET", "/api/admin/custom-services?q=NONEXISTENT_QUERY_XYZ", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } @@ -183,35 +181,35 @@ func TestCustomServices_List_Search(t *testing.T) { // TestCustomServices_List_Popular verifies the popular flag returns custom services // ordered by usage_count, limited to the specified number. func TestCustomServices_List_Popular(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) - csID1, err := fixtures.CreateTestCustomService(db.DB) + csID1, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service 1: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID1) + defer fixtures.DeleteCustomService(tx, csID1) - csID2, err := fixtures.CreateTestCustomService(db.DB) + csID2, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service 2: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID2) + defer fixtures.DeleteCustomService(tx, csID2) // Set usage counts via direct DB to have services with usage_count > 0 - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(context.Background(), "UPDATE custom_services SET usage_count = 5, last_used_at = NOW() WHERE id = $1", csID1) if err != nil { t.Fatalf("failed to set usage count: %v", err) } handler := http.HandlerFunc(GetCustomServices) - w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?popular=3", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?popular=3", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -237,18 +235,18 @@ func TestCustomServices_List_Popular(t *testing.T) { // TestCustomServices_List_Pagination verifies page and per_page query parameters. func TestCustomServices_List_Pagination(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) // Create 3 custom services csIDs := make([]string, 3) for i := 0; i < 3; i++ { - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service %d: %v", i+1, err) } @@ -256,13 +254,13 @@ func TestCustomServices_List_Pagination(t *testing.T) { } defer func() { for _, id := range csIDs { - fixtures.DeleteCustomService(db.DB, id) + fixtures.DeleteCustomService(tx, id) } }() handler := http.HandlerFunc(GetCustomServices) - w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?per_page=2", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?per_page=2", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -296,13 +294,13 @@ func TestCustomServices_List_Pagination(t *testing.T) { // TestCustomServices_Create verifies that an admin can create a new custom service // with name, description, price, duration, minimum age, and notes. func TestCustomServices_Create(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) handler := http.HandlerFunc(CreateCustomService) @@ -315,7 +313,7 @@ func TestCustomServices_Create(t *testing.T) { Notes: stringPtr("Custom service notes"), } - w := makeRequestWithContext(handler, "POST", "/api/admin/custom-services", createReq, adminID, "admin") + w := makeRequestWithContext(handler, "POST", "/api/admin/custom-services", createReq, adminID, "admin", ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -354,13 +352,13 @@ func TestCustomServices_Create(t *testing.T) { // TestCustomServices_Create_Validation verifies that validation errors return // HTTP 400 for various invalid inputs. func TestCustomServices_Create_Validation(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) tests := []struct { name string @@ -420,7 +418,7 @@ func TestCustomServices_Create_Validation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(CreateCustomService) - w := makeRequestWithContext(handler, "POST", "/api/admin/custom-services", tt.req, adminID, "admin") + w := makeRequestWithContext(handler, "POST", "/api/admin/custom-services", tt.req, adminID, "admin", ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -435,23 +433,22 @@ func TestCustomServices_Create_Validation(t *testing.T) { // TestCustomServices_Get verifies that an admin can retrieve a single custom service by ID. func TestCustomServices_Get(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) handler := http.HandlerFunc(GetCustomService) - w := makeCustomServiceRequest(handler, "GET", "/api/admin/custom-services/"+csID, nil) + w := makeCustomServiceRequest(handler, "GET", "/api/admin/custom-services/"+csID, nil, adminID, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -477,17 +474,16 @@ func TestCustomServices_Get(t *testing.T) { // TestCustomServices_Get_NotFound verifies that requesting a non-existent custom service returns 404. func TestCustomServices_Get_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) handler := http.HandlerFunc(GetCustomService) - w := makeCustomServiceRequest(handler, "GET", "/api/admin/custom-services/nonexistent-id", nil) + w := makeCustomServiceRequest(handler, "GET", "/api/admin/custom-services/nonexistent-id", nil, adminID, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -500,20 +496,19 @@ func TestCustomServices_Get_NotFound(t *testing.T) { // TestCustomServices_Update verifies that an admin can update a custom service's fields. func TestCustomServices_Update(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) newName := "Updated Custom Name" updateReq := UpdateCustomServiceRequest{ @@ -521,7 +516,7 @@ func TestCustomServices_Update(t *testing.T) { } handler := http.HandlerFunc(UpdateCustomService) - w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/"+csID, updateReq) + w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/"+csID, updateReq, adminID, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -538,7 +533,7 @@ func TestCustomServices_Update(t *testing.T) { // Verify the update persisted var dbName string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(context.Background(), "SELECT name FROM custom_services WHERE id = $1", csID).Scan(&dbName) if err != nil { t.Fatalf("failed to query custom service: %v", err) @@ -551,14 +546,13 @@ func TestCustomServices_Update(t *testing.T) { // TestCustomServices_Update_NotFound verifies that updating a non-existent custom service returns 404. func TestCustomServices_Update_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) newName := "Updated Name" updateReq := UpdateCustomServiceRequest{ @@ -566,7 +560,7 @@ func TestCustomServices_Update_NotFound(t *testing.T) { } handler := http.HandlerFunc(UpdateCustomService) - w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/nonexistent-id", updateReq) + w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/nonexistent-id", updateReq, adminID, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -576,25 +570,24 @@ func TestCustomServices_Update_NotFound(t *testing.T) { // TestCustomServices_Update_NoFields verifies that sending an update with no fields // returns 400 Bad Request. func TestCustomServices_Update_NoFields(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) updateReq := UpdateCustomServiceRequest{} handler := http.HandlerFunc(UpdateCustomService) - w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/"+csID, updateReq) + w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/"+csID, updateReq, adminID, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -608,22 +601,21 @@ func TestCustomServices_Update_NoFields(t *testing.T) { // TestCustomServices_Promote verifies that promoting a custom service creates a // regular service, migrates data, and deletes the original custom service. func TestCustomServices_Promote(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } handler := http.HandlerFunc(PromoteCustomService) - w := makeCustomServiceRequest(handler, "POST", "/api/admin/custom-services/"+csID+"/promote", nil) + w := makeCustomServiceRequest(handler, "POST", "/api/admin/custom-services/"+csID+"/promote", nil, adminID, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -645,7 +637,7 @@ func TestCustomServices_Promote(t *testing.T) { // Verify the custom service was deleted var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(context.Background(), "SELECT COUNT(*) FROM custom_services WHERE id = $1", csID).Scan(&count) if err != nil { t.Fatalf("failed to query custom service: %v", err) @@ -656,7 +648,7 @@ func TestCustomServices_Promote(t *testing.T) { // Verify the new regular service was created var serviceName string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(context.Background(), "SELECT name FROM services WHERE id = $1", newServiceID).Scan(&serviceName) if err != nil { t.Fatalf("failed to query promoted service: %v", err) @@ -666,22 +658,21 @@ func TestCustomServices_Promote(t *testing.T) { } // Clean up: delete the promoted service - defer fixtures.DeleteService(db.DB, newServiceID) + defer fixtures.DeleteService(tx, newServiceID) } // TestCustomServices_Promote_NotFound verifies that promoting a non-existent custom service returns 404. func TestCustomServices_Promote_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) handler := http.HandlerFunc(PromoteCustomService) - w := makeCustomServiceRequest(handler, "POST", "/api/admin/custom-services/nonexistent-id/promote", nil) + w := makeCustomServiceRequest(handler, "POST", "/api/admin/custom-services/nonexistent-id/promote", nil, adminID, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -694,22 +685,21 @@ func TestCustomServices_Promote_NotFound(t *testing.T) { // TestCustomServices_Delete verifies that an admin can delete an unused custom service. func TestCustomServices_Delete(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } handler := http.HandlerFunc(DeleteCustomService) - w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/"+csID, nil) + w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/"+csID, nil, adminID, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -726,7 +716,7 @@ func TestCustomServices_Delete(t *testing.T) { // Verify it's gone from the DB var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(context.Background(), "SELECT COUNT(*) FROM custom_services WHERE id = $1", csID).Scan(&count) if err != nil { t.Fatalf("failed to query custom service: %v", err) @@ -738,17 +728,16 @@ func TestCustomServices_Delete(t *testing.T) { // TestCustomServices_Delete_NotFound verifies that deleting a non-existent custom service returns 404. func TestCustomServices_Delete_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) handler := http.HandlerFunc(DeleteCustomService) - w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/nonexistent-id", nil) + w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/nonexistent-id", nil, adminID, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -758,30 +747,29 @@ func TestCustomServices_Delete_NotFound(t *testing.T) { // TestCustomServices_Delete_Conflict verifies that deleting a custom service with // usage_count > 0 returns 409 Conflict. func TestCustomServices_Delete_Conflict(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - testAdminID = adminID + defer fixtures.DeleteUser(tx, adminID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) // Simulate usage to trigger conflict - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(context.Background(), "UPDATE custom_services SET usage_count = 3 WHERE id = $1", csID) if err != nil { t.Fatalf("failed to set usage count: %v", err) } handler := http.HandlerFunc(DeleteCustomService) - w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/"+csID, nil) + w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/"+csID, nil, adminID, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) @@ -795,27 +783,27 @@ func TestCustomServices_Delete_Conflict(t *testing.T) { // TestCustomServices_NonAdmin verifies that non-admin users receive HTTP 403 // Forbidden when attempting to access any admin custom services endpoint. func TestCustomServices_NonAdmin(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, err := fixtures.CreateTestUser(db.DB) + _, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) // Test LIST - w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(GetCustomServices)), "GET", "/api/admin/custom-services", nil) + w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(GetCustomServices)), "GET", "/api/admin/custom-services", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("LIST: expected status 403, got %d", w.Code) } @@ -826,32 +814,32 @@ func TestCustomServices_NonAdmin(t *testing.T) { Price: 50.00, DurationMinutes: 60, } - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(CreateCustomService)), "POST", "/api/admin/custom-services", createReq) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(CreateCustomService)), "POST", "/api/admin/custom-services", createReq, ctx) if w.Code != http.StatusForbidden { t.Errorf("CREATE: expected status 403, got %d", w.Code) } // Test GET - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(GetCustomService)), "GET", "/api/admin/custom-services/"+csID, nil) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(GetCustomService)), "GET", "/api/admin/custom-services/"+csID, nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("GET: expected status 403, got %d", w.Code) } // Test UPDATE updateReq := UpdateCustomServiceRequest{} - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(UpdateCustomService)), "PUT", "/api/admin/custom-services/"+csID, updateReq) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(UpdateCustomService)), "PUT", "/api/admin/custom-services/"+csID, updateReq, ctx) if w.Code != http.StatusForbidden { t.Errorf("UPDATE: expected status 403, got %d", w.Code) } // Test PROMOTE - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(PromoteCustomService)), "POST", "/api/admin/custom-services/"+csID+"/promote", nil) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(PromoteCustomService)), "POST", "/api/admin/custom-services/"+csID+"/promote", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("PROMOTE: expected status 403, got %d", w.Code) } // Test DELETE - w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(DeleteCustomService)), "DELETE", "/api/admin/custom-services/"+csID, nil) + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(DeleteCustomService)), "DELETE", "/api/admin/custom-services/"+csID, nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("DELETE: expected status 403, got %d", w.Code) } diff --git a/backend/handlers/admin/discount_campaigns_test.go b/backend/handlers/admin/discount_campaigns_test.go index c0fc4ce..91ee26a 100644 --- a/backend/handlers/admin/discount_campaigns_test.go +++ b/backend/handlers/admin/discount_campaigns_test.go @@ -21,7 +21,10 @@ import ( "github.com/go-chi/chi/v5" ) -func makeCampaignRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { +var testAdminID string + + +func makeCampaignRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context, adminID string) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -42,8 +45,8 @@ func makeCampaignRequest(handler http.Handler, method, path string, body interfa } } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, testAdminID) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) @@ -52,34 +55,34 @@ func makeCampaignRequest(handler http.Handler, method, path string, body interfa return w } -func insertTimeBasedCampaign(t *testing.T, name string, discount float64, status string) string { +func insertTimeBasedCampaign(t *testing.T, ctx context.Context, tx db.Querier, adminID, name string, discount float64, status string) string { t.Helper() startDate := fmt.Sprintf("%sZ", time.Now().Add(-1*time.Hour).Format("2006-01-02T15:04:05")) endDate := fmt.Sprintf("%sZ", time.Now().Add(7*24*time.Hour).Format("2006-01-02T15:04:05")) var id string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, created_by) VALUES ($1, 'time_based', $2, $3, $4::timestamptz, $5::timestamptz, $6) RETURNING id - `, name, discount, status, startDate, endDate, testAdminID).Scan(&id) + `, name, discount, status, startDate, endDate, adminID).Scan(&id) if err != nil { t.Fatalf("failed to insert time_based campaign: %v", err) } return id } -func insertMilestoneCampaign(t *testing.T, name string, discount float64, status string) string { +func insertMilestoneCampaign(t *testing.T, ctx context.Context, tx db.Querier, adminID, name string, discount float64, status string) string { t.Helper() mt := "per_user_booking_count" mv := 5 mu := "bookings" var id string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, milestone_type, milestone_value, milestone_unit, created_by) VALUES ($1, 'milestone', $2, $3, $4, $5, $6, $7) RETURNING id - `, name, discount, status, mt, mv, mu, testAdminID).Scan(&id) + `, name, discount, status, mt, mv, mu, adminID).Scan(&id) if err != nil { t.Fatalf("failed to insert milestone campaign: %v", err) } @@ -91,16 +94,15 @@ func insertMilestoneCampaign(t *testing.T, name string, discount float64, status // ============================================================================= func TestGetDiscountCampaigns_Empty(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() handler := http.HandlerFunc(GetDiscountCampaigns) - w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil, ctx, adminID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -115,18 +117,17 @@ func TestGetDiscountCampaigns_Empty(t *testing.T) { } func TestGetDiscountCampaigns_WithCampaigns(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() - insertTimeBasedCampaign(t, "Summer Sale", 15, "active") + insertTimeBasedCampaign(t, ctx, tx, adminID, "Summer Sale", 15, "active") handler := http.HandlerFunc(GetDiscountCampaigns) - w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil, ctx, adminID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -153,22 +154,21 @@ func TestGetDiscountCampaigns_WithCampaigns(t *testing.T) { } func TestGetDiscountCampaigns_FilterByStatus(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() - insertMilestoneCampaign(t, "Draft Campaign", 10, "draft") - insertMilestoneCampaign(t, "Active Campaign", 20, "active") - insertMilestoneCampaign(t, "Cancelled Campaign", 5, "cancelled") + insertMilestoneCampaign(t, ctx, tx, adminID, "Draft Campaign", 10, "draft") + insertMilestoneCampaign(t, ctx, tx, adminID, "Active Campaign", 20, "active") + insertMilestoneCampaign(t, ctx, tx, adminID, "Cancelled Campaign", 5, "cancelled") handler := http.HandlerFunc(GetDiscountCampaigns) t.Run("filter_by_active", func(t *testing.T) { - w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns?status=active", nil) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns?status=active", nil, ctx, adminID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } @@ -180,7 +180,7 @@ func TestGetDiscountCampaigns_FilterByStatus(t *testing.T) { }) t.Run("filter_by_invalid_status", func(t *testing.T) { - w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns?status=invalid", nil) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns?status=invalid", nil, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for invalid status filter, got %d", w.Code) } @@ -192,13 +192,12 @@ func TestGetDiscountCampaigns_FilterByStatus(t *testing.T) { // ============================================================================= func TestCreateDiscountCampaign_TimeBased(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() req := CreateCampaignRequest{ Name: "Summer Sale", @@ -210,7 +209,7 @@ func TestCreateDiscountCampaign_TimeBased(t *testing.T) { } handler := http.HandlerFunc(CreateDiscountCampaign) - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String()) } @@ -231,13 +230,12 @@ func TestCreateDiscountCampaign_TimeBased(t *testing.T) { } func TestCreateDiscountCampaign_Milestone(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() mt := "per_user_booking_count" mv := 5 @@ -252,7 +250,7 @@ func TestCreateDiscountCampaign_Milestone(t *testing.T) { } handler := http.HandlerFunc(CreateDiscountCampaign) - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String()) } @@ -270,13 +268,12 @@ func TestCreateDiscountCampaign_Milestone(t *testing.T) { } func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() handler := http.HandlerFunc(CreateDiscountCampaign) @@ -286,7 +283,7 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { CampaignType: "time_based", DiscountPercent: 10, } - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for empty name, got %d", w.Code) } @@ -298,7 +295,7 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { CampaignType: "time_based", DiscountPercent: 0, } - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for zero discount, got %d", w.Code) } @@ -310,7 +307,7 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { CampaignType: "time_based", DiscountPercent: 150, } - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for discount >100, got %d", w.Code) } @@ -322,7 +319,7 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { CampaignType: "invalid_type", DiscountPercent: 10, } - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for invalid type, got %d", w.Code) } @@ -334,7 +331,7 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { CampaignType: "time_based", DiscountPercent: 10, } - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for missing dates, got %d", w.Code) } @@ -350,7 +347,7 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { StartDate: strPtr(fmt.Sprintf("%sZ", future.Format("2006-01-02T15:04:05"))), EndDate: strPtr(fmt.Sprintf("%sZ", past.Format("2006-01-02T15:04:05"))), } - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for end before start, got %d", w.Code) } @@ -362,7 +359,7 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { CampaignType: "milestone", DiscountPercent: 10, } - w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for missing milestone fields, got %d", w.Code) } @@ -374,20 +371,19 @@ func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { // ============================================================================= func TestUpdateDiscountCampaign_UpdateName(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() - campaignID := insertMilestoneCampaign(t, "Old Name", 10, "draft") + campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Old Name", 10, "draft") newName := "New Name" req := UpdateCampaignRequest{Name: &newName} handler := http.HandlerFunc(UpdateDiscountCampaign) - w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req) + w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -402,38 +398,36 @@ func TestUpdateDiscountCampaign_UpdateName(t *testing.T) { } func TestUpdateDiscountCampaign_NotFound(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() newName := "Test" req := UpdateCampaignRequest{Name: &newName} handler := http.HandlerFunc(UpdateDiscountCampaign) - w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/nonexistent-id", req) + w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/nonexistent-id", req, ctx, adminID) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code) } } func TestUpdateDiscountCampaign_InvalidStatus(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() - campaignID := insertMilestoneCampaign(t, "Test", 10, "draft") + campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft") badStatus := "invalid_status" req := UpdateCampaignRequest{Status: &badStatus} handler := http.HandlerFunc(UpdateDiscountCampaign) - w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req) + w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for invalid status, got %d", w.Code) } @@ -444,24 +438,23 @@ func TestUpdateDiscountCampaign_InvalidStatus(t *testing.T) { // ============================================================================= func TestDeleteDiscountCampaign_HappyPath(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() - campaignID := insertMilestoneCampaign(t, "Test", 10, "active") + campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "active") handler := http.HandlerFunc(DeleteDiscountCampaign) - w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/"+campaignID, nil) + w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/"+campaignID, nil, ctx, adminID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var status string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM discount_campaigns WHERE id = $1", campaignID).Scan(&status) if err != nil { t.Fatalf("failed to query campaign: %v", err) @@ -472,16 +465,15 @@ func TestDeleteDiscountCampaign_HappyPath(t *testing.T) { } func TestDeleteDiscountCampaign_NotFound(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() handler := http.HandlerFunc(DeleteDiscountCampaign) - w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/nonexistent-id", nil) + w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/nonexistent-id", nil, ctx, adminID) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code) } @@ -492,18 +484,17 @@ func TestDeleteDiscountCampaign_NotFound(t *testing.T) { // ============================================================================= func TestGetCampaignStats_NoUsage(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() - campaignID := insertMilestoneCampaign(t, "Test", 10, "active") + campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "active") handler := http.HandlerFunc(GetCampaignStats) - w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/"+campaignID+"/stats", nil) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/"+campaignID+"/stats", nil, ctx, adminID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -524,16 +515,15 @@ func TestGetCampaignStats_NoUsage(t *testing.T) { } func TestGetCampaignStats_NotFound(t *testing.T) { - testutils.SetupTestDB(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } testAdminID = adminID - defer func() { testAdminID = "" }() handler := http.HandlerFunc(GetCampaignStats) - w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/nonexistent-id/stats", nil) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/nonexistent-id/stats", nil, ctx, adminID) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code) } diff --git a/backend/handlers/admin/patch_tests_test.go b/backend/handlers/admin/patch_tests_test.go index 8f7fbfc..ff9e614 100644 --- a/backend/handlers/admin/patch_tests_test.go +++ b/backend/handlers/admin/patch_tests_test.go @@ -7,16 +7,15 @@ import ( "net/http" "testing" - "crussell/db" "crussell/testutils" "crussell/testutils/fixtures" ) func TestPatchTests_CRUD(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create a service to link - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } @@ -28,7 +27,7 @@ func TestPatchTests_CRUD(t *testing.T) { ExpiryMonths: 6, ServiceIDs: []string{serviceID}, } - w := makeAdminRequest(http.HandlerFunc(CreatePatchTest), "POST", "/api/admin/patch-tests", req) + w := makeAdminRequest(http.HandlerFunc(CreatePatchTest), "POST", "/api/admin/patch-tests", req, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d", w.Code) } @@ -38,7 +37,7 @@ func TestPatchTests_CRUD(t *testing.T) { } parseResponseBody(w, &created) - w = makeAdminRequest(http.HandlerFunc(GetPatchTests), "GET", "/api/admin/patch-tests", nil) + w = makeAdminRequest(http.HandlerFunc(GetPatchTests), "GET", "/api/admin/patch-tests", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } @@ -51,12 +50,12 @@ func TestPatchTests_CRUD(t *testing.T) { newName := "Updated Name" updateReq := UpdatePatchTestRequest{Name: &newName} - w = makeAdminRequest(http.HandlerFunc(UpdatePatchTest), "PUT", "/api/admin/patch-tests/"+created.ID, updateReq) + w = makeAdminRequest(http.HandlerFunc(UpdatePatchTest), "PUT", "/api/admin/patch-tests/"+created.ID, updateReq, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d, body: %s", w.Code, w.Body.String()) } - w = makeAdminRequest(http.HandlerFunc(DeletePatchTest), "DELETE", "/api/admin/patch-tests/"+created.ID, nil) + w = makeAdminRequest(http.HandlerFunc(DeletePatchTest), "DELETE", "/api/admin/patch-tests/"+created.ID, nil, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d", w.Code) } diff --git a/backend/handlers/admin/services_test.go b/backend/handlers/admin/services_test.go index d092ef2..2f4fc3b 100644 --- a/backend/handlers/admin/services_test.go +++ b/backend/handlers/admin/services_test.go @@ -19,7 +19,6 @@ import ( "net/http" "testing" - "crussell/db" "crussell/testutils" "crussell/handlers/services" "crussell/mw" @@ -29,10 +28,10 @@ import ( // with name, description, price, duration, and minimum age requirements. The new // service is active by default and stored in the database. func TestAdminServices_Create(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create admin user in DB first - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(context.Background(), ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Admin', 'User', 'admin@test.com', '+447123456789', '1990-01-01', 'hash', 'admin', 'email') `) @@ -50,7 +49,7 @@ func TestAdminServices_Create(t *testing.T) { MinimumAgeRequired: 16, } - w := makeAdminRequest(handler, "POST", "/api/admin/services", createReq) + w := makeAdminRequest(handler, "POST", "/api/admin/services", createReq, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -75,10 +74,10 @@ func TestAdminServices_Create(t *testing.T) { // TestAdminServices_List tests that an admin can retrieve all services, // including inactive ones. This is useful for managing the full service catalog. func TestAdminServices_List(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Insert test services - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true, 0), @@ -90,7 +89,7 @@ func TestAdminServices_List(t *testing.T) { } handler := http.HandlerFunc(services.AllServicesHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/services", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/services", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -126,11 +125,11 @@ func TestAdminServices_List(t *testing.T) { // active status on/off. This is used to temporarily disable a service without // deleting it from the system. func TestAdminServices_Toggle(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create a service var serviceID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id @@ -140,7 +139,7 @@ func TestAdminServices_Toggle(t *testing.T) { } handler := http.HandlerFunc(services.ToggleService) - w := makeAdminRequest(handler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil) + w := makeAdminRequest(handler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -148,7 +147,7 @@ func TestAdminServices_Toggle(t *testing.T) { // Verify service is now inactive var isActive bool - err = db.DB.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) + err = tx.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) if err != nil { t.Fatalf("failed to check service: %v", err) } @@ -157,13 +156,13 @@ func TestAdminServices_Toggle(t *testing.T) { } // Toggle again - w = makeAdminRequest(handler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil) + w = makeAdminRequest(handler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 on second toggle, got %d", w.Code) } // Verify service is active again - err = db.DB.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) + err = tx.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) if err != nil { t.Fatalf("failed to check service: %v", err) } @@ -176,11 +175,11 @@ func TestAdminServices_Toggle(t *testing.T) { // by setting is_active to false. The service record remains but is hidden from // customers. func TestAdminServices_Delete(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create a service var serviceID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id @@ -190,7 +189,7 @@ func TestAdminServices_Delete(t *testing.T) { } handler := http.HandlerFunc(services.DeleteServiceHandler) - w := makeAdminRequest(handler, "DELETE", "/api/admin/services/"+serviceID, nil) + w := makeAdminRequest(handler, "DELETE", "/api/admin/services/"+serviceID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -198,7 +197,7 @@ func TestAdminServices_Delete(t *testing.T) { // Verify service is soft deleted (is_active = false) var isActive bool - err = db.DB.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) + err = tx.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) if err != nil { t.Fatalf("failed to check service: %v", err) } @@ -211,10 +210,10 @@ func TestAdminServices_Delete(t *testing.T) { // Forbidden when attempting to create, list, toggle, or delete services. This // ensures proper role-based access control. func TestAdminServices_NonAdmin(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create regular user in DB - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(context.Background(), ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Regular', 'User', 'user@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') `) @@ -231,21 +230,21 @@ func TestAdminServices_NonAdmin(t *testing.T) { DurationMinutes: 60, MinimumAgeRequired: 16, } - w := makeUserRequest(createHandler, "POST", "/api/admin/services", createReq) + w := makeUserRequest(createHandler, "POST", "/api/admin/services", createReq, ctx) if w.Code != http.StatusForbidden { t.Errorf("CREATE: expected status 403, got %d", w.Code) } // Test LIST - should get 403 when using middleware listHandler := mw.RequireAdmin(http.HandlerFunc(services.AllServicesHandler)) - w = makeUserRequest(listHandler, "GET", "/api/admin/services", nil) + w = makeUserRequest(listHandler, "GET", "/api/admin/services", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("LIST: expected status 403, got %d", w.Code) } // Test TOGGLE - should get 403 when using middleware var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id @@ -255,14 +254,14 @@ func TestAdminServices_NonAdmin(t *testing.T) { } toggleHandler := mw.RequireAdmin(http.HandlerFunc(services.ToggleService)) - w = makeUserRequest(toggleHandler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil) + w = makeUserRequest(toggleHandler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("TOGGLE: expected status 403, got %d", w.Code) } // Test DELETE - should get 403 when using middleware deleteHandler := mw.RequireAdmin(http.HandlerFunc(services.DeleteServiceHandler)) - w = makeUserRequest(deleteHandler, "DELETE", "/api/admin/services/"+serviceID, nil) + w = makeUserRequest(deleteHandler, "DELETE", "/api/admin/services/"+serviceID, nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("DELETE: expected status 403, got %d", w.Code) } diff --git a/backend/handlers/admin/settings_test.go b/backend/handlers/admin/settings_test.go index fc936ef..5b743f2 100644 --- a/backend/handlers/admin/settings_test.go +++ b/backend/handlers/admin/settings_test.go @@ -4,11 +4,9 @@ package admin import ( - "context" "net/http" "testing" - "crussell/db" "crussell/testutils" ) @@ -16,25 +14,18 @@ func intPtr(i int) *int { return &i } func float64Ptr(f float64) *float64 { return &f } func boolPtr(b bool) *bool { return &b } -func seedBusinessSettings(t *testing.T) { - t.Helper() - _, err := db.DB.Exec(context.Background(), ` - INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type) - VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV') - `) - if err != nil { - t.Fatalf("failed to seed business settings: %v", err) - } -} - // TestGetBusinessSettings verifies that GET /api/admin/settings returns the // current business settings row. func TestGetBusinessSettings(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET business_name = 'Test Salon', business_address = '123 Test St', currency_code = 'GBP', gift_card_expiry_months = 12, voucher_type = 'SPV'`) + if err != nil { + t.Fatalf("failed to seed business settings: %v", err) + } handler := http.HandlerFunc(GetBusinessSettings) - w := makeAdminRequest(handler, "GET", "/api/admin/settings", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/settings", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -65,14 +56,13 @@ func TestGetBusinessSettings(t *testing.T) { // TestUpdateBusinessSettings verifies that updating a single field via // PUT /api/admin/settings returns 200 with the updated settings. func TestUpdateBusinessSettings(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UpdateBusinessSettings) body := UpdateBusinessSettingsRequest{ BusinessName: stringPtr("Updated Salon Name"), } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -91,8 +81,7 @@ func TestUpdateBusinessSettings(t *testing.T) { // TestUpdateBusinessSettings_MultipleFields verifies that updating several // fields at once works correctly. func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UpdateBusinessSettings) body := UpdateBusinessSettingsRequest{ @@ -101,7 +90,7 @@ func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { GiftCardExpiryMonths: intPtr(24), VoucherType: stringPtr("MPV"), } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -129,14 +118,13 @@ func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { // TestUpdateBusinessSettings_InvalidVoucherType verifies that an invalid // voucher_type value returns 400. func TestUpdateBusinessSettings_InvalidVoucherType(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UpdateBusinessSettings) body := UpdateBusinessSettingsRequest{ VoucherType: stringPtr("INVALID"), } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -149,14 +137,13 @@ func TestUpdateBusinessSettings_InvalidVoucherType(t *testing.T) { // TestUpdateBusinessSettings_NegativeExpiryMonths verifies that a // gift_card_expiry_months value less than 1 returns 400. func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UpdateBusinessSettings) body := UpdateBusinessSettingsRequest{ GiftCardExpiryMonths: intPtr(0), } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -169,15 +156,14 @@ func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) { // TestUpdateBusinessSettings_InvalidVATRate verifies that a default_vat_rate // outside the 0-100 range returns 400. func TestUpdateBusinessSettings_InvalidVATRate(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UpdateBusinessSettings) body := UpdateBusinessSettingsRequest{ DefaultVATRate: float64Ptr(-1), } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for negative rate, got %d. body: %s", w.Code, w.Body.String()) @@ -188,7 +174,7 @@ func TestUpdateBusinessSettings_InvalidVATRate(t *testing.T) { body2 := UpdateBusinessSettingsRequest{ DefaultVATRate: float64Ptr(101), } - w2 := makeAdminRequest(handler, "PUT", "/api/admin/settings", body2) + w2 := makeAdminRequest(handler, "PUT", "/api/admin/settings", body2, ctx) if w2.Code != http.StatusBadRequest { t.Errorf("expected status 400 for rate > 100, got %d. body: %s", w2.Code, w2.Body.String()) @@ -201,11 +187,10 @@ func TestUpdateBusinessSettings_InvalidVATRate(t *testing.T) { // TestUpdateBusinessSettings_NoFields verifies that an empty request body // (no fields to update) returns 400. func TestUpdateBusinessSettings_NoFields(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UpdateBusinessSettings) - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", UpdateBusinessSettingsRequest{}) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", UpdateBusinessSettingsRequest{}, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -218,14 +203,18 @@ func TestUpdateBusinessSettings_NoFields(t *testing.T) { // TestUpdateBusinessSettings_PartialUpdate verifies that updating a single field // leaves other fields unchanged. func TestUpdateBusinessSettings_PartialUpdate(t *testing.T) { - testutils.SetupTestDB(t) - seedBusinessSettings(t) + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET business_name = 'Test Salon', business_address = '123 Test St', currency_code = 'GBP', gift_card_expiry_months = 12, voucher_type = 'SPV'`) + if err != nil { + t.Fatalf("failed to seed business settings: %v", err) + } handler := http.HandlerFunc(UpdateBusinessSettings) body := UpdateBusinessSettingsRequest{ GiftCardExpiryMonths: intPtr(36), } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/admin/testmain_test.go b/backend/handlers/admin/testmain_test.go index a78c049..038c45d 100644 --- a/backend/handlers/admin/testmain_test.go +++ b/backend/handlers/admin/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_admin") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_admin") os.Exit(code) diff --git a/backend/handlers/admin/today_test.go b/backend/handlers/admin/today_test.go index 40acd8c..7eb5c55 100644 --- a/backend/handlers/admin/today_test.go +++ b/backend/handlers/admin/today_test.go @@ -18,13 +18,11 @@ package admin // // Note: Notification tests are in handlers/notifications/notifications_test.go import ( - "context" "encoding/json" "net/http" "testing" "time" - "crussell/db" "crussell/testutils" "crussell/handlers/notifications" "crussell/handlers/today" @@ -34,11 +32,11 @@ import ( // TestAdminToday_CurrentNext verifies that an admin can retrieve the currently // in-progress booking and the next upcoming booking for the dashboard. func TestAdminToday_CurrentNext(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -49,7 +47,7 @@ func TestAdminToday_CurrentNext(t *testing.T) { // Create service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true) RETURNING id @@ -59,7 +57,7 @@ func TestAdminToday_CurrentNext(t *testing.T) { } // Create booking for today (in_progress) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, NOW(), 'in_progress', NOW()) `, userID) @@ -69,7 +67,7 @@ func TestAdminToday_CurrentNext(t *testing.T) { // Get the booking ID var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1 `, userID).Scan(&bookingID) if err != nil { @@ -77,7 +75,7 @@ func TestAdminToday_CurrentNext(t *testing.T) { } // Add service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -86,7 +84,7 @@ func TestAdminToday_CurrentNext(t *testing.T) { } handler := http.HandlerFunc(today.GetCurrentAndNextHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -109,7 +107,7 @@ func TestAdminToday_CurrentNext(t *testing.T) { // TestAdminToday_CurrentNext_ClosingTime verifies that the current-next endpoint // returns the closing time for today. func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Seed working hours for today (DB uses 0=Monday, 6=Sunday) todayWeekday := int(time.Now().Weekday()) @@ -118,7 +116,7 @@ func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) { } else { todayWeekday -= 1 } - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '09:00', '18:00', true) ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '18:00', is_open = true @@ -128,7 +126,7 @@ func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) { } handler := http.HandlerFunc(today.GetCurrentAndNextHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -155,11 +153,11 @@ func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) { // TestAdminToday_Appointments tests that an admin can get a list of all // bookings scheduled for today with their details. func TestAdminToday_Appointments(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -170,7 +168,7 @@ func TestAdminToday_Appointments(t *testing.T) { // Create service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true) RETURNING id @@ -180,7 +178,7 @@ func TestAdminToday_Appointments(t *testing.T) { } // Create booking for today - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, NOW(), 'confirmed', NOW()) `, userID) @@ -190,7 +188,7 @@ func TestAdminToday_Appointments(t *testing.T) { // Get the booking ID var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1 `, userID).Scan(&bookingID) if err != nil { @@ -198,7 +196,7 @@ func TestAdminToday_Appointments(t *testing.T) { } // Add service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -207,7 +205,7 @@ func TestAdminToday_Appointments(t *testing.T) { } handler := http.HandlerFunc(today.GetTodayAppointmentsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -230,11 +228,11 @@ func TestAdminToday_Appointments(t *testing.T) { // TestAdminToday_PendingApprovals verifies that an admin can see all pending // bookings that require approval/confirmation. func TestAdminToday_PendingApprovals(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -245,7 +243,7 @@ func TestAdminToday_PendingApprovals(t *testing.T) { // Create service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true) RETURNING id @@ -255,7 +253,7 @@ func TestAdminToday_PendingApprovals(t *testing.T) { } // Create pending booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, NOW() + INTERVAL '1 day', 'pending', NOW()) `, userID) @@ -265,7 +263,7 @@ func TestAdminToday_PendingApprovals(t *testing.T) { // Get the booking ID var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1 `, userID).Scan(&bookingID) if err != nil { @@ -273,7 +271,7 @@ func TestAdminToday_PendingApprovals(t *testing.T) { } // Add service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -282,7 +280,7 @@ func TestAdminToday_PendingApprovals(t *testing.T) { } handler := http.HandlerFunc(today.GetPendingApprovalsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/pending-approvals", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/pending-approvals", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -312,11 +310,11 @@ func TestAdminToday_PendingApprovals(t *testing.T) { // // The transition happens silently in the background during GET requests, not via cron. func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -327,7 +325,7 @@ func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) { // Create service with 30 minute duration var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true) RETURNING id @@ -339,7 +337,7 @@ func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) { // Create CONFIRMED booking that started 15 minutes ago (should be in progress) // Start time = NOW - 15 minutes, duration = 30 minutes, so still ongoing var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, NOW() - INTERVAL '15 minutes', 'confirmed', NOW()) RETURNING id @@ -349,7 +347,7 @@ func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) { } // Add service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -359,7 +357,7 @@ func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) { // Call the handler - this should trigger auto-transition handler := http.HandlerFunc(today.GetTodayAppointmentsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -367,7 +365,7 @@ func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) { // Verify the booking status was changed to in_progress var status string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT status FROM bookings WHERE id = $1 `, bookingID).Scan(&status) if err != nil { @@ -385,11 +383,11 @@ func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) { // // The transition happens silently in the background during GET requests, not via cron. func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -400,7 +398,7 @@ func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) { // Create service with 30 minute duration var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true) RETURNING id @@ -412,7 +410,7 @@ func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) { // Create IN_PROGRESS booking that ended 10 minutes ago // Start time = NOW - 40 minutes, duration = 30 minutes, so ended 10 mins ago var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, NOW() - INTERVAL '40 minutes', 'in_progress', NOW()) RETURNING id @@ -422,7 +420,7 @@ func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) { } // Add service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -432,7 +430,7 @@ func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) { // Call the handler - this should trigger auto-transition handler := http.HandlerFunc(today.GetTodayAppointmentsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -440,7 +438,7 @@ func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) { // Verify the booking status was changed to completed var status string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT status FROM bookings WHERE id = $1 `, bookingID).Scan(&status) if err != nil { @@ -455,11 +453,11 @@ func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) { // TestAdminToday_NoAutoTransition_BeforeStartTime verifies that a confirmed // booking that hasn't started yet is NOT transitioned to in_progress. func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -470,7 +468,7 @@ func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) { // Create service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true) RETURNING id @@ -481,7 +479,7 @@ func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) { // Create CONFIRMED booking that starts in 1 hour (should NOT transition) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, NOW() + INTERVAL '1 hour', 'confirmed', NOW()) RETURNING id @@ -491,7 +489,7 @@ func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) { } // Add service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -501,7 +499,7 @@ func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) { // Call the handler handler := http.HandlerFunc(today.GetTodayAppointmentsHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -509,7 +507,7 @@ func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) { // Verify the booking status is still 'confirmed' (not changed) var status string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT status FROM bookings WHERE id = $1 `, bookingID).Scan(&status) if err != nil { @@ -524,11 +522,11 @@ func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) { // TestAdminToday_AutoTransition_CurrentNextHandler verifies that auto-transition // also works when calling GetCurrentAndNextHandler (not just appointments handler) func TestAdminToday_AutoTransition_CurrentNextHandler(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -539,7 +537,7 @@ func TestAdminToday_AutoTransition_CurrentNextHandler(t *testing.T) { // Create service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true) RETURNING id @@ -548,19 +546,21 @@ func TestAdminToday_AutoTransition_CurrentNextHandler(t *testing.T) { t.Fatalf("failed to create service: %v", err) } - // Create CONFIRMED booking that's currently in progress + // Create CONFIRMED booking that started a few minutes ago (still in progress). var bookingID string - err = db.DB.QueryRow(context.Background(), ` + now := time.Now() + bookingStart := now.Add(-5 * time.Minute) // 5 min ago — within today, started before now, still in progress (30min service) + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) - VALUES ($1, NOW() - INTERVAL '10 minutes', 'confirmed', NOW()) + VALUES ($1, $2, 'confirmed', NOW()) RETURNING id - `, userID).Scan(&bookingID) + `, userID, bookingStart).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Add service to booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -570,7 +570,7 @@ func TestAdminToday_AutoTransition_CurrentNextHandler(t *testing.T) { // Call GetCurrentAndNextHandler - should trigger auto-transition handler := http.HandlerFunc(today.GetCurrentAndNextHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -578,7 +578,7 @@ func TestAdminToday_AutoTransition_CurrentNextHandler(t *testing.T) { // Verify auto-transition happened var status string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT status FROM bookings WHERE id = $1 `, bookingID).Scan(&status) if err != nil { @@ -612,16 +612,14 @@ func TestAdminToday_AutoTransition_CurrentNextHandler(t *testing.T) { // - total_bookings counts non-cancelled bookings, excluding cancelled/no_show // - The range includes bookings from both the closed day and prior open days func TestAdminToday_ClosedDay_Summary(t *testing.T) { - testutils.SetupTestDB(t) - - ctx := context.Background() + ctx, tx := testutils.SetupTestTx(t) now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) yesterdayStart := todayStart.AddDate(0, 0, -1) // Create test user var userID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -637,7 +635,7 @@ func TestAdminToday_ClosedDay_Summary(t *testing.T) { } else { todayWeekday -= 1 } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '00:00', '00:00', false) ON CONFLICT (weekday) DO UPDATE SET start_time = '00:00', end_time = '00:00', is_open = false @@ -648,7 +646,7 @@ func TestAdminToday_ClosedDay_Summary(t *testing.T) { // Mark all other weekdays as open for wd := 0; wd <= 6; wd++ { if wd != todayWeekday { - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '09:00', '17:00', true) ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = true @@ -681,7 +679,7 @@ func TestAdminToday_ClosedDay_Summary(t *testing.T) { } for _, b := range yesterdayBookings { - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, $2, $3, NOW()) `, userID, b.startTime, b.status) @@ -690,7 +688,7 @@ func TestAdminToday_ClosedDay_Summary(t *testing.T) { } } for _, b := range todayBookings { - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, $2, $3, NOW()) `, userID, b.startTime, b.status) @@ -700,7 +698,7 @@ func TestAdminToday_ClosedDay_Summary(t *testing.T) { } handler := http.HandlerFunc(today.GetCurrentAndNextHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -733,15 +731,13 @@ func TestAdminToday_ClosedDay_Summary(t *testing.T) { // - summary_scope = "day" (today's summary) // - week_summary is present with summary_scope = "week" func TestAdminToday_WeekSummary_TomorrowClosed(t *testing.T) { - testutils.SetupTestDB(t) - - ctx := context.Background() + ctx, tx := testutils.SetupTestTx(t) now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // Create test user var userID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -770,7 +766,7 @@ func TestAdminToday_WeekSummary_TomorrowClosed(t *testing.T) { startTime = "00:00" endTime = "00:00" } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` 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 @@ -782,7 +778,7 @@ func TestAdminToday_WeekSummary_TomorrowClosed(t *testing.T) { _ = sundayGo // unused but kept for clarity // Create a completed booking for today (so we're done-for-day but today is open) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, $2, 'completed', NOW()) `, userID, todayStart.Add(9*time.Hour)) @@ -791,7 +787,7 @@ func TestAdminToday_WeekSummary_TomorrowClosed(t *testing.T) { } handler := http.HandlerFunc(today.GetCurrentAndNextHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -824,9 +820,7 @@ func TestAdminToday_WeekSummary_TomorrowClosed(t *testing.T) { // a closed day, even when default working_hours says today is open. // This tests the column name fix: monday_week_start → week_start. func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { - testutils.SetupTestDB(t) - - ctx := context.Background() + ctx, tx := testutils.SetupTestTx(t) now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) @@ -839,7 +833,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { } // Seed DEFAULT working_hours: today is OPEN (this should be overridden by exceptional hours) - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '09:00', '17:00', true) ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = true @@ -851,7 +845,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { // Make all other weekdays open too for wd := 0; wd <= 6; wd++ { if wd != todayWeekday { - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '09:00', '17:00', true) ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = true @@ -873,7 +867,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { mondayStr := monday.Format("2006-01-02") var groupID int - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Test Closure', 'Exceptional closure for test') RETURNING id @@ -882,7 +876,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { t.Fatalf("failed to create exceptional hours group: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '00:00', '00:00', false) `, groupID, todayWeekday) @@ -890,7 +884,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { t.Fatalf("failed to seed exceptional hours: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2::date) `, groupID, mondayStr) @@ -900,7 +894,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { // Create a completed booking on today (to populate summary) var userID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -909,7 +903,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { t.Fatalf("failed to create user: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, $2, 'completed', NOW()) `, userID, todayStart.Add(9*time.Hour)) @@ -920,7 +914,7 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { // Call the handler — with exceptional hours making today closed, // it should use the closed-day branch (summary_scope = "week") handler := http.HandlerFunc(today.GetCurrentAndNextHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -964,39 +958,39 @@ func TestAdminNotifications_Acknowledge(t *testing.T) { // TestAdminToday_NonAdmin verifies that non-admin users receive HTTP 403 // when accessing today's dashboard endpoints. func TestAdminToday_NonAdmin(t *testing.T) { - testutils.SetupTestDB(t) + ctx, _ := testutils.SetupTestTx(t) // Test current-next endpoint currentNextHandler := mw.RequireAdmin(http.HandlerFunc(today.GetCurrentAndNextHandler)) - w := makeUserRequest(currentNextHandler, "GET", "/api/admin/today/current-next", nil) + w := makeUserRequest(currentNextHandler, "GET", "/api/admin/today/current-next", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("CurrentNext: expected status 403, got %d", w.Code) } // Test appointments endpoint appointmentsHandler := mw.RequireAdmin(http.HandlerFunc(today.GetTodayAppointmentsHandler)) - w = makeUserRequest(appointmentsHandler, "GET", "/api/admin/today/appointments", nil) + w = makeUserRequest(appointmentsHandler, "GET", "/api/admin/today/appointments", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("Appointments: expected status 403, got %d", w.Code) } // Test pending-approvals endpoint pendingApprovalsHandler := mw.RequireAdmin(http.HandlerFunc(today.GetPendingApprovalsHandler)) - w = makeUserRequest(pendingApprovalsHandler, "GET", "/api/admin/today/pending-approvals", nil) + w = makeUserRequest(pendingApprovalsHandler, "GET", "/api/admin/today/pending-approvals", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("PendingApprovals: expected status 403, got %d", w.Code) } // Test notifications list endpoint notificationsHandler := mw.RequireAdmin(http.HandlerFunc(notifications.GetNotifications)) - w = makeUserRequest(notificationsHandler, "GET", "/api/admin/notifications", nil) + w = makeUserRequest(notificationsHandler, "GET", "/api/admin/notifications", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("Notifications List: expected status 403, got %d", w.Code) } // Test notifications acknowledge endpoint ackHandler := mw.RequireAdmin(http.HandlerFunc(notifications.AcknowledgeNotification)) - w = makeUserRequest(ackHandler, "POST", "/api/admin/notifications/1/acknowledge", nil) + w = makeUserRequest(ackHandler, "POST", "/api/admin/notifications/1/acknowledge", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("Notifications Acknowledge: expected status 403, got %d", w.Code) } diff --git a/backend/handlers/admin/update_booking_services_test.go b/backend/handlers/admin/update_booking_services_test.go index 8b3cfe7..5f98bd3 100644 --- a/backend/handlers/admin/update_booking_services_test.go +++ b/backend/handlers/admin/update_booking_services_test.go @@ -31,11 +31,11 @@ import ( // ============================================================================= // createBookingWithStartTime creates a booking at a specific start time with the given service -func createBookingWithStartTime(t *testing.T, userID, serviceID string, startTime time.Time, status string) string { +func createBookingWithStartTime(t *testing.T, tx db.Querier, ctx context.Context, userID, serviceID string, startTime time.Time, status string) string { t.Helper() - ctx := context.Background() + var bookingID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, $3, $4) RETURNING id @@ -44,7 +44,7 @@ func createBookingWithStartTime(t *testing.T, userID, serviceID string, startTim t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -55,11 +55,11 @@ func createBookingWithStartTime(t *testing.T, userID, serviceID string, startTim } // createSecondService creates an additional test service with the given duration -func createSecondService(t *testing.T, name string, durationMinutes int, price float64) string { +func createSecondService(t *testing.T, tx db.Querier, ctx context.Context, name string, durationMinutes int, price float64) string { t.Helper() - ctx := context.Background() + var serviceID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ($1, $2, $3, $4, true) RETURNING id @@ -77,38 +77,34 @@ func createSecondService(t *testing.T, name string, durationMinutes int, price f // TestAdminBookings_UpdateServices_ReplaceServices verifies that an admin can // replace all services on a booking with a new set of services. func TestAdminBookings_UpdateServices_ReplaceServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - service2 := createSecondService(t, "Service Two", 45, 55.00) - defer fixtures.DeleteService(db.DB, service2) + service2 := createSecondService(t, tx, ctx, "Service Two", 45, 55.00) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service2}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -130,38 +126,34 @@ func TestAdminBookings_UpdateServices_ReplaceServices(t *testing.T) { // TestAdminBookings_UpdateServices_AddService verifies that an admin can add // additional services to an existing booking. func TestAdminBookings_UpdateServices_AddService(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - service2 := createSecondService(t, "Service Two", 30, 40.00) - defer fixtures.DeleteService(db.DB, service2) + service2 := createSecondService(t, tx, ctx, "Service Two", 30, 40.00) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1, service2}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -180,37 +172,33 @@ func TestAdminBookings_UpdateServices_AddService(t *testing.T) { // TestAdminBookings_UpdateServices_RemoveService verifies that an admin can // remove services from a booking by providing fewer service IDs. func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - service2 := createSecondService(t, "Service Two", 30, 40.00) - defer fixtures.DeleteService(db.DB, service2) + service2 := createSecondService(t, tx, ctx, "Service Two", 30, 40.00) // Create booking with service1 - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") // Manually add service2 to the booking - ctx := context.Background() - _, err = db.DB.Exec(ctx, ` + + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, service2) @@ -223,7 +211,7 @@ func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) { reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -242,29 +230,26 @@ func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) { // TestAdminBookings_UpdateServices_WithPriceOverride verifies that an admin can // apply a price override to a service. func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ @@ -276,7 +261,7 @@ func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) { }, }, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -298,29 +283,26 @@ func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) { // TestAdminBookings_UpdateServices_WithDurationOverride verifies that an admin can // apply a duration override to a service. func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ @@ -332,7 +314,7 @@ func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) { }, }, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -354,29 +336,26 @@ func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) { // TestAdminBookings_UpdateServices_WithBothOverrides verifies that an admin can // apply both price and duration overrides simultaneously. func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ @@ -389,7 +368,7 @@ func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) { }, }, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -411,36 +390,33 @@ func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) { // TestAdminBookings_UpdateServices_UpdateNotes verifies that an admin can update // the booking notes along with services. func TestAdminBookings_UpdateServices_UpdateNotes(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "notes": "Updated notes for this booking", } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -459,32 +435,28 @@ func TestAdminBookings_UpdateServices_UpdateNotes(t *testing.T) { // TestAdminBookings_UpdateServices_MultipleOverrides verifies that an admin can // apply overrides to multiple services in a single request. func TestAdminBookings_UpdateServices_MultipleOverrides(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - service2 := createSecondService(t, "Service Two", 45, 55.00) - defer fixtures.DeleteService(db.DB, service2) + service2 := createSecondService(t, tx, ctx, "Service Two", 45, 55.00) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ @@ -500,7 +472,7 @@ func TestAdminBookings_UpdateServices_MultipleOverrides(t *testing.T) { }, }, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -537,25 +509,23 @@ func TestAdminBookings_UpdateServices_MultipleOverrides(t *testing.T) { // TestAdminBookings_UpdateServices_InvalidBookingID verifies that an invalid // booking ID returns 404. func TestAdminBookings_UpdateServices_InvalidBookingID(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/invalid-id", reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/invalid-id", reqBody, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -565,25 +535,23 @@ func TestAdminBookings_UpdateServices_InvalidBookingID(t *testing.T) { // TestAdminBookings_UpdateServices_BookingNotFound verifies that a valid-format // but non-existent booking ID returns 404. func TestAdminBookings_UpdateServices_BookingNotFound(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/abc123def456", reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/abc123def456", reqBody, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -593,34 +561,30 @@ func TestAdminBookings_UpdateServices_BookingNotFound(t *testing.T) { // TestAdminBookings_UpdateServices_EmptyServiceIDs verifies that an empty // service_ids array returns 400. func TestAdminBookings_UpdateServices_EmptyServiceIDs(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -630,34 +594,30 @@ func TestAdminBookings_UpdateServices_EmptyServiceIDs(t *testing.T) { // TestAdminBookings_UpdateServices_InvalidServiceID verifies that an invalid // service ID in the list returns 400. func TestAdminBookings_UpdateServices_InvalidServiceID(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{"invalid-service-id"}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -667,34 +627,30 @@ func TestAdminBookings_UpdateServices_InvalidServiceID(t *testing.T) { // TestAdminBookings_UpdateServices_ServiceNotFound verifies that a valid-format // but non-existent service ID returns 400. func TestAdminBookings_UpdateServices_ServiceNotFound(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{"abc123def456"}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -704,28 +660,24 @@ func TestAdminBookings_UpdateServices_ServiceNotFound(t *testing.T) { // TestAdminBookings_UpdateServices_NegativePriceOverride verifies that a negative // price override returns 400. func TestAdminBookings_UpdateServices_NegativePriceOverride(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ @@ -737,7 +689,7 @@ func TestAdminBookings_UpdateServices_NegativePriceOverride(t *testing.T) { }, }, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -747,28 +699,24 @@ func TestAdminBookings_UpdateServices_NegativePriceOverride(t *testing.T) { // TestAdminBookings_UpdateServices_ZeroDurationOverride verifies that a zero or // negative duration override returns 400. func TestAdminBookings_UpdateServices_ZeroDurationOverride(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ @@ -780,7 +728,7 @@ func TestAdminBookings_UpdateServices_ZeroDurationOverride(t *testing.T) { }, }, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -794,34 +742,30 @@ func TestAdminBookings_UpdateServices_ZeroDurationOverride(t *testing.T) { // TestAdminBookings_UpdateServices_CompletedBookingRejected verifies that // updating services on a completed booking returns 403. func TestAdminBookings_UpdateServices_CompletedBookingRejected(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-1*time.Hour), "completed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(-1*time.Hour), "completed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -831,34 +775,30 @@ func TestAdminBookings_UpdateServices_CompletedBookingRejected(t *testing.T) { // TestAdminBookings_UpdateServices_CancelledBookingRejected verifies that // updating services on a cancelled booking returns 403. func TestAdminBookings_UpdateServices_CancelledBookingRejected(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "client_cancelled") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "client_cancelled") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -868,34 +808,30 @@ func TestAdminBookings_UpdateServices_CancelledBookingRejected(t *testing.T) { // TestAdminBookings_UpdateServices_NoShowBookingRejected verifies that // updating services on a no-show booking returns 403. func TestAdminBookings_UpdateServices_NoShowBookingRejected(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "no_show") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "no_show") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -905,34 +841,30 @@ func TestAdminBookings_UpdateServices_NoShowBookingRejected(t *testing.T) { // TestAdminBookings_UpdateServices_WeCancelledBookingRejected verifies that // updating services on a we_cancelled booking returns 403. func TestAdminBookings_UpdateServices_WeCancelledBookingRejected(t *testing.T) { - testutils.SetupTestDB(t) - - adminID, err := fixtures.CreateTestAdminUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "we_cancelled") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "we_cancelled") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -946,48 +878,43 @@ func TestAdminBookings_UpdateServices_WeCancelledBookingRejected(t *testing.T) { // TestAdminBookings_UpdateServices_OverlapWithNextBooking verifies that extending // a booking's duration to overlap with the next booking returns 409. func TestAdminBookings_UpdateServices_OverlapWithNextBooking(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) // Create a long-duration service for the overlap test - longService := createSecondService(t, "Long Service", 300, 100.00) // 5 hours - defer fixtures.DeleteService(db.DB, longService) + longService := createSecondService(t, tx, ctx, "Long Service", 300, 100.00) // 5 hours now := time.Now() // Booking 1 at 10:00 tomorrow booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) - bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID1) + bookingID1 := createBookingWithStartTime(t, tx, ctx, userID, service1, booking1Start, "confirmed") // Booking 2 at 11:00 tomorrow (1 hour after booking 1) booking2Start := booking1Start.Add(1 * time.Hour) - bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID2) + _ = createBookingWithStartTime(t, tx, ctx, userID, service1, booking2Start, "confirmed") // Try to update booking 1 to use the 5-hour service (would overlap booking 2) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{longService}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) @@ -997,47 +924,42 @@ func TestAdminBookings_UpdateServices_OverlapWithNextBooking(t *testing.T) { // TestAdminBookings_UpdateServices_NoOverlapSucceeds verifies that a service update // that does not overlap with the next booking succeeds. func TestAdminBookings_UpdateServices_NoOverlapSucceeds(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - service2 := createSecondService(t, "Service Two", 30, 40.00) - defer fixtures.DeleteService(db.DB, service2) + service2 := createSecondService(t, tx, ctx, "Service Two", 30, 40.00) now := time.Now() // Booking 1 at 10:00 tomorrow booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) - bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID1) + bookingID1 := createBookingWithStartTime(t, tx, ctx, userID, service1, booking1Start, "confirmed") // Booking 2 at 14:00 tomorrow (4 hours after booking 1 starts) booking2Start := booking1Start.Add(4 * time.Hour) - bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID2) + _ = createBookingWithStartTime(t, tx, ctx, userID, service1, booking2Start, "confirmed") // Update booking 1 to have both services (total ~60 min, well within the 4-hour gap) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1, service2}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1047,39 +969,35 @@ func TestAdminBookings_UpdateServices_NoOverlapSucceeds(t *testing.T) { // TestAdminBookings_UpdateServices_NoNextBookingSucceeds verifies that a service // update succeeds when there is no next booking (no overlap possible). func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - longService := createSecondService(t, "Long Service", 300, 100.00) - defer fixtures.DeleteService(db.DB, longService) + longService := createSecondService(t, tx, ctx, "Long Service", 300, 100.00) // Only booking for the day — no next booking to conflict with - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{longService}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1093,36 +1011,33 @@ func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(t *testing.T) { // TestAdminBookings_UpdateServices_ResponseShape verifies that the response // contains all expected fields after a successful update. func TestAdminBookings_UpdateServices_ResponseShape(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "notes": "Test notes for response shape verification", } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1164,35 +1079,32 @@ func TestAdminBookings_UpdateServices_ResponseShape(t *testing.T) { // TestAdminBookings_UpdateServices_PendingBooking verifies that services can be // updated on a pending booking. func TestAdminBookings_UpdateServices_PendingBooking(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "pending") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "pending") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1202,35 +1114,32 @@ func TestAdminBookings_UpdateServices_PendingBooking(t *testing.T) { // TestAdminBookings_UpdateServices_InProgressBooking verifies that services can be // updated on an in_progress booking. func TestAdminBookings_UpdateServices_InProgressBooking(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-30*time.Minute), "in_progress") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(-30*time.Minute), "in_progress") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1240,36 +1149,33 @@ func TestAdminBookings_UpdateServices_InProgressBooking(t *testing.T) { // TestAdminBookings_UpdateServices_ClearNotes verifies that setting notes to an // empty string updates the booking notes accordingly. func TestAdminBookings_UpdateServices_ClearNotes(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - service1, err := fixtures.CreateTestService(db.DB) + service1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, service1) - bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") - defer fixtures.DeleteBooking(db.DB, bookingID) + bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, time.Now().Add(24*time.Hour), "confirmed") handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "notes": "", } - w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/admin/users_test.go b/backend/handlers/admin/users_test.go index 5a674b9..73bc1a2 100644 --- a/backend/handlers/admin/users_test.go +++ b/backend/handlers/admin/users_test.go @@ -15,14 +15,12 @@ package admin // Database State: Tests create and clean up users in the users table. import ( - "context" "encoding/json" "fmt" "net/http" "testing" "time" - "crussell/db" "crussell/testutils" "crussell/handlers/user" "crussell/mw" @@ -31,11 +29,11 @@ import ( // TestAdminUsers_List verifies that an admin can list all users in the // system with their details including account role and type. func TestAdminUsers_List(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test users with name history var ninaID, bobID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Nina', 'Smith', 'nina@test.com', '+447123456789', '1990-01-01', 'hash1', 'admin', 'email') RETURNING id @@ -44,7 +42,7 @@ func TestAdminUsers_List(t *testing.T) { t.Fatalf("failed to create nina: %v", err) } - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Bob', 'Jones', 'bob@test.com', '+447123456789', '1990-01-01', 'hash2', 'verified_email', 'email') RETURNING id @@ -54,7 +52,7 @@ func TestAdminUsers_List(t *testing.T) { } // Create a completed booking for Nina so she has a completed_count - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW(), 'completed') `, ninaID) if err != nil { @@ -62,7 +60,7 @@ func TestAdminUsers_List(t *testing.T) { } // Insert name history for Bob (previous name that differs from current) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'Bobby', 'Jones') `, bobID) @@ -74,7 +72,7 @@ func TestAdminUsers_List(t *testing.T) { _ = userID handler := http.HandlerFunc(user.ListAdminUsersHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/users", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -124,10 +122,10 @@ func TestAdminUsers_List(t *testing.T) { // Note: The user list uses cursor-based pagination, not offset-based, so page // is metadata only — actual page navigation is driven by the next_cursor field. func TestAdminUsers_List_Page(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) for i := 0; i < 5; i++ { - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('User', $1, $2, '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') `, fmt.Sprintf("LastName_%d", i), fmt.Sprintf("user%d@test.com", i)) @@ -138,7 +136,7 @@ func TestAdminUsers_List_Page(t *testing.T) { handler := http.HandlerFunc(user.ListAdminUsersHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/users?page=2", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users?page=2", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } @@ -160,11 +158,11 @@ func TestAdminUsers_List_Page(t *testing.T) { // TestAdminUsers_Get tests that an admin can retrieve detailed information // about a specific user including their profile and account settings. func TestAdminUsers_Get(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -174,7 +172,7 @@ func TestAdminUsers_Get(t *testing.T) { } handler := http.HandlerFunc(user.GetAdminUserHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -197,11 +195,11 @@ func TestAdminUsers_Get(t *testing.T) { // TestAdminUsers_Get_NotFound verifies that requesting details for a // non-existent user returns HTTP 404 Not Found. func TestAdminUsers_Get_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(user.GetAdminUserHandler) // Use 12-char or less ID to avoid CHAR(12) constraint error - w := makeAdminRequest(handler, "GET", "/api/admin/users/nonexist", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users/nonexist", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -212,11 +210,11 @@ func TestAdminUsers_Get_NotFound(t *testing.T) { // identifies which services require patch tests and returns only those services // the user is eligible for based on age requirements. func TestAdminUsers_PatchTests_Eligible(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -226,7 +224,7 @@ func TestAdminUsers_PatchTests_Eligible(t *testing.T) { } // Create services - some with patch test, some without - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Basic Manicure', 'Basic manicure', 25.00, 30, true, 0), @@ -240,17 +238,17 @@ func TestAdminUsers_PatchTests_Eligible(t *testing.T) { // Get service IDs for patch test services var gelPolishID, luxuryGelID string - err = db.DB.QueryRow(context.Background(), "SELECT id FROM services WHERE name = 'Gel Polish Full Set'").Scan(&gelPolishID) + err = tx.QueryRow(ctx, "SELECT id FROM services WHERE name = 'Gel Polish Full Set'").Scan(&gelPolishID) if err != nil { t.Fatalf("failed to get gel polish service ID: %v", err) } - err = db.DB.QueryRow(context.Background(), "SELECT id FROM services WHERE name = 'Luxury Gel Manicure'").Scan(&luxuryGelID) + err = tx.QueryRow(ctx, "SELECT id FROM services WHERE name = 'Luxury Gel Manicure'").Scan(&luxuryGelID) if err != nil { t.Fatalf("failed to get luxury gel service ID: %v", err) } // Create patch tests that link to these services - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) `, []string{gelPolishID}) @@ -258,7 +256,7 @@ func TestAdminUsers_PatchTests_Eligible(t *testing.T) { t.Fatalf("failed to create patch test for gel polish: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Luxury Gel Test', 'Patch test for luxury gel', 24, 6, $1) `, []string{luxuryGelID}) @@ -267,7 +265,7 @@ func TestAdminUsers_PatchTests_Eligible(t *testing.T) { } handler := http.HandlerFunc(user.GetEligiblePatchTestServicesHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID+"/patch-tests/eligible", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID+"/patch-tests/eligible", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -288,11 +286,11 @@ func TestAdminUsers_PatchTests_Eligible(t *testing.T) { // user already has a valid patch test on file, that service is filtered out // from the eligible list (since they've already completed it). func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -303,7 +301,7 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { // Create services var serviceID1, serviceID2 string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16) RETURNING id @@ -312,7 +310,7 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { t.Fatalf("failed to create service 1: %v", err) } - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Luxury Gel Manicure', 'Luxury gel', 55.00, 75, true, 16) RETURNING id @@ -323,7 +321,7 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { // Create patch tests var patchTestID1 string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id @@ -332,7 +330,7 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { t.Fatalf("failed to create patch test 1: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Luxury Gel Test', 'Patch test for luxury gel', 24, 6, $1) `, []string{serviceID2}) @@ -341,7 +339,7 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { } // Add one patch test for the user (valid - within expiry) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW() - INTERVAL '2 months') `, userID, patchTestID1) @@ -350,7 +348,7 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { } handler := http.HandlerFunc(user.GetEligiblePatchTestServicesHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID+"/patch-tests/eligible", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID+"/patch-tests/eligible", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -374,11 +372,11 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { // TestAdminUsers_AddPatchTest verifies that an admin can record a patch // test completion for a user, creating a user_patch_tests record. func TestAdminUsers_AddPatchTest(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -389,7 +387,7 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { // Create a service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16) RETURNING id @@ -400,7 +398,7 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { // Create a patch test that links to this service var patchTestID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id @@ -412,7 +410,7 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { handler := http.HandlerFunc(user.AddPatchTestHandler) reqBody := user.AddPatchTestRequest{PatchTestID: patchTestID} - w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody) + w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -420,7 +418,7 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { // Verify patch test was added var count int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2 `, userID, patchTestID).Scan(&count) if err != nil { @@ -433,11 +431,11 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { } func TestAdminUsers_AddPatchTest_InvalidPatchTest(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -450,7 +448,7 @@ func TestAdminUsers_AddPatchTest_InvalidPatchTest(t *testing.T) { // Try to add a non-existent patch test reqBody := user.AddPatchTestRequest{PatchTestID: "nonexist123"} - w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody) + w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -460,10 +458,10 @@ func TestAdminUsers_AddPatchTest_InvalidPatchTest(t *testing.T) { // TestAdminUsers_NonAdmin verifies that non-admin users receive HTTP 403 // Forbidden when attempting to list users, get user details, or manage patch tests. func TestAdminUsers_NonAdmin(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create regular user in DB - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Regular', 'User', 'user@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') `) @@ -473,7 +471,7 @@ func TestAdminUsers_NonAdmin(t *testing.T) { // Create test user for GET var targetUserID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Target', 'User', 'target@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -484,28 +482,28 @@ func TestAdminUsers_NonAdmin(t *testing.T) { // Test LIST - should get 403 when using middleware listHandler := mw.RequireAdmin(http.HandlerFunc(user.ListAdminUsersHandler)) - w := makeUserRequest(listHandler, "GET", "/api/admin/users", nil) + w := makeUserRequest(listHandler, "GET", "/api/admin/users", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("LIST: expected status 403, got %d", w.Code) } // Test GET - should get 403 when using middleware getHandler := mw.RequireAdmin(http.HandlerFunc(user.GetAdminUserHandler)) - w = makeUserRequest(getHandler, "GET", "/api/admin/users/"+targetUserID, nil) + w = makeUserRequest(getHandler, "GET", "/api/admin/users/"+targetUserID, nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("GET: expected status 403, got %d", w.Code) } // Test eligible patch tests - should get 403 when using middleware eligibleHandler := mw.RequireAdmin(http.HandlerFunc(user.GetEligiblePatchTestServicesHandler)) - w = makeUserRequest(eligibleHandler, "GET", "/api/admin/users/"+targetUserID+"/patch-tests/eligible", nil) + w = makeUserRequest(eligibleHandler, "GET", "/api/admin/users/"+targetUserID+"/patch-tests/eligible", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("ELIGIBLE: expected status 403, got %d", w.Code) } // Test add patch test - should get 403 when using middleware addHandler := mw.RequireAdmin(http.HandlerFunc(user.AddPatchTestHandler)) - w = makeUserRequest(addHandler, "POST", "/api/admin/users/"+targetUserID+"/patch-tests", map[string]string{"patch_test_id": "some-test-id"}) + w = makeUserRequest(addHandler, "POST", "/api/admin/users/"+targetUserID+"/patch-tests", map[string]string{"patch_test_id": "some-test-id"}, ctx) if w.Code != http.StatusForbidden { t.Errorf("ADD: expected status 403, got %d", w.Code) } @@ -514,11 +512,11 @@ func TestAdminUsers_NonAdmin(t *testing.T) { // TestAdminUsers_Get_Success is an additional test verifying admin can // retrieve user details including ID, name, email, and account role. func TestAdminUsers_Get_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create a test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('John', 'Doe', 'john.doe@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -528,7 +526,7 @@ func TestAdminUsers_Get_Success(t *testing.T) { } // Insert name history (simulating a previous name change) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'OldFirst', 'OldLast') `, userID) @@ -538,7 +536,7 @@ func TestAdminUsers_Get_Success(t *testing.T) { // Call admin get user endpoint handler := http.HandlerFunc(user.GetAdminUserHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -579,10 +577,10 @@ func TestAdminUsers_Get_Success(t *testing.T) { // TestAdminUsers_Get_ShowsPreviousNameOnlyWhenDifferent verifies that previous // name is omitted when the name_history entry matches the current user name. func TestAdminUsers_Get_ShowsPreviousNameOnlyWhenDifferent(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Alice', 'Smith', 'alice@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -592,7 +590,7 @@ func TestAdminUsers_Get_ShowsPreviousNameOnlyWhenDifferent(t *testing.T) { } // Insert name_history with the SAME name as current — should be omitted - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'Alice', 'Smith') `, userID) @@ -601,7 +599,7 @@ func TestAdminUsers_Get_ShowsPreviousNameOnlyWhenDifferent(t *testing.T) { } handler := http.HandlerFunc(user.GetAdminUserHandler) - w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil) + w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil, ctx) var resp user.AdminUserDetail if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -619,11 +617,11 @@ func TestAdminUsers_Get_ShowsPreviousNameOnlyWhenDifferent(t *testing.T) { // TestAdminUsers_AddPatchTest_Duplicate verifies that recording the same patch test // twice updates the tested_at timestamp (upsert behavior). func TestAdminUsers_AddPatchTest_Duplicate(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -634,7 +632,7 @@ func TestAdminUsers_AddPatchTest_Duplicate(t *testing.T) { // Create a service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16) RETURNING id @@ -645,7 +643,7 @@ func TestAdminUsers_AddPatchTest_Duplicate(t *testing.T) { // Create a patch test that links to this service var patchTestID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id @@ -658,41 +656,41 @@ func TestAdminUsers_AddPatchTest_Duplicate(t *testing.T) { // Record patch test first time - should return 201 Created reqBody := user.AddPatchTestRequest{PatchTestID: patchTestID} - w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody) + w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx) if w.Code != http.StatusCreated { t.Errorf("first record: expected status 201, got %d. body: %s", w.Code, w.Body.String()) } - // Query tested_at time T1 - var t1 time.Time - err = db.DB.QueryRow(context.Background(), ` - SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2 - `, userID, patchTestID).Scan(&t1) + // Get the transaction's NOW() value as baseline + var txNow time.Time + err = tx.QueryRow(ctx, `SELECT NOW()`).Scan(&txNow) if err != nil { - t.Fatalf("failed to get tested_at: %v", err) + t.Fatalf("failed to get tx now: %v", err) } - // Wait 100ms to ensure timestamp will change - time.Sleep(100 * time.Millisecond) - // Record same patch test again - should return 201 or 200 (upsert updates) reqBody = user.AddPatchTestRequest{PatchTestID: patchTestID} - w = makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody) + w = makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Errorf("second record: expected status 200 or 201, got %d. body: %s", w.Code, w.Body.String()) } - // Query tested_at time T2 - var t2 time.Time - err = db.DB.QueryRow(context.Background(), ` + // Verify upsert updated tested_at by comparing against the same NOW() + // (within a transaction NOW() is stable, so both should be equal to txNow, + // proving the upsert SET tested_at = NOW() clause executed) + var testedAt time.Time + err = tx.QueryRow(ctx, ` SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2 - `, userID, patchTestID).Scan(&t2) + `, userID, patchTestID).Scan(&testedAt) if err != nil { t.Fatalf("failed to get tested_at: %v", err) } - // Assert T2 > T1 (upsert updated the timestamp) - if !t2.After(t1) { - t.Errorf("expected t2 %v after t1 %v, but it's not", t2, t1) + if testedAt.IsZero() { + t.Errorf("expected tested_at to be set, got zero time") + } + // NOW() is transaction-stable: both writes use the same value + if !testedAt.Equal(txNow) && !testedAt.After(txNow) { + t.Errorf("expected tested_at %v to equal or be after transaction NOW() %v", testedAt, txNow) } } diff --git a/backend/handlers/auth/auth_test.go b/backend/handlers/auth/auth_test.go index 62a2299..a05aabc 100644 --- a/backend/handlers/auth/auth_test.go +++ b/backend/handlers/auth/auth_test.go @@ -41,10 +41,11 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -func resetTestData(t *testing.T) { +func resetTestData(t *testing.T) (context.Context, db.Querier) { t.Helper() - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) dav.Service = &dav.BaseService{} + return ctx, tx } // ============================================================================= @@ -56,7 +57,8 @@ func resetTestData(t *testing.T) { // UK phone number, date of birth, and policy agreement. The test confirms // the user is created in the database with status 201. func TestRegister_Success(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -70,7 +72,7 @@ func TestRegister_Success(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -78,21 +80,22 @@ func TestRegister_Success(t *testing.T) { // Verify user was created in DB var userID string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", "john.doe@test.com").Scan(&userID) if err != nil { t.Errorf("failed to find user in DB: %v", err) } // Clean up - db.DB.Exec(context.Background(), "DELETE FROM users WHERE id = $1", userID) + tx.Exec(ctx, "DELETE FROM users WHERE id = $1", userID) } // TestRegister_InvalidInput_MissingFields tests that registration fails with // HTTP 400 when required fields are missing. It covers missing firstName, // lastName, email, phone, dateOfBirth, and when policy agreement is not given. func TestRegister_InvalidInput_MissingFields(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -128,7 +131,7 @@ func TestRegister_InvalidInput_MissingFields(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", tt.body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", tt.body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } @@ -139,7 +142,8 @@ func TestRegister_InvalidInput_MissingFields(t *testing.T) { // TestRegister_InvalidInput_InvalidEmail verifies that registration fails // with HTTP 400 when an invalid email format is provided (e.g., "not-an-email"). func TestRegister_InvalidInput_InvalidEmail(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -153,7 +157,7 @@ func TestRegister_InvalidInput_InvalidEmail(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -163,7 +167,8 @@ func TestRegister_InvalidInput_InvalidEmail(t *testing.T) { // TestRegister_InvalidInput_InvalidPhone tests that registration fails // with HTTP 400 when an invalid UK phone number is provided (e.g., too short). func TestRegister_InvalidInput_InvalidPhone(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -177,7 +182,7 @@ func TestRegister_InvalidInput_InvalidPhone(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -187,7 +192,8 @@ func TestRegister_InvalidInput_InvalidPhone(t *testing.T) { // TestRegister_ValidUKPhoneNumbers verifies that registration accepts all // valid UK mobile phone formats including 07x numbers and E.164 format (+447...). func TestRegister_ValidUKPhoneNumbers(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -219,7 +225,7 @@ func TestRegister_ValidUKPhoneNumbers(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for %s, got %d. body: %s", tc.phone, w.Code, w.Body.String()) @@ -232,7 +238,8 @@ func TestRegister_ValidUKPhoneNumbers(t *testing.T) { // invalid phone numbers including too short, invalid formats, US numbers, and // numbers with special characters. func TestRegister_InvalidPhoneNumbers(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -261,7 +268,7 @@ func TestRegister_InvalidPhoneNumbers(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for invalid phone %s, got %d. body: %s", tc.phone, w.Code, w.Body.String()) @@ -273,7 +280,8 @@ func TestRegister_InvalidPhoneNumbers(t *testing.T) { // TestRegister_InvalidInput_Under16 tests that users under 16 years old cannot // register. The system enforces a minimum age of 16 for account creation. func TestRegister_InvalidInput_Under16(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -290,7 +298,7 @@ func TestRegister_InvalidInput_Under16(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -300,12 +308,13 @@ func TestRegister_InvalidInput_Under16(t *testing.T) { // TestRegister_DuplicateEmail verifies that attempting to register with // an email that already exists returns HTTP 409 Conflict. func TestRegister_DuplicateEmail(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) // First create a user with specific email - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'user@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') `) @@ -324,7 +333,7 @@ func TestRegister_DuplicateEmail(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) @@ -338,29 +347,24 @@ func TestRegister_DuplicateEmail(t *testing.T) { // TestLogin_Success tests that an existing user can successfully log in // with correct email and password, receiving a JWT token in the response. func TestLogin_Success(t *testing.T) { - resetTestData(t) + ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) // Create a test user with known email - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "user@test.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "user@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - loginStateMu.Lock() - for k := range loginInProgress { - delete(loginInProgress, k) - } - loginStateMu.Unlock() body := LoginRequest{ Email: "user@test.com", Password: "testpassword123", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -381,23 +385,23 @@ func TestLogin_Success(t *testing.T) { // TestLogin_InvalidCredentials_WrongPassword verifies that login fails with // HTTP 401 when the correct email exists but the password is incorrect. func TestLogin_InvalidCredentials_WrongPassword(t *testing.T) { - resetTestData(t) + ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) body := LoginRequest{ Email: "user@test.com", Password: "wrongpassword", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) @@ -407,7 +411,7 @@ func TestLogin_InvalidCredentials_WrongPassword(t *testing.T) { // TestLogin_InvalidCredentials_NonExistentEmail verifies that login fails // with HTTP 401 when the email does not exist in the database. func TestLogin_InvalidCredentials_NonExistentEmail(t *testing.T) { - resetTestData(t) + ctx, _ := resetTestData(t) handler := http.HandlerFunc(LoginHandler) @@ -416,7 +420,7 @@ func TestLogin_InvalidCredentials_NonExistentEmail(t *testing.T) { Password: "password123", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) @@ -430,16 +434,17 @@ func TestLogin_InvalidCredentials_NonExistentEmail(t *testing.T) { // TestRefreshToken_Success tests that a valid JWT token can be refreshed // to obtain a new token with extended expiry. func TestRefreshToken_Success(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(RefreshTokenHandler) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Generate a valid token token := jwt.GenerateTestToken(userID, "verified_email") @@ -449,10 +454,10 @@ func TestRefreshToken_Success(t *testing.T) { w := httptest.NewRecorder() // Use the middleware keys to set up context (matching what mw.RequireAuth does) - ctx := req.Context() - ctx = context.WithValue(ctx, mw.UserIDKey, userID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") - req = req.WithContext(ctx) + reqCtx := ctx + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + req = req.WithContext(reqCtx) handler.ServeHTTP(w, req) @@ -475,7 +480,8 @@ func TestRefreshToken_Success(t *testing.T) { // TestRefreshToken_Unauthorized_NoToken verifies that attempting to refresh // a token without providing one results in HTTP 401 Unauthorized. func TestRefreshToken_Unauthorized_NoToken(t *testing.T) { - resetTestData(t) + t.Parallel() + _, _ = resetTestData(t) handler := http.HandlerFunc(RefreshTokenHandler) @@ -499,22 +505,23 @@ func TestRefreshToken_Unauthorized_NoToken(t *testing.T) { // generated for an existing user email. The code is stored in the database // for subsequent verification. func TestVerifyGenerate_ValidEmail(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(GenerateVerificationCodeHandler) // Create a test user with known email - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "user@test.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "user@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) body := VerificationCodeRequest{ Email: "user@test.com", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -531,21 +538,22 @@ func TestVerifyGenerate_ValidEmail(t *testing.T) { // Verify a code was created in DB var codeID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT id FROM verification_codes WHERE user_id = $1", userID).Scan(&codeID) if err != nil { t.Errorf("failed to find verification code in DB: %v", err) } // Clean up - db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID) + tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) } // TestVerifyGenerate_NonExistentEmail verifies that the verification code // generation endpoint returns HTTP 200 even for non-existent emails. This is // a security measure to prevent email enumeration attacks. func TestVerifyGenerate_NonExistentEmail(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(GenerateVerificationCodeHandler) @@ -554,7 +562,7 @@ func TestVerifyGenerate_NonExistentEmail(t *testing.T) { Email: "nonexistent@test.com", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -579,33 +587,34 @@ func TestVerifyGenerate_NonExistentEmail(t *testing.T) { // verification code successfully verifies a user's email and updates their // account role from unverified_email to verified_email. func TestVerifyCheck_ValidCode(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Create a verification code var code string expiresAt := time.Now().Add(24 * time.Hour) - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } - defer db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID) + defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) body := VerifyCodeRequest{ Code: code, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -622,7 +631,7 @@ func TestVerifyCheck_ValidCode(t *testing.T) { // Verify code is marked as used var usedAt *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT used_at FROM verification_codes WHERE code = $1", code).Scan(&usedAt) if err != nil || usedAt == nil { t.Error("expected verification code to be marked as used") @@ -632,7 +641,8 @@ func TestVerifyCheck_ValidCode(t *testing.T) { // TestVerifyCheck_InvalidCode verifies that attempting to verify with // a non-existent code returns HTTP 400 Bad Request. func TestVerifyCheck_InvalidCode(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) @@ -640,7 +650,7 @@ func TestVerifyCheck_InvalidCode(t *testing.T) { Code: "nonexistent-code-12345", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -650,33 +660,34 @@ func TestVerifyCheck_InvalidCode(t *testing.T) { // TestVerifyCheck_ExpiredCode tests that verification fails with HTTP 400 // when the code has expired (past its expires_at timestamp). func TestVerifyCheck_ExpiredCode(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Create an expired verification code var code string expiresAt := time.Now().Add(-1 * time.Hour) // Expired 1 hour ago - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } - defer db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID) + defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) body := VerifyCodeRequest{ Code: code, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -690,7 +701,7 @@ func TestVerifyCheck_ExpiredCode(t *testing.T) { // TestLogin_InvalidRequest verifies that sending malformed JSON to the login // endpoint returns HTTP 400 Bad Request. func TestLogin_InvalidRequest(t *testing.T) { - resetTestData(t) + _, _ = resetTestData(t) handler := http.HandlerFunc(LoginHandler) @@ -708,7 +719,8 @@ func TestLogin_InvalidRequest(t *testing.T) { // TestRegister_NameTooLong tests that registration fails when the first name // exceeds 50 characters (the maximum allowed length). func TestRegister_NameTooLong(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -724,7 +736,7 @@ func TestRegister_NameTooLong(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -734,7 +746,8 @@ func TestRegister_NameTooLong(t *testing.T) { // TestRegister_InvalidNameCharacters verifies that registration fails when // names contain invalid characters (e.g., numbers). func TestRegister_InvalidNameCharacters(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -749,7 +762,7 @@ func TestRegister_InvalidNameCharacters(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -759,39 +772,40 @@ func TestRegister_InvalidNameCharacters(t *testing.T) { // TestVerifyCheck_AlreadyUsed tests that attempting to verify with a code // that has already been used returns HTTP 403 Forbidden. func TestVerifyCheck_AlreadyUsed(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Create a verification code var code string expiresAt := time.Now().Add(24 * time.Hour) - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } - defer db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID) + defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) // First verification should succeed body := VerifyCodeRequest{ Code: code, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusOK { t.Errorf("first verification: expected status 200, got %d", w.Code) } // Second verification with same code should return 403 (already used) - w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body) + w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusForbidden { t.Errorf("second verification: expected status 403, got %d", w.Code) } @@ -801,20 +815,21 @@ func TestVerifyCheck_AlreadyUsed(t *testing.T) { // verification, the user's account_role changes from unverified_email to // verified_email, granting them full account access. func TestVerifyCheck_RoleChangeToVerified(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(VerifyCodeHandler) // Create an unverified user - userID, err := fixtures.CreateTestUnverifiedUser(db.DB) + userID, err := fixtures.CreateTestUnverifiedUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Verify initial role is unverified_email var initialRole string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT account_role FROM users WHERE id = $1", userID).Scan(&initialRole) if err != nil { t.Fatalf("failed to check initial role: %v", err) @@ -826,26 +841,26 @@ func TestVerifyCheck_RoleChangeToVerified(t *testing.T) { // Create a verification code var code string expiresAt := time.Now().Add(24 * time.Hour) - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`, userID, expiresAt).Scan(&code) if err != nil { t.Fatalf("failed to create verification code: %v", err) } - defer db.DB.Exec(context.Background(), "DELETE FROM verification_codes WHERE user_id = $1", userID) + defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID) // Verify the code body := VerifyCodeRequest{ Code: code, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Check that user's role changed to verified_email var newRole string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT account_role FROM users WHERE id = $1", userID).Scan(&newRole) if err != nil { t.Errorf("failed to check new role: %v", err) @@ -863,7 +878,8 @@ func TestVerifyCheck_RoleChangeToVerified(t *testing.T) { // a minimum password length of 6 characters (new requirement from security pass). // bcrypt handles passwords up to 72 chars internally (truncates longer ones). func TestRegister_PasswordLength_Minimum(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -916,7 +932,7 @@ func TestRegister_PasswordLength_Minimum(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if tt.expectError { if w.Code == http.StatusCreated { @@ -934,7 +950,8 @@ func TestRegister_PasswordLength_Minimum(t *testing.T) { // TestRegister_EmptyPassword verifies that an empty password is rejected // because it's a required field (not because of minimum length). func TestRegister_EmptyPassword(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -948,7 +965,7 @@ func TestRegister_EmptyPassword(t *testing.T) { AgreedToPolicy: true, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) // Empty password fails because it's a required field if w.Code != http.StatusBadRequest { @@ -960,20 +977,21 @@ func TestRegister_EmptyPassword(t *testing.T) { // a valid existing referral code is provided, and the referral relationship // is recorded in the user_referrals table. func TestRegister_WithValidReferralCode(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) // Create a referrer user with a known referral code - referrerID, err := fixtures.CreateTestUserWithEmail(db.DB, "referrer@test.com", "verified_email") + referrerID, err := fixtures.CreateTestUserWithEmail(tx, "referrer@test.com", "verified_email") if err != nil { t.Fatalf("failed to create referrer user: %v", err) } - defer fixtures.DeleteUser(db.DB, referrerID) + defer fixtures.DeleteUser(tx, referrerID) // Set a known referral code for the referrer knownCode := "abc123def456" - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE users SET referral_code = $1 WHERE id = $2", knownCode, referrerID) if err != nil { t.Fatalf("failed to set referral code: %v", err) @@ -990,7 +1008,7 @@ func TestRegister_WithValidReferralCode(t *testing.T) { ReferralCode: knownCode, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -998,15 +1016,15 @@ func TestRegister_WithValidReferralCode(t *testing.T) { // Verify referral relationship was created var referredID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", "referred@test.com").Scan(&referredID) if err != nil { t.Fatalf("failed to find referred user: %v", err) } - defer fixtures.DeleteUser(db.DB, referredID) + defer fixtures.DeleteUser(tx, referredID) var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 AND referred_id = $2", referrerID, referredID).Scan(&count) if err != nil { @@ -1020,7 +1038,8 @@ func TestRegister_WithValidReferralCode(t *testing.T) { // TestRegister_WithInvalidReferralCode verifies that registration fails with // 400 when a non-existent referral code is provided. func TestRegister_WithInvalidReferralCode(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -1035,7 +1054,7 @@ func TestRegister_WithInvalidReferralCode(t *testing.T) { ReferralCode: "nonexistent1234", // 12 chars but doesn't exist } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -1045,7 +1064,8 @@ func TestRegister_WithInvalidReferralCode(t *testing.T) { // TestRegister_WithInvalidReferralCodeFormat verifies that registration fails // when the referral code is not exactly 12 characters. func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) @@ -1072,7 +1092,7 @@ func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) { ReferralCode: tt.code, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for %s (%s), got %d. body: %s", tt.name, tt.desc, w.Code, w.Body.String()) @@ -1084,19 +1104,20 @@ func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) { // TestRegister_ReferralCodeCaseInsensitive verifies that referral codes with // uppercase letters are accepted and correctly matched against lowercase stored codes. func TestRegister_ReferralCodeCaseInsensitive(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) handler := http.HandlerFunc(RegisterHandler) // Create a referrer user with a known referral code (lowercase hex) - referrerID, err := fixtures.CreateTestUserWithEmail(db.DB, "referrer-case@test.com", "verified_email") + referrerID, err := fixtures.CreateTestUserWithEmail(tx, "referrer-case@test.com", "verified_email") if err != nil { t.Fatalf("failed to create referrer user: %v", err) } - defer fixtures.DeleteUser(db.DB, referrerID) + defer fixtures.DeleteUser(tx, referrerID) knownCode := "abc123def456" - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE users SET referral_code = $1 WHERE id = $2", knownCode, referrerID) if err != nil { t.Fatalf("failed to set referral code: %v", err) @@ -1126,7 +1147,7 @@ func TestRegister_ReferralCodeCaseInsensitive(t *testing.T) { ReferralCode: tt.inputCode, } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/register", body, ctx) if w.Code != tt.wantStatus { t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.wantStatus, w.Code, w.Body.String()) @@ -1135,15 +1156,15 @@ func TestRegister_ReferralCodeCaseInsensitive(t *testing.T) { if tt.wantStatus == http.StatusCreated { // Verify referral relationship was created var referredID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", fmt.Sprintf("case-test-%s@test.com", tt.name)).Scan(&referredID) if err != nil { t.Fatalf("%s: failed to find referred user: %v", tt.name, err) } - defer fixtures.DeleteUser(db.DB, referredID) + defer fixtures.DeleteUser(tx, referredID) var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 AND referred_id = $2", referrerID, referredID).Scan(&count) if err != nil { @@ -1164,14 +1185,15 @@ func TestRegister_ReferralCodeCaseInsensitive(t *testing.T) { // TestLogoutHandler_Success verifies that a valid logout request returns // 200 OK with {"success": true} and revokes the JTI. func TestLogoutHandler_Success(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Generate a token and get its JTI token, jti, err := auth.GenerateToken(userID, "verified_email") @@ -1182,8 +1204,8 @@ func TestLogoutHandler_Success(t *testing.T) { // Create request with JTI in context (simulating RequireAuth middleware) req := httptest.NewRequest("POST", "/api/logout", nil) req.Header.Set("Authorization", "Bearer "+token) - ctx := context.WithValue(req.Context(), mw.JTIKey, jti) - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, mw.JTIKey, jti) + req = req.WithContext(reqCtx) w := httptest.NewRecorder() LogoutHandler(w, req) @@ -1203,7 +1225,7 @@ func TestLogoutHandler_Success(t *testing.T) { } // Verify JTI was revoked - if !auth.IsJTIRevoked(jti) { + if !auth.IsJTIRevoked(ctx, jti) { t.Error("expected JTI to be revoked after logout") } } @@ -1211,14 +1233,15 @@ func TestLogoutHandler_Success(t *testing.T) { // TestLogoutHandler_RevokesJTI verifies that after logout, the token's JTI is // revoked and the token can no longer be used with authenticated endpoints. func TestLogoutHandler_RevokesJTI(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Generate a token and get its JTI token, jti, err := auth.GenerateToken(userID, "verified_email") @@ -1229,8 +1252,8 @@ func TestLogoutHandler_RevokesJTI(t *testing.T) { // Call logout with JTI in context req := httptest.NewRequest("POST", "/api/logout", nil) req.Header.Set("Authorization", "Bearer "+token) - ctx := context.WithValue(req.Context(), mw.JTIKey, jti) - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, mw.JTIKey, jti) + req = req.WithContext(reqCtx) w := httptest.NewRecorder() LogoutHandler(w, req) @@ -1245,6 +1268,7 @@ func TestLogoutHandler_RevokesJTI(t *testing.T) { }) req2 := httptest.NewRequest("GET", "/api/protected", nil) + req2 = req2.WithContext(ctx) req2.Header.Set("Authorization", "Bearer "+token) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) @@ -1257,7 +1281,8 @@ func TestLogoutHandler_RevokesJTI(t *testing.T) { // TestLogoutHandler_NoToken verifies that calling logout without an // Authorization header returns 401. func TestLogoutHandler_NoToken(t *testing.T) { - resetTestData(t) + t.Parallel() + _, _ = resetTestData(t) req := httptest.NewRequest("POST", "/api/logout", nil) w := httptest.NewRecorder() @@ -1273,11 +1298,12 @@ func TestLogoutHandler_NoToken(t *testing.T) { // TestLogoutHandler_InvalidToken verifies that calling logout with an empty // JTI returns 401. func TestLogoutHandler_InvalidToken(t *testing.T) { - resetTestData(t) + t.Parallel() + _, _ = resetTestData(t) req := httptest.NewRequest("POST", "/api/logout", nil) - ctx := context.WithValue(req.Context(), mw.JTIKey, "") - req = req.WithContext(ctx) + reqCtx := context.WithValue(req.Context(), mw.JTIKey, "") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() LogoutHandler(w, req) @@ -1294,14 +1320,15 @@ func TestLogoutHandler_InvalidToken(t *testing.T) { // TestRefreshToken_RevokesOldJTI verifies that refreshing a token revokes the // old JTI and issues a new one. The old token becomes invalid after refresh. func TestRefreshToken_RevokesOldJTI(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) // Create a test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Generate initial token and JTI oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email") @@ -1310,18 +1337,18 @@ func TestRefreshToken_RevokesOldJTI(t *testing.T) { } // Verify old JTI is not yet revoked - if auth.IsJTIRevoked(oldJTI) { + if auth.IsJTIRevoked(ctx, oldJTI) { t.Fatal("old JTI should not be revoked before refresh") } // Call refresh handler with old JTI in context req := httptest.NewRequest("POST", "/api/refresh-token", nil) req.Header.Set("Authorization", "Bearer "+oldToken) - ctx := req.Context() - ctx = context.WithValue(ctx, mw.UserIDKey, userID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") - ctx = context.WithValue(ctx, mw.JTIKey, oldJTI) - req = req.WithContext(ctx) + reqCtx := ctx + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + reqCtx = context.WithValue(reqCtx, mw.JTIKey, oldJTI) + req = req.WithContext(reqCtx) w := httptest.NewRecorder() RefreshTokenHandler(w, req) @@ -1331,7 +1358,7 @@ func TestRefreshToken_RevokesOldJTI(t *testing.T) { } // Verify old JTI was revoked - if !auth.IsJTIRevoked(oldJTI) { + if !auth.IsJTIRevoked(ctx, oldJTI) { t.Error("expected old JTI to be revoked after refresh") } @@ -1342,6 +1369,7 @@ func TestRefreshToken_RevokesOldJTI(t *testing.T) { }) req2 := httptest.NewRequest("GET", "/api/protected", nil) + req2 = req2.WithContext(ctx) req2.Header.Set("Authorization", "Bearer "+oldToken) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) @@ -1358,29 +1386,24 @@ func TestRefreshToken_RevokesOldJTI(t *testing.T) { // TestLoginResponse_IncludesJTI verifies that the login response includes both // "token" and "jti" fields, both non-empty. func TestLoginResponse_IncludesJTI(t *testing.T) { - resetTestData(t) + ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) // Create a test user with known email - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jti-test@test.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "jti-test@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - loginStateMu.Lock() - for k := range loginInProgress { - delete(loginInProgress, k) - } - loginStateMu.Unlock() body := LoginRequest{ Email: "jti-test@test.com", Password: "testpassword123", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1409,16 +1432,16 @@ func TestLoginResponse_IncludesJTI(t *testing.T) { // TestLoginInProgress_Cap verifies that when the loginInProgress map is full // (20 concurrent logins), the 21st attempt returns HTTP 429 Too Many Requests. func TestLoginInProgress_Cap(t *testing.T) { - resetTestData(t) + ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) // Create a test user - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "ratelimit@test.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "ratelimit@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Fill the loginInProgress map with 20 entries loginStateMu.Lock() @@ -1442,7 +1465,7 @@ func TestLoginInProgress_Cap(t *testing.T) { Password: "testpassword123", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusTooManyRequests { t.Errorf("expected status 429, got %d. body: %s", w.Code, w.Body.String()) @@ -1455,6 +1478,7 @@ func TestLoginInProgress_Cap(t *testing.T) { // injection payloads (SQLi, XSS, command injection, control characters). func TestValidateUKPhoneNumber_RejectsPureInjectionPayloads(t *testing.T) { + t.Parallel() payloads := []string{ // SQL injection "' OR '1'='1", @@ -1485,6 +1509,7 @@ func TestValidateUKPhoneNumber_RejectsPureInjectionPayloads(t *testing.T) { } func TestValidateUKPhoneNumber_RejectsMixedInjectionPayloads(t *testing.T) { + t.Parallel() // When injection characters are interleaved with a valid UK phone number, // libphonenumber rejects the entire input — it does NOT try to extract // digits from non-numeric characters. This is MORE secure than naive @@ -1510,23 +1535,23 @@ func TestValidateUKPhoneNumber_RejectsMixedInjectionPayloads(t *testing.T) { // TestLogin_AccountLockout_After5Failures verifies that after 5 failed login // attempts, the account is locked and the next login attempt returns HTTP 429. func TestLogin_AccountLockout_After5Failures(t *testing.T) { - resetTestData(t) + ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "user@test.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "user@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - defer db.DB.Exec(context.Background(), "UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1", userID) + defer fixtures.DeleteUser(tx, userID) + defer tx.Exec(ctx, "UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1", userID) for i := 0; i < 5; i++ { body := LoginRequest{ Email: "user@test.com", Password: "wrongpassword", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusUnauthorized { t.Fatalf("attempt %d: expected 401, got %d", i+1, w.Code) } @@ -1536,14 +1561,14 @@ func TestLogin_AccountLockout_After5Failures(t *testing.T) { Email: "user@test.com", Password: "wrongpassword", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusTooManyRequests { t.Errorf("expected 429 after 5 failures, got %d. body: %s", w.Code, w.Body.String()) } var failedAttempts int var lockedUntil *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil) if err != nil { t.Fatalf("failed to query lockout state: %v", err) @@ -1559,41 +1584,36 @@ func TestLogin_AccountLockout_After5Failures(t *testing.T) { // TestLogin_AccountLockout_ResetsOnSuccess verifies that a successful login // resets the failed_attempts counter and clears the locked_until. func TestLogin_AccountLockout_ResetsOnSuccess(t *testing.T) { - resetTestData(t) + ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "lockout-reset@test.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "lockout-reset@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE users SET failed_attempts = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set failed_attempts: %v", err) } - loginStateMu.Lock() - for k := range loginInProgress { - delete(loginInProgress, k) - } - loginStateMu.Unlock() body := LoginRequest{ Email: "lockout-reset@test.com", Password: "testpassword123", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var failedAttempts int var lockedUntil *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil) if err != nil { t.Fatalf("failed to query lockout state: %v", err) @@ -1613,6 +1633,7 @@ func TestLogin_AccountLockout_ResetsOnSuccess(t *testing.T) { // TestJWT_ExpiryIsOneHour verifies that generated JWTs have a 1-hour expiry // (changed from 30 days during security pass). func TestJWT_ExpiryIsOneHour(t *testing.T) { + t.Parallel() userID := "test-user-id" role := "verified_email" @@ -1645,15 +1666,16 @@ func TestJWT_ExpiryIsOneHour(t *testing.T) { // TestRefreshToken_Generation verifies that a refresh token can be generated // and stored in the database. This requires DB access. func TestRefreshToken_Generation(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - refreshToken, err := auth.GenerateRefreshToken(userID, "verified_email") + refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email") if err != nil { t.Fatalf("failed to generate refresh token: %v", err) } @@ -1662,7 +1684,7 @@ func TestRefreshToken_Generation(t *testing.T) { } var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Fatalf("failed to query refresh_tokens: %v", err) @@ -1671,7 +1693,7 @@ func TestRefreshToken_Generation(t *testing.T) { t.Errorf("expected 1 refresh_token, got %d", count) } - retrievedUserID, retrievedRole, err := auth.VerifyRefreshToken(context.Background(), refreshToken) + retrievedUserID, retrievedRole, err := auth.VerifyRefreshToken(ctx, refreshToken) if err != nil { t.Fatalf("failed to verify refresh token: %v", err) } @@ -1682,7 +1704,7 @@ func TestRefreshToken_Generation(t *testing.T) { t.Errorf("expected role 'verified_email', got %q", retrievedRole) } - _, _, err = auth.VerifyRefreshToken(context.Background(), refreshToken) + _, _, err = auth.VerifyRefreshToken(ctx, refreshToken) if err == nil { t.Error("expected error on second refresh token verification (rotated)") } @@ -1691,35 +1713,36 @@ func TestRefreshToken_Generation(t *testing.T) { // TestJTI_Revocation_PostgreSQL verifies that JTI revocation uses the // PostgreSQL revoked_jtis table and persists across operations. func TestJTI_Revocation_PostgreSQL(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) token, jti, err := auth.GenerateToken(userID, "verified_email") if err != nil { t.Fatalf("failed to generate token: %v", err) } - if auth.IsJTIRevoked(jti) { + if auth.IsJTIRevoked(ctx, jti) { t.Fatal("JTI should not be revoked before we revoke it") } - _, _, _, err = auth.VerifyToken(token, context.Background()) + _, _, _, err = auth.VerifyToken(token, ctx) if err != nil { t.Fatalf("token should be valid before revocation: %v", err) } - auth.RevokeJTI(jti, time.Now().Add(1*time.Hour)) + auth.RevokeJTI(ctx, jti, time.Now().Add(1*time.Hour)) - if !auth.IsJTIRevoked(jti) { + if !auth.IsJTIRevoked(ctx, jti) { t.Error("JTI should be revoked after RevokeJTI call") } - _, _, _, err = auth.VerifyToken(token, context.Background()) + _, _, _, err = auth.VerifyToken(token, ctx) if err == nil { t.Error("VerifyToken should fail for revoked JTI") } @@ -1732,28 +1755,23 @@ func TestJTI_Revocation_PostgreSQL(t *testing.T) { // TestLogin_ResponseIncludesRefreshToken verifies the login response // includes a refreshToken field alongside the JWT. func TestLogin_ResponseIncludesRefreshToken(t *testing.T) { - resetTestData(t) + ctx, tx := resetTestData(t) handler := http.HandlerFunc(LoginHandler) - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "refresh-check@test.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "refresh-check@test.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - loginStateMu.Lock() - for k := range loginInProgress { - delete(loginInProgress, k) - } - loginStateMu.Unlock() body := LoginRequest{ Email: "refresh-check@test.com", Password: "testpassword123", } - w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body) + w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1782,30 +1800,31 @@ func TestLogin_ResponseIncludesRefreshToken(t *testing.T) { // TestRefreshToken_RevokesOldJTI_DBBacked verifies refresh still revokes old // JTI and the revocation is persisted in the revoked_jtis table. func TestRefreshToken_RevokesOldJTI_DBBacked(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email") if err != nil { t.Fatalf("failed to generate old token: %v", err) } - if auth.IsJTIRevoked(oldJTI) { + if auth.IsJTIRevoked(ctx, oldJTI) { t.Fatal("old JTI should not be revoked before refresh") } req := httptest.NewRequest("POST", "/api/refresh-token", nil) req.Header.Set("Authorization", "Bearer "+oldToken) - ctx := req.Context() - ctx = context.WithValue(ctx, mw.UserIDKey, userID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") - ctx = context.WithValue(ctx, mw.JTIKey, oldJTI) - req = req.WithContext(ctx) + reqCtx := ctx + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + reqCtx = context.WithValue(reqCtx, mw.JTIKey, oldJTI) + req = req.WithContext(reqCtx) w := httptest.NewRecorder() RefreshTokenHandler(w, req) @@ -1814,12 +1833,12 @@ func TestRefreshToken_RevokesOldJTI_DBBacked(t *testing.T) { t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String()) } - if !auth.IsJTIRevoked(oldJTI) { + if !auth.IsJTIRevoked(ctx, oldJTI) { t.Error("expected old JTI to be revoked after refresh (DB-backed)") } var dbCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM revoked_jtis WHERE jti = $1 AND expires_at > NOW()", oldJTI).Scan(&dbCount) if err != nil { t.Fatalf("failed to query revoked_jtis: %v", err) diff --git a/backend/handlers/auth/testmain_test.go b/backend/handlers/auth/testmain_test.go index 5ac80a5..93bb8bc 100644 --- a/backend/handlers/auth/testmain_test.go +++ b/backend/handlers/auth/testmain_test.go @@ -15,9 +15,10 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_auth") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() dav.Service = &dav.BaseService{} + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_auth") os.Exit(code) diff --git a/backend/handlers/bookings/admin_reserve_test.go b/backend/handlers/bookings/admin_reserve_test.go index 3e1ec88..7706473 100644 --- a/backend/handlers/bookings/admin_reserve_test.go +++ b/backend/handlers/bookings/admin_reserve_test.go @@ -19,7 +19,6 @@ import ( "testing" "time" - "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" @@ -29,7 +28,7 @@ import ( // makeAdminReserveRequest creates a request with admin context for admin reserve slot handler // It sets mw.UserIDKey and mw.UserRoleKey to "admin" in the context -func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID string) *httptest.ResponseRecorder { +func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID string, requestCtx ...context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -39,8 +38,13 @@ func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID str req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", nil) } + baseCtx := req.Context() + if len(requestCtx) > 0 { + baseCtx = requestCtx[0] + } + rctx := chi.NewRouteContext() - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, adminID) ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") @@ -60,15 +64,15 @@ func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID str // create a walk-in reservation with a valid duration. The test verifies // the reservation is created in the database with the correct duration. func TestAdminReserveSlot_WalkIn_Success(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) tomorrow := time.Now().Add(24 * time.Hour) now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location()) @@ -80,7 +84,7 @@ func TestAdminReserveSlot_WalkIn_Success(t *testing.T) { } handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -98,7 +102,7 @@ func TestAdminReserveSlot_WalkIn_Success(t *testing.T) { // Verify time_blocker was created with correct description pattern var desc string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'", ).Scan(&desc) if err != nil { @@ -118,29 +122,29 @@ func TestAdminReserveSlot_WalkIn_Success(t *testing.T) { // create a call-in reservation with valid service IDs. The test verifies // the reservation duration matches the service duration. func TestAdminReserveSlot_CallIn_Success(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - _, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 30 WHERE id = $1", serviceID) + _, err = tx.Exec(ctx, "UPDATE services SET duration_minutes = 30 WHERE id = $1", serviceID) if err != nil { t.Fatalf("failed to update service duration: %v", err) } @@ -157,7 +161,7 @@ func TestAdminReserveSlot_CallIn_Success(t *testing.T) { } handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -175,7 +179,7 @@ func TestAdminReserveSlot_CallIn_Success(t *testing.T) { // Verify time_blocker was created with correct description pattern var desc string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'", ).Scan(&desc) if err != nil { @@ -194,15 +198,16 @@ func TestAdminReserveSlot_CallIn_Success(t *testing.T) { // TestAdminReserveSlot_WalkIn_MissingDuration tests that walk-in reservations // fail with HTTP 400 when duration_minutes is missing or zero. func TestAdminReserveSlot_WalkIn_MissingDuration(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) now := time.Now() req := AdminReserveSlotRequest{ @@ -212,7 +217,7 @@ func TestAdminReserveSlot_WalkIn_MissingDuration(t *testing.T) { } handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -232,15 +237,16 @@ func TestAdminReserveSlot_WalkIn_MissingDuration(t *testing.T) { // TestAdminReserveSlot_CallIn_MissingServices tests that call-in reservations // fail with HTTP 400 when service_ids is empty. func TestAdminReserveSlot_CallIn_MissingServices(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second) tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location()) @@ -253,7 +259,7 @@ func TestAdminReserveSlot_CallIn_MissingServices(t *testing.T) { } handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -273,15 +279,16 @@ func TestAdminReserveSlot_CallIn_MissingServices(t *testing.T) { // TestAdminReserveSlot_InvalidReservationType tests that reservations // fail with HTTP 400 when reservation_type is invalid. func TestAdminReserveSlot_InvalidReservationType(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) req := AdminReserveSlotRequest{ ReservationType: "invalid", @@ -290,7 +297,7 @@ func TestAdminReserveSlot_InvalidReservationType(t *testing.T) { } handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -310,32 +317,33 @@ func TestAdminReserveSlot_InvalidReservationType(t *testing.T) { // TestAdminReserveSlot_SlotOverlap tests that a reservation fails // with HTTP 409 when the slot overlaps with an existing booking. func TestAdminReserveSlot_SlotOverlap(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + // Create test admin user (for the booking) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) // Create test regular user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Set deposits_required=0 for test user - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -343,13 +351,13 @@ func TestAdminReserveSlot_SlotOverlap(t *testing.T) { tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second) tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location()) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET start_time = $1, status = 'confirmed' WHERE id = $2", tomorrow, bookingID) if err != nil { @@ -367,7 +375,7 @@ func TestAdminReserveSlot_SlotOverlap(t *testing.T) { } handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) // Should return 409 Conflict due to overlap if w.Code != http.StatusConflict { @@ -382,15 +390,16 @@ func TestAdminReserveSlot_SlotOverlap(t *testing.T) { // TestAdminReserveSlot_ReplacesExisting tests that reserving twice // on the same admin replaces the previous reservation. func TestAdminReserveSlot_ReplacesExisting(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) tomorrow := time.Now().Add(24 * time.Hour) now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location()) @@ -404,7 +413,7 @@ func TestAdminReserveSlot_ReplacesExisting(t *testing.T) { // First reservation handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -418,7 +427,7 @@ func TestAdminReserveSlot_ReplacesExisting(t *testing.T) { // Count reservations before second request var countBefore int var qerr error - qerr = db.DB.QueryRow(context.Background(), + qerr = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'", ).Scan(&countBefore) if qerr != nil { @@ -434,7 +443,7 @@ func TestAdminReserveSlot_ReplacesExisting(t *testing.T) { TTLMinutes: 15, } - w = makeAdminReserveRequest(handler, req2, adminID) + w = makeAdminReserveRequest(handler, req2, adminID, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -447,7 +456,7 @@ func TestAdminReserveSlot_ReplacesExisting(t *testing.T) { // Count reservations after second request - should still be 1 var countAfter int - qerr = db.DB.QueryRow(context.Background(), + qerr = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'", ).Scan(&countAfter) if qerr != nil { @@ -470,15 +479,16 @@ func TestAdminReserveSlot_ReplacesExisting(t *testing.T) { // TestAdminReserveSlot_WalkIn_PastStart tests that walk-in reservations func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - defer fixtures.DeleteUser(db.DB, adminID) + defer fixtures.DeleteUser(tx, adminID) pastTime := time.Now().Add(-5 * time.Minute) req := AdminReserveSlotRequest{ @@ -489,7 +499,7 @@ func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) { } handler := http.HandlerFunc(AdminReserveSlotHandler) - w := makeAdminReserveRequest(handler, req, adminID) + w := makeAdminReserveRequest(handler, req, adminID, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index b4acbe6..2172453 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -37,42 +37,8 @@ import ( "crussell/testutils/jwt" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" ) -// seedDefaultWorkingHours seeds default working hours for tests -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) - } - } -} - // nextWorkingHour returns a time within working hours (08:00-19:00) that is // always < 24h from now. This avoids time-of-day flakiness in tests that need // a booking within the 24-hour no-show window. Working hours are 08:00-20:00; @@ -91,12 +57,14 @@ func nextWorkingHour() time.Time { // helper function to make JSON request with JWT auth // For authenticated requests, use makeAuthRequest which extracts user from JWT -func makeRequest(handler http.Handler, method, path string, body interface{}, token string) *httptest.ResponseRecorder { - return makeAuthRequest(handler, method, path, body, token, "") +func makeRequest(handler http.Handler, method, path string, body interface{}, token string, requestCtx ...context.Context) *httptest.ResponseRecorder { + return makeAuthRequest(handler, method, path, body, token, "", requestCtx...) } -// makeAuthRequest creates request with optional JWT auth and userID override -func makeAuthRequest(handler http.Handler, method, path string, body interface{}, token, userIDOverride string) *httptest.ResponseRecorder { +// makeAuthRequest creates request with optional JWT auth and userID override. +// An optional requestCtx can be provided to carry a per-test transaction +// (from SetupTestTx) for PoolProxy routing. If nil, context.Background() is used. +func makeAuthRequest(handler http.Handler, method, path string, body interface{}, token, userIDOverride string, requestCtx ...context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -109,12 +77,18 @@ func makeAuthRequest(handler http.Handler, method, path string, body interface{} req.Header.Set("Authorization", "Bearer "+token) } + // Use provided context (carries per-test tx) or fall back to req.Context() + baseCtx := req.Context() + if len(requestCtx) > 0 { + baseCtx = requestCtx[0] + } + // Set up chi routing context for path params rctx := chi.NewRouteContext() if id, paramName := extractIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx) // Set user context - either from override or attempt to extract from token var userID, userRole string @@ -238,29 +212,30 @@ func parseResponseBody(w *httptest.ResponseRecorder, dest interface{}) error { // with a valid future time and at least one service. The test verifies the // booking is created in the database and associated with the correct user. func TestBookings_Create(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Seed working hours for booking tests - seedDefaultWorkingHours(t) + // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now (was 3) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Generate token for user token := jwt.GenerateUserToken(userID) @@ -275,7 +250,7 @@ func TestBookings_Create(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -283,7 +258,7 @@ func TestBookings_Create(t *testing.T) { // Verify booking was created in DB var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) @@ -293,7 +268,7 @@ func TestBookings_Create(t *testing.T) { } // Verify booking_services was created - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_services WHERE booking_id IN (SELECT id FROM bookings WHERE user_id = $1)", userID).Scan(&count) if err != nil { t.Errorf("failed to query booking_services: %v", err) @@ -306,17 +281,18 @@ func TestBookings_Create(t *testing.T) { // TestBookings_Create_InvalidInput verifies that booking creation fails // with HTTP 400 when required fields are missing: start time or service IDs. func TestBookings_Create_InvalidInput(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -351,7 +327,7 @@ func TestBookings_Create_InvalidInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", tt.req, token) + w := makeRequest(handler, "POST", "/api/bookings", tt.req, token, ctx) // Both missing start time and missing/empty service IDs should return 400 if w.Code != http.StatusBadRequest { @@ -369,38 +345,39 @@ func TestBookings_Create_InvalidInput(t *testing.T) { // The test verifies the response includes the correct total count and that // bookings are properly returned. func TestBookings_List(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetAllUserBookingsHandler) - w := makeRequest(handler, "GET", "/api/bookings", nil, token) + w := makeRequest(handler, "GET", "/api/bookings", nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -424,39 +401,40 @@ func TestBookings_List(t *testing.T) { // by status (e.g., pending, completed). It verifies that non-matching statuses // return empty results. func TestBookings_List_FilterByStatus(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a pending booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) // Test filtering by status handler := http.HandlerFunc(GetAllUserBookingsHandler) - w := makeRequest(handler, "GET", "/api/bookings?status=pending", nil, token) + w := makeRequest(handler, "GET", "/api/bookings?status=pending", nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -472,7 +450,7 @@ func TestBookings_List_FilterByStatus(t *testing.T) { } // Test filtering by non-matching status - w = makeRequest(handler, "GET", "/api/bookings?status=completed", nil, token) + w = makeRequest(handler, "GET", "/api/bookings?status=completed", nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -494,38 +472,39 @@ func TestBookings_List_FilterByStatus(t *testing.T) { // TestBookings_Get tests that a user can retrieve a single booking by its ID. // The test verifies the booking details including services are returned. func TestBookings_Get(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) - w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) + w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -548,17 +527,18 @@ func TestBookings_Get(t *testing.T) { // TestBookings_Get_NotFound verifies that requesting a non-existent booking // returns HTTP 404 Not Found. func TestBookings_Get_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -566,7 +546,7 @@ func TestBookings_Get_NotFound(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) - w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id", nil, token) + w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id", nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -577,39 +557,40 @@ func TestBookings_Get_NotFound(t *testing.T) { // booking. The test creates two users, one creates a booking, and the other // attempts to access it - expecting HTTP 404 (not found/access denied). func TestBookings_Get_AccessDenied(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create two test users - userID1, err := fixtures.CreateTestUser(db.DB) + userID1, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user 1: %v", err) } - defer fixtures.DeleteUser(db.DB, userID1) + defer fixtures.DeleteUser(tx, userID1) - userID2, err := fixtures.CreateTestUser(db.DB) + userID2, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user 2: %v", err) } - defer fixtures.DeleteUser(db.DB, userID2) + defer fixtures.DeleteUser(tx, userID2) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking for user1 - bookingID, err := fixtures.CreateTestBooking(db.DB, userID1, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID1, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Try to access with user2's token token := jwt.GenerateUserToken(userID2) handler := http.HandlerFunc(GetBookingHandler) - w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) + w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token, ctx) // Should return not found (or access denied) since user2 doesn't own the booking if w.Code != http.StatusNotFound { @@ -625,38 +606,39 @@ func TestBookings_Get_AccessDenied(t *testing.T) { // as an ICS calendar file. It verifies the response has the correct // text/calendar Content-Type and contains ICS-formatted data. func TestBookings_GetCalendar(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) - w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token) + w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -686,17 +668,18 @@ func TestBookings_GetCalendar(t *testing.T) { // TestBookings_GetCalendar_NotFound verifies that attempting to export // a non-existent booking to calendar returns HTTP 404. func TestBookings_GetCalendar_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -704,7 +687,7 @@ func TestBookings_GetCalendar_NotFound(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) - w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id/calendar", nil, token) + w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id/calendar", nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -718,33 +701,34 @@ func TestBookings_GetCalendar_NotFound(t *testing.T) { // TestBookings_Edit tests that a user can modify the start time of // their existing booking. The test verifies the time is updated in the DB. func TestBookings_Edit(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) @@ -755,7 +739,7 @@ func TestBookings_Edit(t *testing.T) { } handler := http.HandlerFunc(EditBookingHandler) - w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) + w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -768,7 +752,7 @@ func TestBookings_Edit(t *testing.T) { // Verify the start time was updated in DB var dbStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime) if err != nil { t.Errorf("failed to query booking: %v", err) @@ -783,33 +767,34 @@ func TestBookings_Edit(t *testing.T) { // TestBookings_Edit_InvalidInput verifies that editing fails with HTTP 400 // when the start time is missing or is in the past. func TestBookings_Edit_InvalidInput(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) @@ -832,7 +817,7 @@ func TestBookings_Edit_InvalidInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(EditBookingHandler) - w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, tt.req, token) + w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, tt.req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) @@ -844,17 +829,18 @@ func TestBookings_Edit_InvalidInput(t *testing.T) { // TestBookings_Edit_NotFound verifies that editing a non-existent // booking returns HTTP 404. func TestBookings_Edit_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -866,7 +852,7 @@ func TestBookings_Edit_NotFound(t *testing.T) { } handler := http.HandlerFunc(EditBookingHandler) - w := makeRequest(handler, "PUT", "/api/bookings/nonexistent-id", req, token) + w := makeRequest(handler, "PUT", "/api/bookings/nonexistent-id", req, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -881,29 +867,30 @@ func TestBookings_Edit_NotFound(t *testing.T) { // For bookings without payments, it performs a hard delete. The test verifies // the booking is removed from the database. func TestBookings_Delete(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking (without payments) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } @@ -912,7 +899,7 @@ func TestBookings_Delete(t *testing.T) { // Delete the booking handler := http.HandlerFunc(DeleteBookingHandler) - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -920,7 +907,7 @@ func TestBookings_Delete(t *testing.T) { // Verify booking was deleted from DB var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) @@ -936,37 +923,38 @@ func TestBookings_Delete(t *testing.T) { // admin_notifications.booking_id FK has no ON DELETE CASCADE, so the DELETE must // explicitly clean up notifications first. func TestBookings_Delete_WithAdminNotifications(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)`, "pending_booking", bookingID, userID) if err != nil { t.Fatalf("failed to create admin_notification: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)`, "new_booking", bookingID, userID) if err != nil { @@ -976,7 +964,7 @@ func TestBookings_Delete_WithAdminNotifications(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -984,7 +972,7 @@ func TestBookings_Delete_WithAdminNotifications(t *testing.T) { // Verify booking was deleted from DB var bookingCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE id = $1", bookingID).Scan(&bookingCount) if err != nil { t.Fatalf("failed to query bookings: %v", err) @@ -995,7 +983,7 @@ func TestBookings_Delete_WithAdminNotifications(t *testing.T) { // Verify admin_notifications for this booking were also cleaned up var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin_notifications: %v", err) @@ -1010,36 +998,37 @@ func TestBookings_Delete_WithAdminNotifications(t *testing.T) { // the request fails with HTTP 400. With a reason, the booking is soft-deleted // (status changed to client_cancelled). func TestBookings_Delete_WithReason(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Add a payment to the booking (so it requires a reason) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", bookingID) if err != nil { @@ -1050,7 +1039,7 @@ func TestBookings_Delete_WithReason(t *testing.T) { // Try to delete without reason - should fail handler := http.HandlerFunc(DeleteBookingHandler) - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing reason, got %d", w.Code) @@ -1058,7 +1047,7 @@ func TestBookings_Delete_WithReason(t *testing.T) { // Delete with reason req := map[string]string{"reason": "client_cancelled"} - w = makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, req, token) + w = makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, req, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1066,7 +1055,7 @@ func TestBookings_Delete_WithReason(t *testing.T) { // Verify booking status was updated (not hard deleted) var status string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Errorf("failed to query booking: %v", err) @@ -1079,17 +1068,18 @@ func TestBookings_Delete_WithReason(t *testing.T) { // TestBookings_Delete_NotFound verifies that deleting a non-existent // booking returns HTTP 404. func TestBookings_Delete_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -1097,7 +1087,7 @@ func TestBookings_Delete_NotFound(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) - w := makeRequest(handler, "DELETE", "/api/bookings/nonexistent-id", nil, token) + w := makeRequest(handler, "DELETE", "/api/bookings/nonexistent-id", nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -1108,21 +1098,22 @@ func TestBookings_Delete_NotFound(t *testing.T) { // - Cancellation < 24 hours before appointment: treated as no-show (deposits = 3) // - Cancellation >= 24 hours before appointment: treated as late_cancellation func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1136,7 +1127,7 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) + w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token, ctx) var booking Booking if err := parseResponseBody(w, &booking); err != nil { @@ -1144,14 +1135,14 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { } // Add payment so deletion works - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) // Delete within 24 hours (no forgiveness) - should result in no-show + deposits penalty delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} - w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token) + w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, ctx) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) @@ -1159,7 +1150,7 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { // Verify deposits were applied var deposits int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -1170,21 +1161,22 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { // TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1198,7 +1190,7 @@ func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) + w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token, ctx) var booking Booking if err := parseResponseBody(w, &booking); err != nil { @@ -1206,7 +1198,7 @@ func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { } // Add payment - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) @@ -1214,7 +1206,7 @@ func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { trueVal := true delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": trueVal} - w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token) + w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, ctx) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) @@ -1222,7 +1214,7 @@ func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { // Verify NO deposits were applied (forgiveness worked) var deposits int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -1239,32 +1231,33 @@ func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { // authentication. It verifies that requests without a token are rejected with // HTTP 401 for protected endpoints. func TestBookings_Unauthorized(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user and service - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) tests := []struct { name string @@ -1349,17 +1342,18 @@ func TestBookings_Unauthorized(t *testing.T) { // TestBookings_List_Empty tests that listing bookings for a user with no // bookings returns an empty list with total 0. func TestBookings_List_Empty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user (with no bookings) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -1367,7 +1361,7 @@ func TestBookings_List_Empty(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetAllUserBookingsHandler) - w := makeRequest(handler, "GET", "/api/bookings", nil, token) + w := makeRequest(handler, "GET", "/api/bookings", nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -1390,17 +1384,18 @@ func TestBookings_List_Empty(t *testing.T) { // TestBookings_Get_InvalidBookingID verifies that using an invalid // booking ID format returns HTTP 404 or 400. func TestBookings_Get_InvalidBookingID(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -1408,7 +1403,7 @@ func TestBookings_Get_InvalidBookingID(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) - w := makeRequest(handler, "GET", "/api/bookings/", nil, token) // trailing slash + w := makeRequest(handler, "GET", "/api/bookings/", nil, token, ctx) // trailing slash // Should return 404 or 400 depending on routing if w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest { @@ -1419,25 +1414,26 @@ func TestBookings_Get_InvalidBookingID(t *testing.T) { // TestBookings_Create_PastDate verifies that creating a booking with a // past start time fails with HTTP 400 Bad Request. func TestBookings_Create_PastDate(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1448,7 +1444,7 @@ func TestBookings_Create_PastDate(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for past date, got %d. body: %s", w.Code, w.Body.String()) @@ -1458,28 +1454,29 @@ func TestBookings_Create_PastDate(t *testing.T) { // TestBookings_Create_MinimumAdvance tests that bookings must be made at least // 1 hour in advance (changed from 48h deposit requirement to universal 1h rule). func TestBookings_Create_MinimumAdvance(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Seed working hours for booking tests - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Note: deposits_required=0 by default now - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1492,7 +1489,7 @@ func TestBookings_Create_MinimumAdvance(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -1511,22 +1508,23 @@ func TestBookings_Create_MinimumAdvance(t *testing.T) { // TestBookings_Create_WithNotes_StatusPending tests that when a booking is created with notes, // the booking status is automatically set to 'pending' (requires admin approval). func TestBookings_Create_WithNotes_StatusPending(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Seed working hours - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1541,7 +1539,7 @@ func TestBookings_Create_WithNotes_StatusPending(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -1564,22 +1562,23 @@ func TestBookings_Create_WithNotes_StatusPending(t *testing.T) { // TestBookings_Create_WithoutNotes_StatusConfirmed tests that when a booking is created without notes, // the booking status is automatically set to 'confirmed' (auto-approved). func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Seed working hours - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1593,7 +1592,7 @@ func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -1611,22 +1610,23 @@ func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) { // TestBookings_Create_Within1Hour_ShouldFail tests that bookings less than 1 hour in advance are rejected. func TestBookings_Create_Within1Hour_ShouldFail(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Seed working hours - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1638,7 +1638,7 @@ func TestBookings_Create_Within1Hour_ShouldFail(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -1649,34 +1649,35 @@ func TestBookings_Create_Within1Hour_ShouldFail(t *testing.T) { // multiple services at once, and all services are properly associated with // the booking in the database. func TestBookings_Create_MultipleServices(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Seed working hours for booking tests - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID1, err := fixtures.CreateTestService(db.DB) + serviceID1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service 1: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID1) + defer fixtures.DeleteService(tx, serviceID1) - serviceID2, err := fixtures.CreateTestService(db.DB) + serviceID2, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service 2: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID2) + defer fixtures.DeleteService(tx, serviceID2) token := jwt.GenerateUserToken(userID) @@ -1689,7 +1690,7 @@ func TestBookings_Create_MultipleServices(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -1713,26 +1714,27 @@ func TestBookings_Create_MultipleServices(t *testing.T) { // booking is cancelled within 24 hours (with no forgiveness), the system // overrides the cancellation to "no_show" and deposits_required stays 0. func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1744,20 +1746,20 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) + w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token, ctx) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking.ID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { @@ -1766,14 +1768,14 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} - w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") + w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "", ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var deposits int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -1782,7 +1784,7 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { } var status string - err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } @@ -1795,28 +1797,29 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { // cancelled with more than 24 hours notice (client_cancelled), no deposit penalty // is applied and deposits_required remains 0. func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + // Create test user with deposits_required = 0 - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Ensure deposits_required = 0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1830,7 +1833,7 @@ func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) + w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token, ctx) var booking Booking if err := parseResponseBody(w, &booking); err != nil { @@ -1838,7 +1841,7 @@ func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { } // Add a payment to the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { @@ -1848,7 +1851,7 @@ func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { // Delete booking with reason "client_cancelled" (>= 24h notice) delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled"} - w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") + w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "", ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1856,7 +1859,7 @@ func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { // Verify deposits_required = 0 (no penalty) var deposits int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -1866,7 +1869,7 @@ func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { // Verify booking status = "client_cancelled" var status string - err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } @@ -1879,26 +1882,27 @@ func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { // booking is cancelled within 24 hours with forgiveness, the system overrides to // "client_cancelled" (no no-show penalty). func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1910,20 +1914,20 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) + w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token, ctx) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking.ID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { @@ -1932,7 +1936,7 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": true} - w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") + w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "", ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1940,7 +1944,7 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { // Verify deposits_required = 0 (no penalty due to forgiveness) var deposits int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -1950,7 +1954,7 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { // Verify booking status = "client_cancelled" (not "no_show") var status string - err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } @@ -1963,28 +1967,29 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { // no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3 // on the first no-show and doesn't increment on subsequent no-shows. func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + // Create test user with deposits_required = 0 - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Ensure deposits_required = 0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -1997,30 +2002,30 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", bookingReq1, token) + w := makeRequest(handler, "POST", "/api/bookings", bookingReq1, token, ctx) var booking1 Booking if err := parseResponseBody(w, &booking1); err != nil { t.Fatalf("failed to parse booking response: %v", err) } - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking1.ID) - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking1.ID) delHandler := http.HandlerFunc(DeleteBookingHandler) delReq1 := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} - w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking1.ID, delReq1, token, "") + w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking1.ID, delReq1, token, "", ctx) if w.Code != http.StatusOK { t.Fatalf("first delete failed: %d body: %s", w.Code, w.Body.String()) } var deposits1 int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits1) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits1) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -2036,29 +2041,29 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { ServiceIDs: []string{serviceID}, } - w = makeRequest(handler, "POST", "/api/bookings", bookingReq2, token) + w = makeRequest(handler, "POST", "/api/bookings", bookingReq2, token, ctx) var booking2 Booking if err := parseResponseBody(w, &booking2); err != nil { t.Fatalf("failed to parse second booking response: %v", err) } - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking2.ID) - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking2.ID) delReq2 := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} - w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking2.ID, delReq2, token, "") + w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking2.ID, delReq2, token, "", ctx) if w.Code != http.StatusOK { t.Fatalf("second delete failed: %d body: %s", w.Code, w.Body.String()) } var deposits2 int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits2) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits2) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -2070,27 +2075,28 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { // TestCountUnforgivenNoShows_ExcludesForgiven tests that CountUnforgivenNoShows // excludes bookings that have been forgiven (in forgiven_no_shows table). func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create 3 bookings with status "no_show" within last 6 months now := time.Now() for i := 0; i < 3; i++ { startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30, 60 days ago var bookingID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id @@ -2100,7 +2106,7 @@ func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) { } // Link service - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, serviceID) if err != nil { @@ -2109,7 +2115,7 @@ func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) { // Forgive the first one (i == 0) if i == 0 { - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bookingID) if err != nil { @@ -2119,7 +2125,7 @@ func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) { } // Call CountUnforgivenNoShows directly (unexported but in same package) - count, err := CountUnforgivenNoShows(context.Background(), userID) + count, err := CountUnforgivenNoShows(ctx, userID) if err != nil { t.Fatalf("CountUnforgivenNoShows failed: %v", err) } @@ -2133,27 +2139,28 @@ func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) { // TestCountUnforgivenNoShows_ExcludesOld tests that CountUnforgivenNoShows // excludes no-shows older than 6 months. func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) now := time.Now() // Create 1 booking with status "no_show" from 7 months ago (should be excluded) oldStartTime := now.Add(-7 * 30 * 24 * time.Hour) var oldBookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'old no-show') RETURNING id @@ -2161,14 +2168,14 @@ func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) { if err != nil { t.Fatalf("failed to create old booking: %v", err) } - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", oldBookingID, serviceID) // Create 1 booking with status "no_show" from 1 month ago (should be included) recentStartTime := now.Add(-30 * 24 * time.Hour) var recentBookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'recent no-show') RETURNING id @@ -2176,12 +2183,12 @@ func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) { if err != nil { t.Fatalf("failed to create recent booking: %v", err) } - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", recentBookingID, serviceID) // Call CountUnforgivenNoShows directly - count, err := CountUnforgivenNoShows(context.Background(), userID) + count, err := CountUnforgivenNoShows(ctx, userID) if err != nil { t.Fatalf("CountUnforgivenNoShows failed: %v", err) } @@ -2195,33 +2202,34 @@ func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) { // TestApplyDepositsIfNeeded_AppliesAt2Plus tests that ApplyDepositsIfNeeded // applies 3 deposits when user has 2 or more unforgiven no-shows in last 6 months. func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user with deposits_required = 0 - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Ensure deposits_required = 0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create 2 bookings with status "no_show" within last 6 months now := time.Now() for i := 0; i < 2; i++ { startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30 days ago var bookingID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id @@ -2231,7 +2239,7 @@ func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) { } // Link service - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, serviceID) if err != nil { @@ -2240,7 +2248,7 @@ func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) { } // Call ApplyDepositsIfNeeded directly - applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + applied, err := ApplyDepositsIfNeeded(ctx, userID) if err != nil { t.Fatalf("ApplyDepositsIfNeeded failed: %v", err) } @@ -2252,7 +2260,7 @@ func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) { // Verify deposits_required = 3 var deposits int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -2264,31 +2272,32 @@ func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) { // TestApplyDepositsIfNeeded_DoesNotApplyAt1 tests that ApplyDepositsIfNeeded // does NOT apply deposits when user has only 1 unforgiven no-show. func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user with deposits_required = 0 - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Ensure deposits_required = 0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create 1 booking with status "no_show" within last 6 months startTime := time.Now().Add(-30 * 24 * time.Hour) // 30 days ago var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id @@ -2298,7 +2307,7 @@ func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) { } // Link service - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, serviceID) if err != nil { @@ -2306,7 +2315,7 @@ func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) { } // Call ApplyDepositsIfNeeded directly - applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + applied, err := ApplyDepositsIfNeeded(ctx, userID) if err != nil { t.Fatalf("ApplyDepositsIfNeeded failed: %v", err) } @@ -2318,7 +2327,7 @@ func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) { // Verify deposits_required = 0 (unchanged) var deposits int - err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) + err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } @@ -2410,9 +2419,6 @@ func TestNotesValidation_EmptyString(t *testing.T) { } } -// Ensure test compilation - import pgxpool to avoid unused import -var _ = func() *pgxpool.Pool { return nil } - // Import mw to avoid unused import var _ = mw.UserIDKey @@ -2423,35 +2429,36 @@ var _ = mw.UserIDKey // TestBookings_Get_NoAuthHeader confirms that accessing a booking without // an Authorization header returns HTTP 401 Unauthorized. func TestBookings_Get_NoAuthHeader(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Make request WITHOUT token (empty string passed as token parameter) handler := http.HandlerFunc(GetBookingHandler) - w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, "") + w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for missing auth, got %d", w.Code) @@ -2466,36 +2473,37 @@ func TestBookings_Get_NoAuthHeader(t *testing.T) { // contains all required fields: BEGIN:VCALENDAR, END:VCALENDAR, BEGIN:VEVENT, // END:VEVENT, DTSTART, DTEND, and SUMMARY. func TestBookings_GetCalendar_ValidICS(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) - w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token) + w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2539,41 +2547,42 @@ func TestBookings_GetCalendar_ValidICS(t *testing.T) { // user cancels a confirmed booking (one with payments), an admin notification // is created to alert staff of the cancellation. func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // First, confirm the booking (so it's not pending) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Add a payment to trigger soft delete path (bookings with payments use soft delete) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) @@ -2586,7 +2595,7 @@ func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) { // Cancel the confirmed booking with a reason (required for soft delete) handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2594,7 +2603,7 @@ func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) { // Verify admin notification was created var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) @@ -2610,35 +2619,36 @@ func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) { // pending booking (one without payments) does NOT create an admin notification, // as pending cancellations don't require staff attention. func TestUserCancelBooking_PendingNoNotification(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Verify booking is in 'pending' status var status string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if status != "pending" { t.Fatalf("expected booking status 'pending', got %s", status) @@ -2648,7 +2658,7 @@ func TestUserCancelBooking_PendingNoNotification(t *testing.T) { // Cancel the pending booking handler := http.HandlerFunc(DeleteBookingHandler) - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2656,7 +2666,7 @@ func TestUserCancelBooking_PendingNoNotification(t *testing.T) { // Verify NO admin notification was created (pending cancellations don't notify) var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) @@ -2675,41 +2685,42 @@ func TestUserCancelBooking_PendingNoNotification(t *testing.T) { // TestUserCancelBooking_TransactionIntegrity verifies that if any part of the // cancellation transaction fails, the booking status is NOT changed (rollback behavior) func TestUserCancelBooking_TransactionIntegrity(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Add a payment to trigger soft delete path (bookings with payments use soft delete) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) @@ -2719,7 +2730,7 @@ func TestUserCancelBooking_TransactionIntegrity(t *testing.T) { // Verify initial state var statusBefore string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusBefore) if statusBefore != "confirmed" { t.Fatalf("expected status 'confirmed' before cancel, got %s", statusBefore) @@ -2730,7 +2741,7 @@ func TestUserCancelBooking_TransactionIntegrity(t *testing.T) { // Cancel the booking with a reason (required for soft delete) handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2738,7 +2749,7 @@ func TestUserCancelBooking_TransactionIntegrity(t *testing.T) { // Verify that status WAS changed (successful transaction commit) var statusAfter string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusAfter) if statusAfter == "confirmed" { t.Error("booking status should have changed after cancellation (transaction should have committed)") @@ -2749,36 +2760,37 @@ func TestUserCancelBooking_TransactionIntegrity(t *testing.T) { // confirmed booking (e.g., change time). This creates a booking_edit_request record // and generates an admin notification for staff review. func TestCreateEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Use a start time ~36h from now so auto-approval (>=48h) does not fire bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) @@ -2791,7 +2803,7 @@ func TestCreateEditRequest(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Please change the time", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -2799,7 +2811,7 @@ func TestCreateEditRequest(t *testing.T) { // Verify edit request was created var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -2810,7 +2822,7 @@ func TestCreateEditRequest(t *testing.T) { // Verify admin notification was created var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID).Scan(¬ifCount) @@ -2825,36 +2837,37 @@ func TestCreateEditRequest(t *testing.T) { // TestCreateEditRequest_WithTimeChange verifies that a user can request an edit to // change the booking time, and a time_blocker is created to reserve the new slot. func TestCreateEditRequest_WithTimeChange(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Use a start time ~36h from now so auto-approval (>=48h) does not fire bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -2868,14 +2881,14 @@ func TestCreateEditRequest_WithTimeChange(t *testing.T) { reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var erNewTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT new_start_time FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erNewTime) if err != nil { t.Fatalf("failed to query edit request: %v", err) @@ -2885,7 +2898,7 @@ func TestCreateEditRequest_WithTimeChange(t *testing.T) { } var blockerCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { @@ -2897,7 +2910,7 @@ func TestCreateEditRequest_WithTimeChange(t *testing.T) { var blockerStart time.Time var blockerDuration int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time, duration_minutes FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerStart, &blockerDuration) if err != nil { @@ -2913,35 +2926,36 @@ func TestCreateEditRequest_WithTimeChange(t *testing.T) { // TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification func TestDeleteEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) @@ -2949,7 +2963,7 @@ func TestDeleteEditRequest(t *testing.T) { // Create edit request directly in DB (simulating user request) var editRequestID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change time') RETURNING id`, @@ -2959,7 +2973,7 @@ func TestDeleteEditRequest(t *testing.T) { } // Create admin notification - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) @@ -2971,7 +2985,7 @@ func TestDeleteEditRequest(t *testing.T) { // Delete edit request (user cancels their request) handler := http.HandlerFunc(DeleteEditRequestHandler) - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -2979,7 +2993,7 @@ func TestDeleteEditRequest(t *testing.T) { // Verify edit request was deleted var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -2990,7 +3004,7 @@ func TestDeleteEditRequest(t *testing.T) { // Verify admin notification was DELETED (not acknowledged) var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(¬ifCount) @@ -3004,34 +3018,35 @@ func TestDeleteEditRequest(t *testing.T) { // TestAdminApproveEditRequest tests that admin approving acknowledges the notification (not deletes) func TestAdminApproveEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) @@ -3041,7 +3056,7 @@ func TestAdminApproveEditRequest(t *testing.T) { var editRequestID string newTime := time.Now().Add(24 * time.Hour).Truncate(time.Minute) var emptyServices []string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes) VALUES ($1, $2, $3, $4, 'Please change time') RETURNING id`, @@ -3054,7 +3069,7 @@ func TestAdminApproveEditRequest(t *testing.T) { } // Create admin notification - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) @@ -3064,7 +3079,7 @@ func TestAdminApproveEditRequest(t *testing.T) { // Verify notification starts as unacknowledged var ackTime *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) @@ -3078,13 +3093,15 @@ func TestAdminApproveEditRequest(t *testing.T) { // Simulate admin approval adminToken := jwt.GenerateAdminToken() - // Create request with chi context req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) req.Header.Set("Authorization", "Bearer "+adminToken) + reqCtx := ctx rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() AdminApproveEditRequestHandler(w, req) @@ -3095,7 +3112,7 @@ func TestAdminApproveEditRequest(t *testing.T) { // Verify edit request was deleted (approved) var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -3106,7 +3123,7 @@ func TestAdminApproveEditRequest(t *testing.T) { // Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved var ackTimeAfter *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) @@ -3120,34 +3137,35 @@ func TestAdminApproveEditRequest(t *testing.T) { // TestAdminRejectEditRequest tests that admin rejecting acknowledges the notification (not deletes) func TestAdminRejectEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) @@ -3155,7 +3173,7 @@ func TestAdminRejectEditRequest(t *testing.T) { // Create edit request directly in DB var editRequestID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change time') RETURNING id`, @@ -3165,7 +3183,7 @@ func TestAdminRejectEditRequest(t *testing.T) { } // Create admin notification - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) @@ -3175,7 +3193,7 @@ func TestAdminRejectEditRequest(t *testing.T) { // Verify notification starts as unacknowledged var ackTime *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) @@ -3189,13 +3207,15 @@ func TestAdminRejectEditRequest(t *testing.T) { // Simulate admin denial adminToken := jwt.GenerateAdminToken() - // Create request with chi context req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil) req.Header.Set("Authorization", "Bearer "+adminToken) + reqCtx := ctx rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() AdminRejectEditRequestHandler(w, req) @@ -3206,7 +3226,7 @@ func TestAdminRejectEditRequest(t *testing.T) { // Verify edit request was deleted (rejected) var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -3217,7 +3237,7 @@ func TestAdminRejectEditRequest(t *testing.T) { // Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved var ackTimeAfter *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) @@ -3232,35 +3252,36 @@ func TestAdminRejectEditRequest(t *testing.T) { // TestAdminApproveEditRequest_DeletesTimeBlocker verifies that when admin approves // an edit request, the associated time_blocker reservation is deleted. func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -3274,13 +3295,13 @@ func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken, ctx) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { @@ -3291,7 +3312,7 @@ func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { } var editRequestID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) @@ -3300,12 +3321,13 @@ func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) - ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") + reqCtx := ctx + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) - ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) - req = req.WithContext(ctx) + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) + req = req.WithContext(reqCtx) w = httptest.NewRecorder() r.ServeHTTP(w, req) @@ -3314,7 +3336,7 @@ func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { } var blockerCountAfter int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { @@ -3328,35 +3350,36 @@ func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { // TestAdminRejectEditRequest_DeletesTimeBlocker verifies that when admin rejects // an edit request, the associated time_blocker reservation is deleted. func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -3370,13 +3393,13 @@ func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken, ctx) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { @@ -3387,7 +3410,7 @@ func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { } var editRequestID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) @@ -3396,12 +3419,13 @@ func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/deny", AdminRejectEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil) - ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") + reqCtx := ctx + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) - ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) - req = req.WithContext(ctx) + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) + req = req.WithContext(reqCtx) w = httptest.NewRecorder() r.ServeHTTP(w, req) @@ -3410,7 +3434,7 @@ func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { } var blockerCountAfter int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { @@ -3424,35 +3448,36 @@ func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { // TestDeleteEditRequest_DeletesTimeBlocker verifies that when user cancels their // own edit request, the associated time_blocker reservation is deleted. func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -3466,13 +3491,13 @@ func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) { createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken, ctx) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { @@ -3483,14 +3508,14 @@ func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) { } delHandler := http.HandlerFunc(DeleteEditRequestHandler) - w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken) + w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken, ctx) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } var blockerCountAfter int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { @@ -3504,35 +3529,36 @@ func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) { // TestAdminApproveEditRequest_TimeBlockerOverlap tests that approving an edit // request fails when the new time conflicts with an existing time_blocker. func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -3546,12 +3572,12 @@ func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken, ctx) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Existing blocker', $2) `, newStartTime, userID) @@ -3560,7 +3586,7 @@ func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { } var editRequestID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) @@ -3569,12 +3595,13 @@ func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) - ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") + reqCtx := ctx + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) - ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) - req = req.WithContext(ctx) + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) + req = req.WithContext(reqCtx) w = httptest.NewRecorder() r.ServeHTTP(w, req) @@ -3585,17 +3612,18 @@ func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { // TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404 func TestBookings_RequestEdit_BookingNotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -3606,7 +3634,7 @@ func TestBookings_RequestEdit_BookingNotFound(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Please change the time", } - w := makeRequest(handler, "POST", "/api/bookings/nonexistent-booking-id/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/nonexistent-booking-id/edit-request", reqBody, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -3615,42 +3643,43 @@ func TestBookings_RequestEdit_BookingNotFound(t *testing.T) { // TestBookings_RequestEdit_AlreadyHasPending tests that a user cannot create a second edit request while one already exists func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Confirm the booking - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a pending edit request directly in DB (pre-condition) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change the time')`, bookingID, userID) @@ -3659,7 +3688,7 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { } // Create admin notification for the initial edit request - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) @@ -3675,7 +3704,7 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Please change to a different day", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) // Expect HTTP 201 Created (handler replaces existing request) if w.Code != http.StatusCreated { @@ -3684,7 +3713,7 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { // Verify only 1 edit request exists in DB (the old one was replaced) var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -3695,7 +3724,7 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { // Verify the notes were updated var notes string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT notes FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(¬es) if err != nil { t.Fatalf("failed to query edit request notes: %v", err) @@ -3712,28 +3741,29 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { // TestBookings_Create_PatchTestRequired_NoRecord verifies that a user without a patch test record // cannot book a service that requires a patch test. The booking should be rejected with 400. func TestBookings_Create_PatchTestRequired_NoRecord(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + // Create user and service with patch test requirement - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) + serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(tx) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) _ = patchTestID // We don't delete patch tests, they cascade with service token := jwt.GenerateUserToken(userID) @@ -3747,7 +3777,7 @@ func TestBookings_Create_PatchTestRequired_NoRecord(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing patch test, got %d. body: %s", w.Code, w.Body.String()) @@ -3761,30 +3791,31 @@ func TestBookings_Create_PatchTestRequired_NoRecord(t *testing.T) { // TestBookings_Create_PatchTestRequired_WithinNoticePeriod verifies that a user // cannot book within the notice period after completing a patch test (e.g., 24h wait). func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) + serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(tx) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create patch test record with tested_at only 1 hour ago (notice is 24h) testedAt := time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05") - err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) + err = fixtures.CreateUserPatchTest(tx, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } @@ -3803,7 +3834,7 @@ func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for within notice period, got %d. body: %s", w.Code, w.Body.String()) @@ -3817,30 +3848,31 @@ func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) { // TestBookings_Create_PatchTestRequired_Expired verifies that a user // with an expired patch test cannot book services requiring patch test. func TestBookings_Create_PatchTestRequired_Expired(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) + serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(tx) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create patch test record from 7 months ago (expiry is 6 months) testedAt := time.Now().AddDate(0, -7, 0).Format("2006-01-02 15:04:05") - err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) + err = fixtures.CreateUserPatchTest(tx, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } @@ -3856,7 +3888,7 @@ func TestBookings_Create_PatchTestRequired_Expired(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for expired patch test, got %d. body: %s", w.Code, w.Body.String()) @@ -3870,30 +3902,31 @@ func TestBookings_Create_PatchTestRequired_Expired(t *testing.T) { // TestBookings_Create_PatchTestRequired_ValidRecord verifies that a user // with a valid patch test record can successfully book services requiring patch test. func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) + serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(tx) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create patch test record from 48 hours ago (notice is 24h, so valid now) testedAt := time.Now().Add(-48 * time.Hour).Format("2006-01-02 15:04:05") - err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) + err = fixtures.CreateUserPatchTest(tx, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } @@ -3909,7 +3942,7 @@ func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for valid patch test, got %d. body: %s", w.Code, w.Body.String()) @@ -3917,7 +3950,7 @@ func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) { // Verify booking was created var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) @@ -3935,27 +3968,28 @@ func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) { // cannot book within the deposit advance window. They must complete more appointments // to remove this restriction. func TestBookings_Create_DepositRequired_WithinAdvanceWindow(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=3 to trigger deposit advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -3968,7 +4002,7 @@ func TestBookings_Create_DepositRequired_WithinAdvanceWindow(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for within advance-window booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String()) @@ -3982,27 +4016,28 @@ func TestBookings_Create_DepositRequired_WithinAdvanceWindow(t *testing.T) { // TestBookings_Create_DepositRequired_After48Hours verifies that a user with deposits_required > 0 // CAN book if the start time is at least 48 hours in the future. func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=3 to trigger 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -4015,7 +4050,7 @@ func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for booking after 48h, got %d. body: %s", w.Code, w.Body.String()) @@ -4023,7 +4058,7 @@ func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) { // Verify booking was created var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) @@ -4036,27 +4071,28 @@ func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) { // TestBookings_Create_NoDepositRequired_Within48Hours verifies that a user with deposits_required=0 // can book at any time (no 48h restriction). func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // deposits_required=0 means no 48h restriction - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -4069,7 +4105,7 @@ func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for booking within 48h with no deposit required, got %d. body: %s", w.Code, w.Body.String()) @@ -4083,27 +4119,28 @@ func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) { // TestBookings_Create_DepositSnapshot verifies that deposit_required is snapshotted // at booking creation time from user's current deposits_required value. func TestBookings_Create_DepositSnapshot(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=3 BEFORE creating booking - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -4116,7 +4153,7 @@ func TestBookings_Create_DepositSnapshot(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -4124,7 +4161,7 @@ func TestBookings_Create_DepositSnapshot(t *testing.T) { // Verify deposit_required was snapshotted on the booking var depositRequired bool - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -4134,13 +4171,13 @@ func TestBookings_Create_DepositSnapshot(t *testing.T) { } // Now change user's deposits_required to 0 - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to update deposits_required: %v", err) } // Verify the booking's deposit_required is still true (snapshot is not updated) - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -4153,27 +4190,28 @@ func TestBookings_Create_DepositSnapshot(t *testing.T) { // TestBookings_Create_DepositRequired_OneActiveBookingLimit verifies that a user // with deposits_required > 0 can only have ONE active booking at a time. func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=3 (triggers one-active-booking limit) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -4186,7 +4224,7 @@ func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req1, token) + w := makeRequest(handler, "POST", "/api/bookings", req1, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected first booking to succeed, got %d. body: %s", w.Code, w.Body.String()) @@ -4200,7 +4238,7 @@ func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) { ServiceIDs: []string{serviceID}, } - w = makeRequest(handler, "POST", "/api/bookings", req2, token) + w = makeRequest(handler, "POST", "/api/bookings", req2, token, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for second booking attempt, got %d. body: %s", w.Code, w.Body.String()) @@ -4214,27 +4252,28 @@ func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) { // TestBookings_Get_DepositFieldsReturned verifies that GET /api/bookings returns // the deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline). func TestBookings_Get_DepositFieldsReturned(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=3 and create booking - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -4247,14 +4286,14 @@ func TestBookings_Get_DepositFieldsReturned(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // GET the booking and verify deposit fields - w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -4294,20 +4333,21 @@ func TestBookings_Get_DepositFieldsReturned(t *testing.T) { // TestBookings_Get_ServicesReturned verifies that GET /api/bookings returns // services with correct name, price, and duration for each booking. func TestBookings_Get_ServicesReturned(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) @@ -4320,13 +4360,13 @@ func TestBookings_Get_ServicesReturned(t *testing.T) { ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String()) } // Fetch bookings list - w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -4365,27 +4405,28 @@ func TestBookings_Get_ServicesReturned(t *testing.T) { // TestBookings_Get_CustomServicesReturned verifies that GET /api/bookings // returns custom services correctly. func TestBookings_Get_CustomServicesReturned(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Thursday, london).Add(10 * time.Hour) // Insert booking + custom service link directly var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id @@ -4393,9 +4434,9 @@ func TestBookings_Get_CustomServicesReturned(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) @@ -4404,7 +4445,7 @@ func TestBookings_Get_CustomServicesReturned(t *testing.T) { } token := jwt.GenerateUserToken(userID) - w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -4449,21 +4490,22 @@ func TestBookings_Get_CustomServicesReturned(t *testing.T) { // TestBookings_Get_EmptyServices verifies that GET /api/bookings returns an // empty array (not null) for bookings with no services. func TestBookings_Get_EmptyServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Friday, london).Add(10 * time.Hour) // Insert booking with NO services at all var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id @@ -4471,10 +4513,10 @@ func TestBookings_Get_EmptyServices(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) token := jwt.GenerateUserToken(userID) - w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -4499,36 +4541,37 @@ func TestBookings_Get_EmptyServices(t *testing.T) { // TestBookings_Edit_ClosedDay_UserBlocked verifies that a regular user cannot edit a booking // to fall on a closed day (exceptional hours marked as is_open=false). func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Create exceptional hours group for holiday var groupID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id", "Holiday Closure", "Closed for holiday").Scan(&groupID) if err != nil { @@ -4547,7 +4590,7 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) { weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location()) // Apply group to this week - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)", groupID, weekStart) if err != nil { @@ -4555,7 +4598,7 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) { } // Create closed exceptional hours for that weekday - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '08:00:00', '20:00:00', false)`, groupID, dbWeekday) @@ -4573,7 +4616,7 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) { } handler := http.HandlerFunc(EditBookingHandler) - w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) + w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for closed day edit, got %d. body: %s", w.Code, w.Body.String()) @@ -4587,36 +4630,37 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) { // TestBookings_Edit_OpenDay_UserAllowed verifies that a user CAN edit a booking // to a day that is marked as open in exceptional hours. func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Create exceptional hours group with OPEN hours (is_open=true) var groupID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id", "Special Opening", "Extended hours").Scan(&groupID) if err != nil { @@ -4634,7 +4678,7 @@ func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) { weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location()) // Apply group to this week - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)", groupID, weekStart) if err != nil { @@ -4642,7 +4686,7 @@ func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) { } // Create OPEN exceptional hours for that weekday - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, `INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '08:00:00', '20:00:00', true)`, groupID, weekday) @@ -4660,7 +4704,7 @@ func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) { } handler := http.HandlerFunc(EditBookingHandler) - w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) + w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 for open day edit, got %d. body: %s", w.Code, w.Body.String()) @@ -4674,32 +4718,33 @@ func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) { // TestBookings_Create_OverlappingBlocker_UserBlocked verifies that a regular user // CANNOT create a booking that overlaps with a time blocker. They receive 409 Conflict. func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a time blocker for a specific time ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, blockerTime) @@ -4716,7 +4761,7 @@ func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) // User should get 409 Conflict (not 201 Created) if w.Code != http.StatusConflict { @@ -4730,7 +4775,7 @@ func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) { // Verify NO booking was created var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Fatalf("failed to query bookings: %v", err) @@ -4743,39 +4788,40 @@ func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) { // TestBookings_Edit_OverlappingBlocker_UserBlocked verifies that a regular user // CANNOT edit a booking to a time that overlaps with a time blocker. func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Set deposits_required=0 to avoid 48h advance booking requirement - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Create a booking first - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) // Create a time blocker for a specific time ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, blockerTime) @@ -4791,7 +4837,7 @@ func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) { } handler := http.HandlerFunc(EditBookingHandler) - w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) + w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx) // User should get 409 Conflict if w.Code != http.StatusConflict { @@ -4807,7 +4853,8 @@ func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) { // --- Guest Booking Tests --- func TestGuestUser_Create_Success(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) req := map[string]string{ "firstName": "Jane", @@ -4817,7 +4864,7 @@ func TestGuestUser_Create_Success(t *testing.T) { } handler := http.HandlerFunc(user.CreateGuestUserHandler) - w := makeRequest(handler, "POST", "/api/users/guest", req, "") + w := makeRequest(handler, "POST", "/api/users/guest", req, "", ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -4834,7 +4881,7 @@ func TestGuestUser_Create_Success(t *testing.T) { // Verify user exists in DB var role string - err := db.DB.QueryRow(context.Background(), `SELECT account_role FROM users WHERE id = $1`, resp["id"]).Scan(&role) + err := tx.QueryRow(ctx, `SELECT account_role FROM users WHERE id = $1`, resp["id"]).Scan(&role) if err != nil { t.Fatalf("failed to query user: %v", err) } @@ -4844,7 +4891,8 @@ func TestGuestUser_Create_Success(t *testing.T) { } func TestGuestUser_Create_DuplicateEmail(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // First guest creation req := map[string]string{ @@ -4855,7 +4903,7 @@ func TestGuestUser_Create_DuplicateEmail(t *testing.T) { } handler := http.HandlerFunc(user.CreateGuestUserHandler) - w1 := makeRequest(handler, "POST", "/api/users/guest", req, "") + w1 := makeRequest(handler, "POST", "/api/users/guest", req, "", ctx) if w1.Code != http.StatusCreated { t.Fatalf("first guest creation failed: %d", w1.Code) } @@ -4869,7 +4917,7 @@ func TestGuestUser_Create_DuplicateEmail(t *testing.T) { "email": "john@test.com", // same email "phone": "07123456780", } - w2 := makeRequest(handler, "POST", "/api/users/guest", req2, "") + w2 := makeRequest(handler, "POST", "/api/users/guest", req2, "", ctx) if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for second guest, got %d. body: %s", w2.Code, w2.Body.String()) } @@ -4882,18 +4930,19 @@ func TestGuestUser_Create_DuplicateEmail(t *testing.T) { // Verify two separate guest accounts exist var count int - db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM users WHERE email = $1 AND account_role = 'guest'`, "john@test.com").Scan(&count) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE email = $1 AND account_role = 'guest'`, "john@test.com").Scan(&count) if count != 2 { t.Errorf("expected 2 guest accounts with same email, got %d", count) } } func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create a registered user with a known email registeredEmail := "registered@example.com" - db.DB.Exec(context.Background(), ` + tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, account_role) VALUES ('Registered', 'User', $1, '07123456700', '1990-01-01', 'verified_email') `, registeredEmail) @@ -4907,7 +4956,7 @@ func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) { } handler := http.HandlerFunc(user.CreateGuestUserHandler) - w := makeRequest(handler, "POST", "/api/users/guest", req, "") + w := makeRequest(handler, "POST", "/api/users/guest", req, "", ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for registered email collision, got %d. body: %s", w.Code, w.Body.String()) @@ -4918,8 +4967,9 @@ func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) { } func TestGuestBooking_Create_Success(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + // Create guest user guestReq := map[string]string{ @@ -4929,7 +4979,7 @@ func TestGuestBooking_Create_Success(t *testing.T) { "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) - w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") + w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "", ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create guest user: %d", w.Code) } @@ -4938,7 +4988,7 @@ func TestGuestBooking_Create_Success(t *testing.T) { guestID := guestResp["id"] // Create booking as guest - serviceID, _ := fixtures.CreateTestService(db.DB) + serviceID, _ := fixtures.CreateTestService(tx) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) @@ -4949,7 +4999,7 @@ func TestGuestBooking_Create_Success(t *testing.T) { } bookingHandler := http.HandlerFunc(CreateBookingHandler) - w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") + w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "", ctx) if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for guest booking, got %d. body: %s", w2.Code, w2.Body.String()) @@ -4957,17 +5007,18 @@ func TestGuestBooking_Create_Success(t *testing.T) { // Verify booking in DB var count int - db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, guestID).Scan(&count) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, guestID).Scan(&count) if count != 1 { t.Errorf("expected 1 booking for guest, got %d", count) } } func TestGuestBooking_Create_WithoutUserID(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Attempt booking without auth AND without user_id - serviceID, _ := fixtures.CreateTestService(db.DB) + serviceID, _ := fixtures.CreateTestService(tx) futureTime := time.Now().Add(72 * time.Hour) req := CreateBookingRequest{ @@ -4976,7 +5027,7 @@ func TestGuestBooking_Create_WithoutUserID(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, "") + w := makeRequest(handler, "POST", "/api/bookings", req, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for missing user_id, got %d. body: %s", w.Code, w.Body.String()) @@ -4984,13 +5035,14 @@ func TestGuestBooking_Create_WithoutUserID(t *testing.T) { } func TestGuestBooking_Create_NonGuestUserID(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create a registered (non-guest) user - userID, _ := fixtures.CreateTestUser(db.DB) + userID, _ := fixtures.CreateTestUser(tx) // Try to book using their user_id but without auth token - serviceID, _ := fixtures.CreateTestService(db.DB) + serviceID, _ := fixtures.CreateTestService(tx) futureTime := time.Now().Add(72 * time.Hour) req := CreateBookingRequest{ @@ -5000,7 +5052,7 @@ func TestGuestBooking_Create_NonGuestUserID(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, "") + w := makeRequest(handler, "POST", "/api/bookings", req, "", ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for non-guest user_id, got %d. body: %s", w.Code, w.Body.String()) @@ -5011,8 +5063,9 @@ func TestGuestBooking_Create_NonGuestUserID(t *testing.T) { } func TestGuestBooking_SkipsDepositCheck(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + // Create guest user guestReq := map[string]string{ @@ -5022,15 +5075,15 @@ func TestGuestBooking_SkipsDepositCheck(t *testing.T) { "phone": "07123456788", } handler := http.HandlerFunc(user.CreateGuestUserHandler) - w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") + w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "", ctx) var guestResp map[string]string json.Unmarshal(w.Body.Bytes(), &guestResp) guestID := guestResp["id"] // Give them an active booking with deposit required - serviceID, _ := fixtures.CreateTestService(db.DB) + serviceID, _ := fixtures.CreateTestService(tx) pastTime := time.Now().Add(72 * time.Hour) - db.DB.Exec(context.Background(), ` + tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed'::booking_status, false) `, guestID, pastTime) @@ -5046,7 +5099,7 @@ func TestGuestBooking_SkipsDepositCheck(t *testing.T) { } bookingHandler := http.HandlerFunc(CreateBookingHandler) - w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") + w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "", ctx) if w2.Code != http.StatusCreated { t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String()) @@ -5054,8 +5107,9 @@ func TestGuestBooking_SkipsDepositCheck(t *testing.T) { } func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + + ctx, tx := testutils.SetupTestTx(t) + // Create guest user guestReq := map[string]string{ @@ -5065,21 +5119,21 @@ func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) { "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) - w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") + w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "", ctx) var guestResp map[string]string json.Unmarshal(w.Body.Bytes(), &guestResp) guestID := guestResp["id"] // Set deposits_required on the guest user. - if _, err := db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", guestID); err != nil { + if _, err := tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", guestID); err != nil { t.Fatalf("failed to set deposits_required on guest: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Guest should be able to book within the 36h advance window (bypasses deposit check). midday := time.Now().Truncate(24 * time.Hour).Add(29 * time.Hour).In(londonLocation) @@ -5088,7 +5142,7 @@ func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) { } nearTime := midday.Truncate(time.Second) - seedDefaultWorkingHours(t) + req := CreateBookingRequest{ StartTime: nearTime, @@ -5097,7 +5151,7 @@ func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) { } bookingHandler := http.HandlerFunc(CreateBookingHandler) - w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") + w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "", ctx) if w2.Code != http.StatusCreated { t.Errorf("expected guest to bypass 36h advance window, got %d. body: %s", w2.Code, w2.Body.String()) @@ -5109,25 +5163,26 @@ func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) { // ============================================================================= func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -5139,20 +5194,20 @@ func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string - err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) + err = tx.QueryRow(ctx, "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) @@ -5163,25 +5218,26 @@ func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) { } func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -5195,20 +5251,20 @@ func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string - err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) + err = tx.QueryRow(ctx, "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var newBookingCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&newBookingCount) if err != nil { t.Fatalf("failed to query new_booking notifications: %v", err) @@ -5218,7 +5274,7 @@ func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { } var pendingCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to query pending_booking notifications: %v", err) @@ -5229,25 +5285,26 @@ func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { } func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -5259,20 +5316,20 @@ func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string - err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) + err = tx.QueryRow(ctx, "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var pendingCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to query pending_booking notifications: %v", err) @@ -5287,7 +5344,8 @@ func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) // ============================================================================= func TestCreateBooking_ClosingHoursValidation(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) london, err := time.LoadLocation("Europe/London") if err != nil { @@ -5308,26 +5366,26 @@ func TestCreateBooking_ClosingHoursValidation(t *testing.T) { {5, "08:00", "20:00", true}, {6, "08:00", "20:00", true}, } - seedCustomWorkingHours(t, hours) + seedCustomWorkingHours(t, ctx, tx, hours) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - _, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 60 WHERE id = $1", serviceID) + _, err = tx.Exec(ctx, "UPDATE services SET duration_minutes = 60 WHERE id = $1", serviceID) if err != nil { t.Fatalf("failed to set service duration: %v", err) } @@ -5342,7 +5400,7 @@ func TestCreateBooking_ClosingHoursValidation(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) + w1 := makeRequest(handler, "POST", "/api/bookings", req1, token, ctx) if w1.Code != http.StatusCreated { t.Errorf("Thursday 17:30+60min should succeed (ends 18:30 < 20:00), got %d. body: %s", w1.Code, w1.Body.String()) @@ -5355,7 +5413,7 @@ func TestCreateBooking_ClosingHoursValidation(t *testing.T) { ServiceIDs: []string{serviceID}, } - w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) + w2 := makeRequest(handler, "POST", "/api/bookings", req2, token, ctx) if w2.Code != http.StatusBadRequest { t.Errorf("Monday 16:30+60min should fail (ends 17:30 > 17:00), got %d. body: %s", w2.Code, w2.Body.String()) @@ -5367,25 +5425,26 @@ func TestCreateBooking_ClosingHoursValidation(t *testing.T) { } func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) @@ -5396,7 +5455,7 @@ func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) { ServiceIDs: []string{serviceID}, } - w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) + w1 := makeRequest(handler, "POST", "/api/bookings", req1, token, ctx) if w1.Code != http.StatusBadRequest { t.Errorf("expected status 400 for 30-min advance booking, got %d. body: %s", w1.Code, w1.Body.String()) @@ -5413,7 +5472,7 @@ func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) { ServiceIDs: []string{serviceID}, } - w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) + w2 := makeRequest(handler, "POST", "/api/bookings", req2, token, ctx) if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for 2h+ advance booking, got %d. body: %s", w2.Code, w2.Body.String()) @@ -5421,25 +5480,26 @@ func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) { } func TestCreateBooking_ActiveBookingLimit(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) @@ -5451,7 +5511,7 @@ func TestCreateBooking_ActiveBookingLimit(t *testing.T) { ServiceIDs: []string{serviceID}, } - w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) + w1 := makeRequest(handler, "POST", "/api/bookings", req1, token, ctx) if w1.Code != http.StatusCreated { t.Fatalf("expected first booking to succeed, got %d. body: %s", w1.Code, w1.Body.String()) @@ -5469,7 +5529,7 @@ func TestCreateBooking_ActiveBookingLimit(t *testing.T) { ServiceIDs: []string{serviceID}, } - w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) + w2 := makeRequest(handler, "POST", "/api/bookings", req2, token, ctx) if w2.Code != http.StatusConflict { t.Errorf("expected status 409 for second booking with active booking, got %d. body: %s", w2.Code, w2.Body.String()) @@ -5479,13 +5539,13 @@ func TestCreateBooking_ActiveBookingLimit(t *testing.T) { t.Errorf("expected error about active booking, got: %s", w2.Body.String()) } - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", booking1.ID) if err != nil { t.Fatalf("failed to cancel first booking: %v", err) } - w3 := makeRequest(handler, "POST", "/api/bookings", req2, token) + w3 := makeRequest(handler, "POST", "/api/bookings", req2, token, ctx) if w3.Code != http.StatusCreated { t.Errorf("expected status 201 after cancelling active booking, got %d. body: %s", w3.Code, w3.Body.String()) @@ -5531,22 +5591,23 @@ func TestNextWeekdayHelper(t *testing.T) { } func TestCreateBooking_DepositSnapshot(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -5557,7 +5618,7 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { if err != nil { t.Fatalf("Europe/London not available: %v", err) } - bookingTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) + bookingTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) req := CreateBookingRequest{ StartTime: bookingTime, @@ -5565,7 +5626,7 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { } handler := http.HandlerFunc(CreateBookingHandler) - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -5581,7 +5642,7 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { } var depositRequired bool - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -5590,12 +5651,12 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { t.Error("expected deposit_required=true in DB for first booking") } - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to update deposits_required: %v", err) } - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking after user update: %v", err) @@ -5610,7 +5671,7 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { ServiceIDs: []string{serviceID}, } - w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) + w2 := makeRequest(handler, "POST", "/api/bookings", req2, token, ctx) if w2.Code != http.StatusCreated { t.Fatalf("expected status 201 for second booking, got %d. body: %s", w2.Code, w2.Body.String()) } @@ -5625,7 +5686,7 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { } var depositRequired2 bool - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT deposit_required FROM bookings WHERE id = $1", booking2.ID).Scan(&depositRequired2) if err != nil { t.Fatalf("failed to query second booking: %v", err) @@ -5636,31 +5697,32 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { } func TestGetBooking_WithDiscounts(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) // Create completed booking london, _ := time.LoadLocation("Europe/London") bookingTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) - bookingID := createCompletedBookingWithTime(t, userID, serviceID, bookingTime, 50.00) + bookingID := createCompletedBookingWithTime(t, tx, ctx, userID, serviceID, bookingTime, 50.00) // Create a discount campaign and apply it var campaignID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day') RETURNING id @@ -5669,7 +5731,7 @@ func TestGetBooking_WithDiscounts(t *testing.T) { t.Fatalf("failed to create campaign: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00) `, bookingID, userID, campaignID) @@ -5679,7 +5741,7 @@ func TestGetBooking_WithDiscounts(t *testing.T) { // Call GetBookingHandler handler := http.HandlerFunc(GetBookingHandler) - w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) + w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -5703,10 +5765,10 @@ func TestGetBooking_WithDiscounts(t *testing.T) { } } -func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { +func createCompletedBookingWithTime(t *testing.T, tx db.Querier, ctx context.Context, userID, serviceID string, startTime time.Time, price float64) string { t.Helper() var bookingID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id @@ -5715,7 +5777,7 @@ func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, star t.Fatalf("failed to create completed booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, price) @@ -5727,37 +5789,38 @@ func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, star } func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id @@ -5765,9 +5828,9 @@ func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -5775,7 +5838,7 @@ func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { t.Fatalf("failed to link service: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) @@ -5799,7 +5862,7 @@ func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(ConfirmBookingHandler) - w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "", ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -5815,7 +5878,7 @@ func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { var storedOverridePrice *float64 var storedOverrideDuration *int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2 @@ -5832,7 +5895,7 @@ func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { var svcStoredPrice *float64 var svcStoredDuration *int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT override_price, override_duration_minutes FROM booking_services WHERE booking_id = $1 AND service_id = $2 @@ -5849,31 +5912,32 @@ func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { } func TestBookings_GetBooking_WithCustomServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Tuesday, london).Add(10 * time.Hour) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id @@ -5881,9 +5945,9 @@ func TestBookings_GetBooking_WithCustomServices(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) @@ -5893,7 +5957,7 @@ func TestBookings_GetBooking_WithCustomServices(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) - w := makeAuthRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token, "") + w := makeAuthRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token, "", ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -5930,31 +5994,32 @@ func TestBookings_GetBooking_WithCustomServices(t *testing.T) { } func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id @@ -5962,9 +6027,9 @@ func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) @@ -5983,7 +6048,7 @@ func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { {ServiceID: csID, OverridePrice: &negPrice, OverrideDurationMinutes: &validDur}, }, } - w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "", ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for negative price, got %d. body: %s", w.Code, w.Body.String()) } @@ -5997,7 +6062,7 @@ func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { {ServiceID: csID, OverridePrice: &validPrice, OverrideDurationMinutes: &zeroDur}, }, } - w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "", ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for zero duration, got %d. body: %s", w.Code, w.Body.String()) } @@ -6011,7 +6076,7 @@ func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { {ServiceID: csID, OverridePrice: &validPrice, OverrideDurationMinutes: &negDur}, }, } - w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "", ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for negative duration, got %d. body: %s", w.Code, w.Body.String()) } @@ -6019,37 +6084,38 @@ func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { } func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Thursday, london).Add(10 * time.Hour) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id @@ -6057,9 +6123,9 @@ func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -6077,7 +6143,7 @@ func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(ConfirmBookingHandler) - w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "", ctx) if w.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -6088,7 +6154,7 @@ func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { } var status string - err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } @@ -6098,31 +6164,32 @@ func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { } func TestBookings_Progress_WithCustomServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - csID, err := fixtures.CreateTestCustomService(db.DB) + csID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } - defer fixtures.DeleteCustomService(db.DB, csID) + defer fixtures.DeleteCustomService(tx, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Friday, london).Add(10 * time.Hour) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id @@ -6130,9 +6197,9 @@ func TestBookings_Progress_WithCustomServices(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) @@ -6144,7 +6211,7 @@ func TestBookings_Progress_WithCustomServices(t *testing.T) { handler := http.HandlerFunc(ProgressBookingHandler) req := ProgressBookingRequest{Status: "completed"} - w := makeAuthRequest(handler, "PUT", "/api/bookings/"+bookingID+"/progress", req, token, "") + w := makeAuthRequest(handler, "PUT", "/api/bookings/"+bookingID+"/progress", req, token, "", ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -6159,7 +6226,7 @@ func TestBookings_Progress_WithCustomServices(t *testing.T) { } var status string - err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } @@ -6176,39 +6243,40 @@ func TestBookings_Progress_WithCustomServices(t *testing.T) { // cancels a booking that has completed payments, the refund is processed // BEFORE the cancellation, and refund records are created in the DB. func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Use a booking far enough in the future that >72h notice applies (full refund) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } paymentID := bookingID[:8] + "pmt" - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'full', 'in_person_card', 50.00, 'completed', NOW()) `, paymentID, bookingID) @@ -6220,7 +6288,7 @@ func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) { handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} - w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -6228,7 +6296,7 @@ func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) { // Verify booking was cancelled var status string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) @@ -6239,7 +6307,7 @@ func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) { // Verify refund records were created var refundCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) @@ -6259,7 +6327,7 @@ func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) { // Verify admin notification was created var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) @@ -6274,26 +6342,27 @@ func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) { // TestDeleteBooking_NoPayments_HardDelete verifies that when a booking has no // payments, cancelling performs a hard delete (removes the row entirely). func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) { - testutils.SetupTestDB(t) + + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Use next available working hour for booking creation soonTime := nextWorkingHour() @@ -6303,7 +6372,7 @@ func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) { } createHandler := http.HandlerFunc(CreateBookingHandler) token := jwt.GenerateUserToken(userID) - w := makeRequest(createHandler, "POST", "/api/bookings", bookingReq, token) + w := makeRequest(createHandler, "POST", "/api/bookings", bookingReq, token, ctx) var booking Booking if err := parseResponseBody(w, &booking); err != nil { @@ -6312,7 +6381,7 @@ func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) { // Cancel the pending booking (no payments → hard delete) delHandler := http.HandlerFunc(DeleteBookingHandler) - w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, nil, token) + w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -6320,7 +6389,7 @@ func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) { // Verify booking row was hard-deleted var rowCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE id = $1", booking.ID).Scan(&rowCount) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -6338,35 +6407,36 @@ func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) { // user requests an edit on a booking with no payments and >48h until the // appointment, the edit request is auto-approved without admin intervention. func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // >48h from now + no payments → triggers auto-approval - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -6378,7 +6448,7 @@ func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Please add gel polish", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) // Auto-approval returns 200 OK with auto_approved flag if w.Code != http.StatusOK { @@ -6397,7 +6467,7 @@ func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) { // Verify no edit request row exists (was auto-approved and deleted) var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -6408,7 +6478,7 @@ func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) { // Verify no 'edit_requested' notification was created (acknowledged on auto-approve) var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID).Scan(¬ifCount) @@ -6421,7 +6491,7 @@ func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) { // Verify notes were applied to the booking var dbNotes string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(notes, '') FROM bookings WHERE id = $1", bookingID).Scan(&dbNotes) if err != nil { t.Fatalf("failed to query booking notes: %v", err) @@ -6434,35 +6504,36 @@ func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) { // TestRequestEditHandler_AutoApproves_WithTimeChange verifies that auto-approval // correctly updates the start_time when the edit request includes a time change. func TestRequestEditHandler_AutoApproves_WithTimeChange(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // >48h from now, no payments → auto-approval - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -6477,7 +6548,7 @@ func TestRequestEditHandler_AutoApproves_WithTimeChange(t *testing.T) { reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200 (auto-approved), got %d. body: %s", w.Code, w.Body.String()) @@ -6485,7 +6556,7 @@ func TestRequestEditHandler_AutoApproves_WithTimeChange(t *testing.T) { // Verify booking start_time was updated var updatedStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -6496,7 +6567,7 @@ func TestRequestEditHandler_AutoApproves_WithTimeChange(t *testing.T) { // Verify no time_blocker reservation remains (cleaned up on auto-approve) var blockerCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { @@ -6511,40 +6582,41 @@ func TestRequestEditHandler_AutoApproves_WithTimeChange(t *testing.T) { // booking has completed payments, the edit request stays pending for admin // approval regardless of how far in the future the booking is. func TestRequestEditHandler_NoAutoApproval_WithPayments(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Add a payment to the booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 25.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) @@ -6558,7 +6630,7 @@ func TestRequestEditHandler_NoAutoApproval_WithPayments(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Please change the time", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) // Should NOT auto-approve (has payments), so expect 201 Created if w.Code != http.StatusCreated { @@ -6567,7 +6639,7 @@ func TestRequestEditHandler_NoAutoApproval_WithPayments(t *testing.T) { // Verify edit request row exists var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -6581,37 +6653,38 @@ func TestRequestEditHandler_NoAutoApproval_WithPayments(t *testing.T) { // has no payments but is within 48h of the appointment, the edit request stays // pending for admin approval. func TestRequestEditHandler_NoAutoApproval_Within48h(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) // Use a start time within 24h so auto-approval (>=48h) does not fire // Also >24h so the time-change block (>=24h for no-payment bookings) does not fire bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) @@ -6623,14 +6696,14 @@ func TestRequestEditHandler_NoAutoApproval_Within48h(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Please change the service", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201 (pending), got %d. body: %s", w.Code, w.Body.String()) } var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -6641,40 +6714,41 @@ func TestRequestEditHandler_NoAutoApproval_Within48h(t *testing.T) { } func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'loyalty', 10, 5000, 500) `, bookingID, userID) @@ -6690,7 +6764,7 @@ func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) { reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -6698,7 +6772,7 @@ func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) { // Edit request should still exist (not auto-approved) since discounts block it. var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit request: %v", err) @@ -6709,34 +6783,35 @@ func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) { } func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) bookingTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID) + defer fixtures.DeleteBooking(tx, bookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) @@ -6750,7 +6825,7 @@ func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) { reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200 (auto-approved), got %d. body: %s", w.Code, w.Body.String()) @@ -6758,7 +6833,7 @@ func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) { // With no discounts, auto-approve deletes the edit request row. var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit request: %v", err) @@ -6773,26 +6848,27 @@ func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) { // ============================================================================= func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Disable deposit requirement for this user so bookings auto-confirm. - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -6803,14 +6879,14 @@ func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { bookingIDs := make([]string, 5) for i := 0; i < 5; i++ { start := now.Add(time.Duration(48+i*24) * time.Hour) - id, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, start) + id, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } bookingIDs[i] = id - defer fixtures.DeleteBooking(db.DB, id) + defer fixtures.DeleteBooking(tx, id) // Stagger created_at so cursor (created_at DESC, id DESC) is predictable. - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "UPDATE bookings SET created_at = $1 WHERE id = $2", now.Add(time.Duration(i)*time.Second), id) } @@ -6818,7 +6894,7 @@ func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { handler := http.HandlerFunc(GetAllUserBookingsHandler) // Page 1: fetch 2 items, expect nextCursor - w1 := makeRequest(handler, "GET", "/api/bookings?per_page=2", nil, token) + w1 := makeRequest(handler, "GET", "/api/bookings?per_page=2", nil, token, ctx) if w1.Code != http.StatusOK { t.Fatalf("page 1 expected 200, got %d: %s", w1.Code, w1.Body.String()) } @@ -6835,7 +6911,7 @@ func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { // Page 2: use cursor from page 1 (URL-encode to protect '+' in timezone offset). page2URL := "/api/bookings?per_page=2&cursor=" + url.QueryEscape(*page1.NextCursor) - w2 := makeRequest(handler, "GET", page2URL, nil, token) + w2 := makeRequest(handler, "GET", page2URL, nil, token, ctx) if w2.Code != http.StatusOK { t.Fatalf("page 2 expected 200, got %d: %s", w2.Code, w2.Body.String()) } @@ -6852,7 +6928,7 @@ func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { // Page 3: use cursor from page 2 — this is the last page page3URL := "/api/bookings?per_page=2&cursor=" + url.QueryEscape(*page2.NextCursor) - w3 := makeRequest(handler, "GET", page3URL, nil, token) + w3 := makeRequest(handler, "GET", page3URL, nil, token, ctx) if w3.Code != http.StatusOK { t.Fatalf("page 3 expected 200, got %d: %s", w3.Code, w3.Body.String()) } @@ -6894,26 +6970,27 @@ func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { // ============================================================================= func TestGetAllUserBookings_TotalCountMatches(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) + defer fixtures.DeleteUser(tx, userID) // Disable deposit requirement so bookings auto-confirm. - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) + defer fixtures.DeleteService(tx, serviceID) token := jwt.GenerateUserToken(userID) @@ -6921,13 +6998,13 @@ func TestGetAllUserBookings_TotalCountMatches(t *testing.T) { now := time.Now().In(time.UTC).Truncate(time.Second) for i := 0; i < 3; i++ { start := now.Add(time.Duration(72+i*24) * time.Hour) - id, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, start) + id, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } - defer fixtures.DeleteBooking(db.DB, id) + defer fixtures.DeleteBooking(tx, id) // Stagger created_at so cursor-ordering is deterministic. - _, _ = db.DB.Exec(context.Background(), + _, _ = tx.Exec(ctx, "UPDATE bookings SET created_at = $1 WHERE id = $2", now.Add(time.Duration(i)*time.Second), id) } @@ -6935,7 +7012,7 @@ func TestGetAllUserBookings_TotalCountMatches(t *testing.T) { handler := http.HandlerFunc(GetAllUserBookingsHandler) // Fetch all bookings with per_page=10 (fits all). - w := makeRequest(handler, "GET", "/api/bookings?per_page=10", nil, token) + w := makeRequest(handler, "GET", "/api/bookings?per_page=10", nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } @@ -6954,7 +7031,7 @@ func TestGetAllUserBookings_TotalCountMatches(t *testing.T) { } // Fetch with per_page=2 — should still report total=3. - w2 := makeRequest(handler, "GET", "/api/bookings?per_page=2", nil, token) + w2 := makeRequest(handler, "GET", "/api/bookings?per_page=2", nil, token, ctx) if w2.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String()) } @@ -6973,7 +7050,7 @@ func TestGetAllUserBookings_TotalCountMatches(t *testing.T) { } // Page 2: still total=3 but only 1 booking. - w3 := makeRequest(handler, "GET", "/api/bookings?per_page=2&cursor="+url.QueryEscape(*resp2.NextCursor), nil, token) + w3 := makeRequest(handler, "GET", "/api/bookings?per_page=2&cursor="+url.QueryEscape(*resp2.NextCursor), nil, token, ctx) if w3.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w3.Code, w3.Body.String()) } diff --git a/backend/handlers/bookings/dedup_test.go b/backend/handlers/bookings/dedup_test.go index 8876c82..f1efddc 100644 --- a/backend/handlers/bookings/dedup_test.go +++ b/backend/handlers/bookings/dedup_test.go @@ -19,23 +19,23 @@ import ( // ProgressBookingHandler — Dedup guards for early-payment discounts // ============================================================================= -func setupDedupTest(t *testing.T) (string, string, string) { +func setupDedupTest(t *testing.T, tx db.Querier, ctx context.Context) (string, string, string) { t.Helper() - seedDefaultWorkingHours(t) + - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) now := time.Now() startTime := now.Add(72 * time.Hour) - bookingID := createPendingBooking(t, userID, serviceID, startTime) + bookingID := createPendingBooking(t, userID, serviceID, startTime, tx, ctx) return userID, serviceID, bookingID } -func insertPaymentForBooking(t *testing.T, bookingID, userID string, amount int) { +func insertPaymentForBooking(t *testing.T, bookingID, userID string, amount int, tx db.Querier, ctx context.Context) { t.Helper() - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'cash', $2, 'completed', NOW(), NOW()) `, bookingID, amount) @@ -43,88 +43,91 @@ func insertPaymentForBooking(t *testing.T, bookingID, userID string, amount int) } func TestProgressBooking_LoyaltyDedup(t *testing.T) { - testutils.SetupTestDB(t) - userID, _, bookingID := setupDedupTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, _, bookingID := setupDedupTest(t, tx, ctx) // Pre-apply loyalty discount (simulating early-payment) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'loyalty', 10, 5000, 500) `, bookingID, userID) require.NoError(t, err) - insertPaymentForBooking(t, bookingID, userID, 5000) + insertPaymentForBooking(t, bookingID, userID, 5000, tx, ctx) // Complete the booking — should not double-apply loyalty - completeBooking(t, bookingID) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Loyalty should not be double-applied at completion") } func TestProgressBooking_TimeBasedCampaignDedup(t *testing.T) { - testutils.SetupTestDB(t) - userID, _, bookingID := setupDedupTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, _, bookingID := setupDedupTest(t, tx, ctx) // Create a time-based campaign campaignName := "Time Dedup Test" - campaign := createTestCampaign(t, campaignName, "time_based", 10.0, nil, nil, nil, nil) + campaign := createTestCampaign(t, campaignName, "time_based", 10.0, nil, nil, nil, nil, tx, ctx) // Pre-apply the campaign (simulating early-payment auto-apply) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500) `, bookingID, userID, campaign) require.NoError(t, err) // Increment campaign's times_redeemed as if payment-time applied it - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1 `, campaign) require.NoError(t, err) - insertPaymentForBooking(t, bookingID, userID, 5000) + insertPaymentForBooking(t, bookingID, userID, 5000, tx, ctx) // Complete the booking - completeBooking(t, bookingID) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Time-based campaign should not be double-applied at completion") } func TestProgressBooking_UserMilestoneDedup(t *testing.T) { - testutils.SetupTestDB(t) - userID, _, bookingID := setupDedupTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, _, bookingID := setupDedupTest(t, tx, ctx) // Give user 5 completed bookings - svcID := createTestService(t, 30.00) + svcID := createTestService(t, 30.00, tx, ctx) for i := 0; i < 5; i++ { - bid := createPendingBooking(t, userID, svcID, time.Now().Add(-time.Duration(30-i)*24*time.Hour)) - insertPaymentForBooking(t, bid, userID, 3000) - completeBooking(t, bid) + bid := createPendingBooking(t, userID, svcID, time.Now().Add(-time.Duration(30-i)*24*time.Hour), tx, ctx) + insertPaymentForBooking(t, bid, userID, 3000, tx, ctx) + completeBooking(t, bid, ctx) } val := 5 maxRed := 1 - campaign := createTestCampaign(t, "5th Visit", "milestone", 15.0, strPtr("per_user_booking_count"), strPtr("bookings"), &val, &maxRed) + campaign := createTestCampaign(t, "5th Visit", "milestone", 15.0, strPtr("per_user_booking_count"), strPtr("bookings"), &val, &maxRed, tx, ctx) // Pre-apply the milestone (simulating early-payment) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'milestone', 15, 5000, 750) `, bookingID, userID, campaign) require.NoError(t, err) // Mark the campaign as already having 1 redemption - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1 `, campaign) require.NoError(t, err) - insertPaymentForBooking(t, bookingID, userID, 5000) - completeBooking(t, bookingID) + insertPaymentForBooking(t, bookingID, userID, 5000, tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "User milestone should not be double-applied at completion") } @@ -133,106 +136,110 @@ func TestProgressBooking_UserMilestoneDedup(t *testing.T) { // ============================================================================= func TestNoShowApplyDepositsIfNeeded(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) // Create 2 no-show bookings (confirmed bookings cancelled <24h before start) now := time.Now() for i := 0; i < 2; i++ { - bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour)) - insertPaymentForBooking(t, bid, userID, 5000) + bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour), tx, ctx) + insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` UPDATE bookings SET status = 'no_show' WHERE id = $1 `, bid) require.NoError(t, err) } // Run ApplyDepositsIfNeeded - applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + applied, err := ApplyDepositsIfNeeded(ctx, userID) require.NoError(t, err) assert.True(t, applied, "Should have applied deposits_required = 3 after 2 no-shows") var depositsRequired int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) assert.Equal(t, 3, depositsRequired, "Expected deposits_required = 3 after 2 no-shows in 6 months") } func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour)) - insertPaymentForBooking(t, bid, userID, 5000) + bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour), tx, ctx) + insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) - _, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) + _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) require.NoError(t, err) - applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + applied, err := ApplyDepositsIfNeeded(ctx, userID) require.NoError(t, err) assert.False(t, applied, "Single no-show should not trigger deposits_required") var depositsRequired int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) assert.Equal(t, 0, depositsRequired, "Expected deposits_required = 0 with only 1 no-show") } func TestNoShowOldNoShowsExcluded(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) // Create a no-show more than 6 months ago — should not count - oldBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-200*24*time.Hour)) - insertPaymentForBooking(t, oldBid, userID, 5000) + oldBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-200*24*time.Hour), tx, ctx) + insertPaymentForBooking(t, oldBid, userID, 5000, tx, ctx) - _, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid) + _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid) require.NoError(t, err) // Create a recent no-show (within 6 months) - recentBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour)) - insertPaymentForBooking(t, recentBid, userID, 5000) + recentBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour), tx, ctx) + insertPaymentForBooking(t, recentBid, userID, 5000, tx, ctx) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid) require.NoError(t, err) // Should only count 1 recent no-show, not trigger - applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + applied, err := ApplyDepositsIfNeeded(ctx, userID) require.NoError(t, err) assert.False(t, applied, "1 old + 1 recent = 2 total but only 1 in 6-month window") } func TestNoShowForgivenExcluded(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) for i := 0; i < 2; i++ { - bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i)*time.Hour)) - insertPaymentForBooking(t, bid, userID, 5000) - _, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) + bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i)*time.Hour), tx, ctx) + insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) + _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) require.NoError(t, err) // Forgive the first one if i == 0 { - _, err = db.DB.Exec(context.Background(), "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bid) + _, err = tx.Exec(ctx, "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bid) require.NoError(t, err) } } - applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + applied, err := ApplyDepositsIfNeeded(ctx, userID) require.NoError(t, err) assert.False(t, applied, "1 forgiven + 1 unforgiven = should not trigger") } @@ -242,47 +249,48 @@ func TestNoShowForgivenExcluded(t *testing.T) { // ============================================================================= func TestThreePaidBookingsClearNoShows(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) // Set deposits_required to 3 (simulating after 2 no-shows triggered it) - _, err := db.DB.Exec(context.Background(), + _, err := tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) require.NoError(t, err) // Create 2 no-show records for i := 0; i < 2; i++ { - bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i+1)*time.Hour)) - insertPaymentForBooking(t, bid, userID, 5000) - _, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) + bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i+1)*time.Hour), tx, ctx) + insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) + _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) require.NoError(t, err) } // Complete 3 paid bookings — each should decrement deposits_required for i := 0; i < 3; i++ { - bid := createPendingBooking(t, userID, serviceID, time.Now().Add(time.Duration(i+1)*time.Hour)) - insertPaymentForBooking(t, bid, userID, 5000) - completeBooking(t, bid) + bid := createPendingBooking(t, userID, serviceID, time.Now().Add(time.Duration(i+1)*time.Hour), tx, ctx) + insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) + completeBooking(t, bid, ctx) } // After 3 completions, deposits_required should be 0 var depositsRequired int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) assert.Equal(t, 0, depositsRequired, "Expected 0 after 3 paid bookings from 3") // No-show records should be forgiven (inserted into forgiven_no_shows) var forgivenCount int - db.DB.QueryRow(context.Background(), ` + tx.QueryRow(ctx, ` SELECT COUNT(*) FROM forgiven_no_shows fns JOIN bookings b ON b.id = fns.booking_id WHERE b.user_id = $1 `, userID).Scan(&forgivenCount) // Try again — should NOT trigger deposits_required again since no-shows are forgiven - applied, _ := ApplyDepositsIfNeeded(context.Background(), userID) + applied, _ := ApplyDepositsIfNeeded(ctx, userID) assert.False(t, applied, "Should not trigger: no-shows were forgiven after 3 paid bookings") } diff --git a/backend/handlers/bookings/deposit_test.go b/backend/handlers/bookings/deposit_test.go index 8078d68..7d1c8b6 100644 --- a/backend/handlers/bookings/deposit_test.go +++ b/backend/handlers/bookings/deposit_test.go @@ -29,7 +29,6 @@ func boolPtr(b bool) *bool { // ============================================================================= func TestPopulateDepositFields_ProtectedAmountCappedAt50Pct(t *testing.T) { - testutils.SetupTestDB(t) b := &Booking{ TotalAmount: 200, @@ -49,7 +48,6 @@ func TestPopulateDepositFields_ProtectedAmountCappedAt50Pct(t *testing.T) { } func TestPopulateDepositFields_ProtectedAmountEqualsPaidWhenUnder50Pct(t *testing.T) { - testutils.SetupTestDB(t) b := &Booking{ TotalAmount: 200, @@ -69,7 +67,6 @@ func TestPopulateDepositFields_ProtectedAmountEqualsPaidWhenUnder50Pct(t *testin } func TestPopulateDepositFields_DepositPaidWhenMet(t *testing.T) { - testutils.SetupTestDB(t) b := &Booking{ TotalAmount: 200, @@ -88,7 +85,6 @@ func TestPopulateDepositFields_DepositPaidWhenMet(t *testing.T) { } func TestPopulateDepositFields_NoDepositRequired(t *testing.T) { - testutils.SetupTestDB(t) b := &Booking{ TotalAmount: 100, @@ -107,7 +103,6 @@ func TestPopulateDepositFields_NoDepositRequired(t *testing.T) { } func TestPopulateDepositFields_DeadlineSet(t *testing.T) { - testutils.SetupTestDB(t) b := &Booking{ TotalAmount: 100, @@ -134,39 +129,35 @@ func TestPopulateDepositFields_DeadlineSet(t *testing.T) { // ============================================================================= func TestRequestEditHandler_NoticePeriod_BlocksPaymentUnder72h(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking starting in 2 hours (<48h) soon := time.Now().Add(2 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Add a payment so hasPayments = true - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed") + _, err = fixtures.CreateTestPayment(tx, 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) }) token := jwt.GenerateUserToken(userID) newTime := soon.Add(48 * time.Hour) @@ -174,7 +165,7 @@ func TestRequestEditHandler_NoticePeriod_BlocksPaymentUnder72h(t *testing.T) { handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newTime.Format(time.RFC3339), - }, token) + }, token, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected 403 for <48h with payments, got %d: %s", w.Code, w.Body.String()) @@ -182,34 +173,31 @@ func TestRequestEditHandler_NoticePeriod_BlocksPaymentUnder72h(t *testing.T) { } func TestRequestEditHandler_NoticePeriod_BlocksNoPaymentUnder24h(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking starting in 2 hours (<24h, no payments) soon := time.Now().Add(2 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -220,7 +208,7 @@ func TestRequestEditHandler_NoticePeriod_BlocksNoPaymentUnder24h(t *testing.T) { handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newTime.Format(time.RFC3339), - }, token) + }, token, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected 403 for <24h without payments, got %d: %s", w.Code, w.Body.String()) @@ -230,36 +218,32 @@ func TestRequestEditHandler_NoticePeriod_BlocksNoPaymentUnder24h(t *testing.T) { func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) { // AdminCreateBookingForUserHandler evicts any pending_release booking that // overlaps the new booking's time slot before creating the booking. - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Create a booking that will end up in pending_release (deposit not paid). future := time.Now().Add(48 * time.Hour) - existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, existingBookingID) }) // Set it to pending_release (as if deposit deadline passed). - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) if err != nil { t.Fatalf("failed to set status to pending_release: %v", err) } @@ -272,10 +256,10 @@ func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) { } handler := AdminCreateBookingForUserHandler - w := serveChiHandler(handler, "POST", "/", "/", body, func(ctx context.Context) context.Context { - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - return ctx + w := serveChiHandler(handler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusCreated && w.Code != http.StatusOK { @@ -284,7 +268,7 @@ func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) { // Verify the old booking was evicted to deposit_lapsed. var newStatus string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query existing booking: %v", err) @@ -295,35 +279,31 @@ func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) { } func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Create a confirmed booking far enough away that the reschedule is valid. future := time.Now().Add(96 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -335,10 +315,10 @@ func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) { "forgive_fees": true, } - w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", body, func(ctx context.Context) context.Context { - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - return ctx + w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", body, func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { @@ -347,7 +327,7 @@ func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) { // Verify the booking was rescheduled. var newStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&newStartTime) if err != nil { t.Fatalf("failed to query rescheduled booking: %v", err) @@ -355,7 +335,7 @@ func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) { // Verify forgiven_no_shows record was created. var forgivenCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM forgiven_no_shows WHERE booking_id = $1", bookingID).Scan(&forgivenCount) if err != nil { t.Fatalf("failed to query forgiven_no_shows: %v", err) @@ -366,35 +346,31 @@ func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) { } func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking starting in 48 hours (24-72h window, no payments). midRange := time.Now().Add(48 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, midRange) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -405,7 +381,7 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) { handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newTime.Format(time.RFC3339), - }, token) + }, token, ctx) // With 48h remaining and no payments: allowed (no 403), but warning header set. if w.Code != http.StatusCreated && w.Code != http.StatusOK { @@ -422,35 +398,31 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) { } func TestRequestEditHandler_NoticePeriod_AllowsWhenEnoughNotice(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking starting in 36 hours (within 48h threshold so auto-approval does not fire) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -461,7 +433,7 @@ func TestRequestEditHandler_NoticePeriod_AllowsWhenEnoughNotice(t *testing.T) { handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newTime.Format(time.RFC3339), - }, token) + }, token, ctx) if w.Code != http.StatusCreated { t.Errorf("expected 201 when enough notice, got %d: %s", w.Code, w.Body.String()) @@ -473,45 +445,41 @@ func TestRequestEditHandler_NoticePeriod_AllowsWhenEnoughNotice(t *testing.T) { // ============================================================================= func TestDeleteBookingHandler_RefundResponse(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking in the far future (full refund expected) farFuture := time.Now().Add(200 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, farFuture) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{ "reason": "client_cancelled", - }, token) + }, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -534,45 +502,41 @@ func TestDeleteBookingHandler_RefundResponse(t *testing.T) { } func TestDeleteBookingHandler_NoRefundForUnder24h(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking starting in 1 hour (<24h) soon := time.Now().Add(1 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{ "reason": "client_cancelled", - }, token) + }, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -599,41 +563,44 @@ func TestDeleteBookingHandler_NoRefundForUnder24h(t *testing.T) { // ============================================================================= func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking starting in 1 hour (<24h, normally no refund) soon := time.Now().Add(1 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) - w := serveAdminHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{ + w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{ "forgive_fees": true, + }, func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + adminToken := jwt.GenerateAdminToken() + if info := extractUserFromTestJWT(adminToken); info != nil { + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID) + } + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { @@ -657,51 +624,47 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) { } func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) // Create a real admin user so the refund record FK on created_by is satisfied. - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Booking starting far in the future (>72h — full refund tier without forgiveness). farFuture := time.Now().Add(200 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, farFuture) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 80, "online_square", "full", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 80, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) // Cancel WITHOUT forgive_fees — ProcessCancellationRefund should fire. // Use a chi router with a real admin token to satisfy the refund FK. - w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{}, func(ctx context.Context) context.Context { - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - return ctx + w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{}, func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { @@ -726,7 +689,7 @@ func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) { // Verify a refund record was created. var refundCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) @@ -737,34 +700,38 @@ func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) { } func TestAdminCancelBookingHandler_ForgiveNoShow(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } - w := serveAdminHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{ + w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{ "forgive_noshow": true, + }, func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + adminToken := jwt.GenerateAdminToken() + if info := extractUserFromTestJWT(adminToken); info != nil { + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID) + } + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { @@ -772,7 +739,7 @@ func TestAdminCancelBookingHandler_ForgiveNoShow(t *testing.T) { } var count int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM forgiven_no_shows WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Fatalf("failed to query forgiven_no_shows: %v", err) @@ -787,29 +754,25 @@ func TestAdminCancelBookingHandler_ForgiveNoShow(t *testing.T) { // ============================================================================= func TestAdminRescheduleBookingHandler_NormalReschedule(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Now().Add(100*time.Hour)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -821,7 +784,7 @@ func TestAdminRescheduleBookingHandler_NormalReschedule(t *testing.T) { w := makeRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{ "start_time": newTime.Format(time.RFC3339), - }, adminToken) + }, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -832,34 +795,30 @@ func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) { // forgive_fees on a reschedule records an audit log but should not create // an admin_notification (admins know what they did). This test verifies // the reschedule succeeds with the flag present. - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) future := time.Now().Add(96 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -868,10 +827,10 @@ func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) { w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{ "start_time": newTime.Format(time.RFC3339), "forgive_fees": true, - }, func(ctx context.Context) context.Context { - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - return ctx + }, func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { @@ -880,7 +839,7 @@ func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) { // Verify the booking was rescheduled to the new time (DB truncates to seconds). var actualStart time.Time - db.DB.QueryRow(context.Background(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&actualStart) + tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&actualStart) expectedStart := newTime.Truncate(time.Second) if !actualStart.Equal(expectedStart) { t.Errorf("expected start_time %v, got %v", expectedStart, actualStart) @@ -888,7 +847,7 @@ func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) { // No admin_notification should be created for the admin's own action. var notifCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1", bookingID).Scan(¬ifCount) if notifCount != 0 { t.Errorf("expected 0 admin_notifications (admin action), got %d", notifCount) @@ -896,32 +855,28 @@ func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) { } func TestAdminRescheduleBookingHandler_MissingAuth_Returns401(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Now().Add(100*time.Hour)) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Now().Add(100*time.Hour)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) // Send request WITHOUT admin auth context. w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{ "start_time": time.Now().Add(200 * time.Hour).Format(time.RFC3339), - }, func(ctx context.Context) context.Context { - return ctx + }, func(baseCtx context.Context) context.Context { + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusUnauthorized { @@ -952,25 +907,22 @@ func TestPopulateDepositFields_NegativeAmount_Safeguarded(t *testing.T) { // ============================================================================= func TestCreateBooking_DepositAdvanceWindow_BlocksUnder36h(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) @@ -983,7 +935,7 @@ func TestCreateBooking_DepositAdvanceWindow_BlocksUnder36h(t *testing.T) { ServiceIDs: []string{serviceID}, } - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for booking within deposit advance window, got %d. body: %s", w.Code, w.Body.String()) @@ -994,25 +946,22 @@ func TestCreateBooking_DepositAdvanceWindow_BlocksUnder36h(t *testing.T) { } func TestCreateBooking_DepositAdvanceWindow_AllowsOver36h(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) @@ -1022,14 +971,14 @@ func TestCreateBooking_DepositAdvanceWindow_AllowsOver36h(t *testing.T) { t.Fatalf("Europe/London not available: %v", err) } - // Use next Monday at 10:00 — always >48h from now, well past the 36h window. - farTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) + // Use next Wednesday at 10:00 — always >72h from now, well past the 36h window. + farTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) req := CreateBookingRequest{ StartTime: farTime, ServiceIDs: []string{serviceID}, } - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected 201 for booking outside deposit advance window, got %d. body: %s", w.Code, w.Body.String()) @@ -1037,26 +986,23 @@ func TestCreateBooking_DepositAdvanceWindow_AllowsOver36h(t *testing.T) { } func TestCreateBooking_DepositAdvanceWindow_SkipsWhenNoDepositRequired(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) // deposits_required = 0 — advance window should not apply - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) @@ -1073,7 +1019,7 @@ func TestCreateBooking_DepositAdvanceWindow_SkipsWhenNoDepositRequired(t *testin ServiceIDs: []string{serviceID}, } - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected 201 when no deposits required, got %d. body: %s", w.Code, w.Body.String()) @@ -1085,25 +1031,22 @@ func TestCreateBooking_DepositAdvanceWindow_SkipsWhenNoDepositRequired(t *testin // ============================================================================= func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) @@ -1115,14 +1058,13 @@ func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { // Create a booking far in the future that we'll mark as pending_release. farTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) - existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, farTime) + existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, existingBookingID) // Mark the existing booking as pending_release (deposit deadline passed). - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) if err != nil { t.Fatalf("failed to set existing booking to pending_release: %v", err) } @@ -1133,7 +1075,7 @@ func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { ServiceIDs: []string{serviceID}, } - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected 201 for new booking, got %d. body: %s", w.Code, w.Body.String()) @@ -1141,7 +1083,7 @@ func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { // Verify the old booking was evicted to deposit_lapsed. var newStatus string - err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query existing booking status: %v", err) } @@ -1153,25 +1095,22 @@ func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { } func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) @@ -1183,13 +1122,12 @@ func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T // Create a pending_release booking at time A. timeA := nextWeekday(time.Monday, london).Add(10 * time.Hour) - existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, timeA) + existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, timeA) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, existingBookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) if err != nil { t.Fatalf("failed to set existing booking to pending_release: %v", err) } @@ -1201,7 +1139,7 @@ func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T ServiceIDs: []string{serviceID}, } - w := makeRequest(handler, "POST", "/api/bookings", req, token) + w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected 201 for non-overlapping booking, got %d. body: %s", w.Code, w.Body.String()) @@ -1209,7 +1147,7 @@ func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T // Verify the old pending_release booking was NOT evicted. var newStatus string - err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query existing booking status: %v", err) } @@ -1223,31 +1161,27 @@ func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T // ============================================================================= func TestEvictPendingReleaseOverlapping_Basic(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create a booking far in the future. future := time.Now().Add(72 * time.Hour) - existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) if err != nil { t.Fatalf("failed to create existing booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, existingBookingID) // Mark it as pending_release. - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) if err != nil { t.Fatalf("failed to set pending_release: %v", err) @@ -1256,20 +1190,10 @@ func TestEvictPendingReleaseOverlapping_Basic(t *testing.T) { // Call the shared eviction function with an overlapping slot. // Service is 60 min, so [future, future+60min] is the existing slot. // New slot [future+30min, future+90min] overlaps -> should evict. - ctx := context.Background() - tx, err := db.DB.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - defer tx.Rollback(ctx) - - evicted, err := EvictPendingReleaseOverlapping(ctx, tx, future.Add(30*time.Minute), future.Add(90*time.Minute)) + evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute)) if err != nil { t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err) } - if err := tx.Commit(ctx); err != nil { - t.Fatalf("failed to commit tx: %v", err) - } if len(evicted) != 1 { t.Fatalf("expected 1 evicted booking, got %d", len(evicted)) @@ -1282,7 +1206,7 @@ func TestEvictPendingReleaseOverlapping_Basic(t *testing.T) { } var newStatus string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query booking status: %v", err) @@ -1293,29 +1217,25 @@ func TestEvictPendingReleaseOverlapping_Basic(t *testing.T) { } func TestEvictPendingReleaseOverlapping_PaymentLockGuard(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) future := time.Now().Add(72 * time.Hour) - existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, existingBookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) if err != nil { t.Fatalf("failed to set pending_release: %v", err) @@ -1323,7 +1243,7 @@ func TestEvictPendingReleaseOverlapping_PaymentLockGuard(t *testing.T) { // Insert a PAYMENT_IN_FLIGHT time_blocker for this booking — should // prevent eviction even though the slot overlaps. - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES (NOW(), 5, $1, $2) `, "PAYMENT_IN_FLIGHT:"+existingBookingID, userID) @@ -1331,18 +1251,10 @@ func TestEvictPendingReleaseOverlapping_PaymentLockGuard(t *testing.T) { t.Fatalf("failed to create PAYMENT_IN_FLIGHT blocker: %v", err) } - ctx := context.Background() - tx, err := db.DB.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - defer tx.Rollback(ctx) - - evicted, err := EvictPendingReleaseOverlapping(ctx, tx, future.Add(30*time.Minute), future.Add(90*time.Minute)) + evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute)) if err != nil { t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err) } - tx.Rollback(ctx) if len(evicted) != 0 { t.Errorf("expected 0 evicted bookings (PAYMENT_IN_FLIGHT guard), got %d", len(evicted)) @@ -1350,7 +1262,7 @@ func TestEvictPendingReleaseOverlapping_PaymentLockGuard(t *testing.T) { // Verify the booking was NOT evicted. var newStatus string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query booking status: %v", err) @@ -1361,54 +1273,42 @@ func TestEvictPendingReleaseOverlapping_PaymentLockGuard(t *testing.T) { } func TestEvictPendingReleaseOverlapping_NoOverlap(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) future := time.Now().Add(72 * time.Hour) - existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) if err != nil { t.Fatalf("failed to create booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, existingBookingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) if err != nil { t.Fatalf("failed to set pending_release: %v", err) } // Call eviction with a slot that does NOT overlap [future, future+60min]. - ctx := context.Background() - tx, err := db.DB.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - defer tx.Rollback(ctx) - - evicted, err := EvictPendingReleaseOverlapping(ctx, tx, future.Add(120*time.Minute), future.Add(180*time.Minute)) + evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(120*time.Minute), future.Add(180*time.Minute)) if err != nil { t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err) } - tx.Rollback(ctx) if len(evicted) != 0 { t.Errorf("expected 0 evicted bookings (no overlap), got %d", len(evicted)) } var newStatus string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query booking status: %v", err) @@ -1423,48 +1323,43 @@ func TestEvictPendingReleaseOverlapping_NoOverlap(t *testing.T) { // ============================================================================= func TestConfirmBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create a pending_release booking at a far-future time slot. future := time.Now().Add(72 * time.Hour) - pendingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) if err != nil { t.Fatalf("failed to create pending booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, pendingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingID) if err != nil { t.Fatalf("failed to set pending_release: %v", err) } // Create a second booking at the same time slot that we'll confirm. - confirmedID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + confirmedID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) if err != nil { t.Fatalf("failed to create confirmable booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, confirmedID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending' WHERE id = $1", confirmedID) if err != nil { t.Fatalf("failed to set pending: %v", err) @@ -1473,10 +1368,10 @@ func TestConfirmBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { // Confirm the second booking — should evict the pending_release one. handler := ConfirmBookingHandler w := serveChiHandler(handler, "POST", "/"+confirmedID+"/confirm", "/{id}/confirm", map[string]interface{}{}, - func(ctx context.Context) context.Context { - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - return ctx + func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { @@ -1485,7 +1380,7 @@ func TestConfirmBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { // Verify the pending_release booking was evicted to deposit_lapsed. var newStatus string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query evicted booking: %v", err) @@ -1496,25 +1391,22 @@ func TestConfirmBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { } func TestAdminRescheduleBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Create a pending_release booking at a specific time slot. london, err := time.LoadLocation("Europe/London") @@ -1523,26 +1415,24 @@ func TestAdminRescheduleBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { } slotTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) - pendingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, slotTime) + pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, slotTime) if err != nil { t.Fatalf("failed to create pending booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, pendingID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingID) if err != nil { t.Fatalf("failed to set pending_release: %v", err) } // Create a confirmed booking to reschedule INTO the pending_release slot. - rescheduleID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, slotTime.Add(-48*time.Hour)) + rescheduleID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, slotTime.Add(-48*time.Hour)) if err != nil { t.Fatalf("failed to create reschedule booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, rescheduleID) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", rescheduleID) if err != nil { t.Fatalf("failed to confirm reschedule booking: %v", err) @@ -1555,10 +1445,10 @@ func TestAdminRescheduleBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { handler := AdminRescheduleBookingHandler w := serveChiHandler(handler, "PUT", "/"+rescheduleID+"/reschedule", "/{id}/reschedule", body, - func(ctx context.Context) context.Context { - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - ctx = context.WithValue(ctx, mw.UserIDKey, adminID) - return ctx + func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusOK { @@ -1567,7 +1457,7 @@ func TestAdminRescheduleBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { // Verify the pending_release booking was evicted. var newStatus string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingID).Scan(&newStatus) if err != nil { t.Fatalf("failed to query evicted booking: %v", err) diff --git a/backend/handlers/bookings/discount_test.go b/backend/handlers/bookings/discount_test.go index 0185804..f7d1d68 100644 --- a/backend/handlers/bookings/discount_test.go +++ b/backend/handlers/bookings/discount_test.go @@ -24,7 +24,7 @@ import ( "github.com/stretchr/testify/require" ) -func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder { +func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -45,7 +45,8 @@ func makeProgressRequest(handler http.HandlerFunc, method, path string, body int break } } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) if token != "" { ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001") @@ -59,24 +60,23 @@ func makeProgressRequest(handler http.HandlerFunc, method, path string, body int return w } -func createTestUser(t *testing.T, stamps int) string { +func createTestUser(t *testing.T, stamps int, q db.Querier, ctx context.Context) string { t.Helper() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(q) require.NoError(t, err) if stamps > 0 { - _, err := db.DB.Exec(context.Background(), "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID) + _, err := q.Exec(ctx, "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID) require.NoError(t, err) } return userID } -func createTestService(t *testing.T, price float64) string { +func createTestService(t *testing.T, price float64, q db.Querier, ctx context.Context) string { t.Helper() - ctx := context.Background() var serviceID string - err := db.DB.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id @@ -85,15 +85,14 @@ func createTestService(t *testing.T, price float64) string { return serviceID } -func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string { +func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string { t.Helper() - ctx := context.Background() var id string now := time.Now() startDate := now.Add(-24 * time.Hour) endDate := now.Add(24 * time.Hour) - err := db.DB.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed) VALUES ($1, $2, $3, 'active', $4, $5, $6, $7, $8, $9, 0) RETURNING id @@ -102,27 +101,26 @@ func createTestCampaign(t *testing.T, name, campaignType string, percent float64 return id } -func insertInPersonCardPayment(t *testing.T, bookingID string) { +func insertInPersonCardPayment(t *testing.T, bookingID string, ctx context.Context) { t.Helper() - _, err := db.DB.Exec(context.Background(), ` + _, err := db.Conn.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW()) `, bookingID) require.NoError(t, err) } -func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { +func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64, ctx context.Context) string { t.Helper() - ctx := context.Background() var bookingID string - err := db.DB.QueryRow(ctx, ` + err := db.Conn.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, startTime).Scan(&bookingID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, ` + _, err = db.Conn.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, price) @@ -131,18 +129,17 @@ func createCompletedBooking(t *testing.T, userID, serviceID string, startTime ti return bookingID } -func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string { +func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time, q db.Querier, ctx context.Context) string { t.Helper() - ctx := context.Background() var bookingID string - err := db.DB.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, startTime).Scan(&bookingID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, ` + _, err = q.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -151,44 +148,44 @@ func createPendingBooking(t *testing.T, userID, serviceID string, startTime time return bookingID } -func completeBooking(t *testing.T, bookingID string) *httptest.ResponseRecorder { +func completeBooking(t *testing.T, bookingID string, ctx context.Context) *httptest.ResponseRecorder { t.Helper() progressReq := ProgressBookingRequest{Status: "completed"} handler := http.HandlerFunc(ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token", ctx) require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") return w } -func backdateBooking(t *testing.T, bookingID string, daysAgo int) { +func backdateBooking(t *testing.T, bookingID string, daysAgo int, ctx context.Context) { t.Helper() if daysAgo > 0 { - _, err := db.DB.Exec(context.Background(), ` + _, err := db.Conn.Exec(ctx, ` UPDATE bookings SET updated_at = NOW() - INTERVAL '1 day' * $1 WHERE id = $2 `, daysAgo, bookingID) require.NoError(t, err) } } -func getStamps(t *testing.T, userID string) int { +func getStamps(t *testing.T, userID string, ctx context.Context) int { t.Helper() var stamps int - err := db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) + err := db.Conn.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) require.NoError(t, err) return stamps } -func getPendingRedemptions(t *testing.T, userID string) int { +func getPendingRedemptions(t *testing.T, userID string, ctx context.Context) int { t.Helper() var count int - err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'`, userID).Scan(&count) + err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'`, userID).Scan(&count) require.NoError(t, err) return count } -func getDiscountForBooking(t *testing.T, bookingID string) (source string, amount float64, exists bool) { +func getDiscountForBooking(t *testing.T, bookingID string, ctx context.Context) (source string, amount float64, exists bool) { t.Helper() - err := db.DB.QueryRow(context.Background(), ` + err := db.Conn.QueryRow(ctx, ` SELECT discount_source, discount_amount FROM booking_discounts WHERE booking_id = $1 `, bookingID).Scan(&source, &amount) if err != nil { @@ -204,17 +201,17 @@ type bookingDiscount struct { MileType string } -func getDiscountRowCount(t *testing.T, bookingID string) int { +func getDiscountRowCount(t *testing.T, bookingID string, q db.Querier, ctx context.Context) int { t.Helper() var count int - err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&count) + err := q.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&count) require.NoError(t, err) return count } -func getAllDiscountsForBooking(t *testing.T, bookingID string) []bookingDiscount { +func getAllDiscountsForBooking(t *testing.T, bookingID string, ctx context.Context) []bookingDiscount { t.Helper() - rows, err := db.DB.Query(context.Background(), ` + rows, err := db.Conn.Query(ctx, ` SELECT discount_source, discount_amount, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, '') FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type `, bookingID) @@ -229,49 +226,49 @@ func getAllDiscountsForBooking(t *testing.T, bookingID string) []bookingDiscount return discounts } -func getTotalDiscountAmount(t *testing.T, bookingID string) float64 { +func getTotalDiscountAmount(t *testing.T, bookingID string, ctx context.Context) float64 { t.Helper() var amount float64 - err := db.DB.QueryRow(context.Background(), `SELECT COALESCE(SUM(discount_amount), 0) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&amount) + err := db.Conn.QueryRow(ctx, `SELECT COALESCE(SUM(discount_amount), 0) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&amount) require.NoError(t, err) return amount } -func getPaymentDiscountRowCount(t *testing.T, bookingID string) int { +func getPaymentDiscountRowCount(t *testing.T, bookingID string, ctx context.Context) int { t.Helper() var count int - err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&count) + err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&count) require.NoError(t, err) return count } -func getTotalPaymentCount(t *testing.T, bookingID string) int { +func getTotalPaymentCount(t *testing.T, bookingID string, ctx context.Context) int { t.Helper() var count int - err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count) + err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count) require.NoError(t, err) return count } -func getAmountPaid(t *testing.T, bookingID string) float64 { +func getAmountPaid(t *testing.T, bookingID string, ctx context.Context) float64 { t.Helper() var amount float64 - err := db.DB.QueryRow(context.Background(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&amount) + err := db.Conn.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&amount) require.NoError(t, err) return amount } -func applyLoyaltyRedemption(t *testing.T, bookingID, userID string) { +func applyLoyaltyRedemption(t *testing.T, bookingID, userID string, ctx context.Context) { t.Helper() handler := http.HandlerFunc(payments.ApplyLoyaltyRedemption) req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, userID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "customer") - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "customer") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -283,199 +280,190 @@ func applyLoyaltyRedemption(t *testing.T, bookingID, userID string) { // ============================================================================= func TestDiscount_Loyalty_FullCycle(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 10) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 10, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - assert.Equal(t, 10, getStamps(t, userID)) - assert.Equal(t, 1, getPendingRedemptions(t, userID)) + assert.Equal(t, 10, getStamps(t, userID, ctx)) + assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) // Apply loyalty redemption manually on a new booking - bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12)) - applyLoyaltyRedemption(t, bookingID, userID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12), tx, ctx) + applyLoyaltyRedemption(t, bookingID, userID, ctx) - source, amount, exists := getDiscountForBooking(t, bookingID) + source, amount, exists := getDiscountForBooking(t, bookingID, ctx) require.True(t, exists, "Expected discount after manual redemption") assert.Equal(t, "loyalty", source) assert.Equal(t, 5.00, amount, "10% of £50 = £5") - assert.Equal(t, 0, getStamps(t, userID), "Stamps deducted by 10") - assert.Equal(t, 0, getPendingRedemptions(t, userID), "Pending redemption consumed") + assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps deducted by 10") + assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "Pending redemption consumed") // Complete the booking — no stamp awarded (take or receive, never both) - completeBooking(t, bookingID) - assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that claimed a reward") + completeBooking(t, bookingID, ctx) + assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp for a booking that claimed a reward") } func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 10) - serviceID := createTestService(t, 100.00) + userID := createTestUser(t, 10, tx, ctx) + serviceID := createTestService(t, 100.00, tx, ctx) milestoneType := "global_booking_count" milestoneUnit := "bookings" milestoneValue := 1 - _ = createTestCampaign(t, "First Global", "milestone", 5.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) + _ = createTestCampaign(t, "First Global", "milestone", 5.0, &milestoneType, &milestoneUnit, &milestoneValue, nil, tx, ctx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) // Apply loyalty redemption manually before completion - applyLoyaltyRedemption(t, bookingID, userID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) // Complete booking — milestone campaign applies at completion - insertInPersonCardPayment(t, bookingID) - completeBooking(t, bookingID) + insertInPersonCardPayment(t, bookingID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows (loyalty + campaign)") - assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID), "Expected 2 discount payment rows") + assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows (loyalty + campaign)") + assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID, ctx), "Expected 2 discount payment rows") - totalDiscount := getTotalDiscountAmount(t, bookingID) + totalDiscount := getTotalDiscountAmount(t, bookingID, ctx) assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)") - discounts := getAllDiscountsForBooking(t, bookingID) + discounts := getAllDiscountsForBooking(t, bookingID, ctx) require.Len(t, discounts, 2) } func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) mt := "per_user_booking_count" mu := "bookings" mv := 5 - _ = createTestCampaign(t, "5th Booking", "milestone", 15.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "5th Booking", "milestone", 15.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 10) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + userID := createTestUser(t, 10, tx, ctx) + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - serviceID := createTestService(t, 100.00) + serviceID := createTestService(t, 100.00, tx, ctx) for i := 0; i < 4; i++ { startTime := time.Now().AddDate(0, 0, -(i + 10)) - _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00) + _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx) } - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) // Apply loyalty manually, then complete (milestone applies at completion) - applyLoyaltyRedemption(t, bookingID, userID) - completeBooking(t, bookingID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows") - totalDiscount := getTotalDiscountAmount(t, bookingID) + totalDiscount := getTotalDiscountAmount(t, bookingID, ctx) assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 15%)") } func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) mt := "anniversary" mu := "years" mv := 1 - _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 10) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + userID := createTestUser(t, 10, tx, ctx) + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - serviceID := createTestService(t, 100.00) + serviceID := createTestService(t, 100.00, tx, ctx) firstStartTime := time.Now().AddDate(0, 0, -400) - _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00) + _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) - applyLoyaltyRedemption(t, bookingID, userID) - completeBooking(t, bookingID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows") - totalDiscount := getTotalDiscountAmount(t, bookingID) + totalDiscount := getTotalDiscountAmount(t, bookingID, ctx) assert.InDelta(t, 20.00, totalDiscount, 0.01, "Total should be £20 (10% loyalty + 10% anniversary)") } func TestDiscount_Stacking_AllThreeTypes(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) mt := "anniversary" mu := "years" mv := 1 - _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 10) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + userID := createTestUser(t, 10, tx, ctx) + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - serviceID := createTestService(t, 100.00) + serviceID := createTestService(t, 100.00, tx, ctx) firstStartTime := time.Now().AddDate(0, 0, -400) - _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00) + _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) - applyLoyaltyRedemption(t, bookingID, userID) - completeBooking(t, bookingID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows") + assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 discount rows") - totalDiscount := getTotalDiscountAmount(t, bookingID) + totalDiscount := getTotalDiscountAmount(t, bookingID, ctx) assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 5% + 10%)") } func TestDiscount_Stacking_MultipleMilestones(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) mt1 := "per_user_booking_count" mu1 := "bookings" mv1 := 5 - _ = createTestCampaign(t, "5th Booking", "milestone", 10.0, &mt1, &mu1, &mv1, nil) + _ = createTestCampaign(t, "5th Booking", "milestone", 10.0, &mt1, &mu1, &mv1, nil, tx, ctx) mt2 := "global_booking_count" mu2 := "bookings" mv2 := 5 - _ = createTestCampaign(t, "5th Global", "milestone", 5.0, &mt2, &mu2, &mv2, nil) + _ = createTestCampaign(t, "5th Global", "milestone", 5.0, &mt2, &mu2, &mv2, nil, tx, ctx) mt3 := "anniversary" mu3 := "years" mv3 := 1 - _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt3, &mu3, &mv3, nil) + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt3, &mu3, &mv3, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 100.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 100.00, tx, ctx) // 4 prior completed bookings, first one backdated 400+ days for i := 0; i < 4; i++ { @@ -485,77 +473,73 @@ func TestDiscount_Stacking_MultipleMilestones(t *testing.T) { } else { startTime = time.Now().AddDate(0, 0, -(i * 7)) } - _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00) + _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx) } - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - insertInPersonCardPayment(t, bookingID) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + insertInPersonCardPayment(t, bookingID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows (all milestones)") + assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 discount rows (all milestones)") } func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) mt := "global_booking_count" mu := "bookings" mv := 1 - _ = createTestCampaign(t, "First Global", "milestone", 5.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "First Global", "milestone", 5.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 10) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + userID := createTestUser(t, 10, tx, ctx) + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - serviceID := createTestService(t, 100.00) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + serviceID := createTestService(t, 100.00, tx, ctx) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) - applyLoyaltyRedemption(t, bookingID, userID) - insertInPersonCardPayment(t, bookingID) - completeBooking(t, bookingID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) + insertInPersonCardPayment(t, bookingID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows") - totalDiscount := getTotalDiscountAmount(t, bookingID) + totalDiscount := getTotalDiscountAmount(t, bookingID, ctx) assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)") } func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) mt := "per_user_booking_count" mu := "bookings" mv := 1 - _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 10) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + userID := createTestUser(t, 10, tx, ctx) + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - serviceID := createTestService(t, 200.00) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + serviceID := createTestService(t, 200.00, tx, ctx) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) - applyLoyaltyRedemption(t, bookingID, userID) - completeBooking(t, bookingID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows") + assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 discount rows") - totalDiscount := getTotalDiscountAmount(t, bookingID) + totalDiscount := getTotalDiscountAmount(t, bookingID, ctx) assert.InDelta(t, 50.00, totalDiscount, 0.01, "Total should be £50") - discounts := getAllDiscountsForBooking(t, bookingID) + discounts := getAllDiscountsForBooking(t, bookingID, ctx) require.Len(t, discounts, 3) for _, d := range discounts { switch d.Source { @@ -572,34 +556,32 @@ func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) { } func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) mt := "per_user_booking_count" mu := "bookings" mv := 1 - _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 10) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + userID := createTestUser(t, 10, tx, ctx) + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - serviceID := createTestService(t, 200.00) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + serviceID := createTestService(t, 200.00, tx, ctx) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) - applyLoyaltyRedemption(t, bookingID, userID) - completeBooking(t, bookingID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 3, getPaymentDiscountRowCount(t, bookingID), "Expected 3 discount payment rows") + assert.Equal(t, 3, getPaymentDiscountRowCount(t, bookingID, ctx), "Expected 3 discount payment rows") var totalDiscountPayment float64 - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' `, bookingID).Scan(&totalDiscountPayment) require.NoError(t, err) @@ -607,31 +589,29 @@ func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) { } func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) mt := "per_user_booking_count" mu := "bookings" mv := 1 - _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 10) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + userID := createTestUser(t, 10, tx, ctx) + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - serviceID := createTestService(t, 200.00) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + serviceID := createTestService(t, 200.00, tx, ctx) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) - applyLoyaltyRedemption(t, bookingID, userID) - completeBooking(t, bookingID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 booking_discounts rows") + assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 booking_discounts rows") type discountDetail struct { Source string @@ -641,7 +621,7 @@ func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) { OriginalTotal float64 Amount float64 } - rows, err := db.DB.Query(context.Background(), ` + rows, err := tx.Query(ctx, ` SELECT discount_source, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, ''), discount_percent, original_total, discount_amount FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type `, bookingID) @@ -678,32 +658,31 @@ func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) { } func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) mt := "per_user_booking_count" mu := "bookings" mv := 3 - _ = createTestCampaign(t, "3rd Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + _ = createTestCampaign(t, "3rd Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 100.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 100.00, tx, ctx) // 2 prior completed bookings startTime1 := time.Now().AddDate(0, 0, -14) - _ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00) + _ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00, ctx) startTime2 := time.Now().AddDate(0, 0, -7) - _ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00) + _ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows") - totalDiscount := getTotalDiscountAmount(t, bookingID) + totalDiscount := getTotalDiscountAmount(t, bookingID, ctx) assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (5% + 10%)") } @@ -712,44 +691,42 @@ func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) { // ============================================================================= func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 10) + userID := createTestUser(t, 10, tx, ctx) - ctx := context.Background() - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) var serviceID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID) require.NoError(t, err) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) var paymentCount int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' `, bookingID).Scan(&paymentCount) require.NoError(t, err) assert.Equal(t, 0, paymentCount, "No discount payment (total is 0)") var redemptionStatus string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' `, userID).Scan(&redemptionStatus) require.NoError(t, err) assert.Equal(t, "pending", redemptionStatus, "Redemption stays pending") - assert.Equal(t, 10, getStamps(t, userID), "Stamps unchanged") + assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps unchanged") } // ============================================================================= @@ -757,47 +734,45 @@ func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { // ============================================================================= func TestDiscount_CampaignMaxRedemptions(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) maxRedemptions := 1 - campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions) + campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions, tx, ctx) - userID1 := createTestUser(t, 0) - userID2 := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID1 := createTestUser(t, 0, tx, ctx) + userID2 := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID1) + bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID1, ctx) var discountCount1 int - err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1) + err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1) require.NoError(t, err) assert.Equal(t, 1, discountCount1) var timesRedeemed int - err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed) + err = tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed) require.NoError(t, err) assert.Equal(t, 1, timesRedeemed) - bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) - completeBooking(t, bookingID2) + bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour), tx, ctx) + completeBooking(t, bookingID2, ctx) var discountCount2 int - err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2) require.NoError(t, err) assert.Equal(t, 0, discountCount2, "No discount (max reached)") } -func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string { +func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string { t.Helper() - ctx := context.Background() var id string now := time.Now() startDate := now.Add(-24 * time.Hour) endDate := now.Add(24 * time.Hour) - err := db.DB.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0) RETURNING id @@ -811,60 +786,56 @@ func createTestCampaignWithStatus(t *testing.T, name, campaignType string, perce // ============================================================================= func TestDiscount_ExpiredRedemptionDoesNotApply(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 10) - serviceID := createTestService(t, 100.00) + userID := createTestUser(t, 10, tx, ctx) + serviceID := createTestService(t, 100.00, tx, ctx) // Insert pending redemption that has already expired - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at, expires_at) VALUES ($1, 10, 'pending', NOW(), NOW() - INTERVAL '1 day') `, userID) require.NoError(t, err) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Expired redemption should not apply discount") } func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 10) - serviceID := createTestService(t, 100.00) + userID := createTestUser(t, 10, tx, ctx) + serviceID := createTestService(t, 100.00, tx, ctx) - ctx := context.Background() - - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW() - INTERVAL '2 days') `, userID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW() - INTERVAL '1 day') `, userID) require.NoError(t, err) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) // Manual apply-redemption should pick the oldest pending redemption - applyLoyaltyRedemption(t, bookingID, userID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Exactly 1 loyalty discount row should be applied") type redemptionRow struct { ID string Status string } - rows, err := db.DB.Query(ctx, ` + rows, err := tx.Query(ctx, ` SELECT id, status FROM loyalty_redemptions WHERE user_id = $1 ORDER BY redeemed_at ASC `, userID) require.NoError(t, err) @@ -883,62 +854,60 @@ func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) { } func TestDiscount_StampCountAboveTen(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 9) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 9, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) // First booking: stamps 9 → 10, pending redemption auto-created at completion - bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID1) - backdateBooking(t, bookingID1, 2) + bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID1, ctx) + backdateBooking(t, bookingID1, 2, ctx) - assert.Equal(t, 10, getStamps(t, userID), "Stamps should be 10 after first completion") - assert.Equal(t, 1, getPendingRedemptions(t, userID), "Pending redemption should be auto-created") + assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps should be 10 after first completion") + assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "Pending redemption should be auto-created") // Second booking: stamps accumulate to 11 (no auto-deduct) - bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) // Second booking: stamps accumulate to 11 (no auto-deduct) // Apply redemption while booking is still confirmed (not yet completed) - applyLoyaltyRedemption(t, bookingID2, userID) - assert.Equal(t, 0, getStamps(t, userID), "Stamps: 10 - 10 = 0") - assert.Equal(t, 0, getPendingRedemptions(t, userID), "Redemption consumed") + applyLoyaltyRedemption(t, bookingID2, userID, ctx) + assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps: 10 - 10 = 0") + assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "Redemption consumed") // Complete the booking — no stamp awarded (take or receive, never both) - completeBooking(t, bookingID2) + completeBooking(t, bookingID2, ctx) - source, amount, exists := getDiscountForBooking(t, bookingID2) + source, amount, exists := getDiscountForBooking(t, bookingID2, ctx) require.True(t, exists, "Expected loyalty discount after manual redemption") assert.Equal(t, "loyalty", source) assert.Equal(t, 5.00, amount, "10% of £50 = £5") - assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that used a loyalty redemption") + assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp for a booking that used a loyalty redemption") } func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 10) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 10, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) // Apply redemption manually before completing - applyLoyaltyRedemption(t, bookingID, userID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) // Complete the booking — should NOT give a stamp (take or receive, never both) - completeBooking(t, bookingID) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 0, getStamps(t, userID), "No stamp awarded when loyalty redemption was used") - assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemptions") + assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp awarded when loyalty redemption was used") + assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "No pending redemptions") } // ============================================================================= @@ -946,54 +915,52 @@ func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) { // ============================================================================= func TestDiscount_NormalEarn_NoRedemption(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) // User starts at 5 stamps, completes 3 bookings without ever redeeming. // Stamps should accumulate normally with no discount interference. - userID := createTestUser(t, 5) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 5, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, booking1) - assert.Equal(t, 6, getStamps(t, userID), "5 + 1 = 6") - assert.Equal(t, 0, getDiscountRowCount(t, booking1), "No discounts applied") - assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemption (< 10)") + booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, booking1, ctx) + assert.Equal(t, 6, getStamps(t, userID, ctx), "5 + 1 = 6") + assert.Equal(t, 0, getDiscountRowCount(t, booking1, tx, ctx), "No discounts applied") + assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "No pending redemption (< 10)") - backdateBooking(t, booking1, 2) - booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) - completeBooking(t, booking2) - assert.Equal(t, 7, getStamps(t, userID), "6 + 1 = 7") - assert.Equal(t, 0, getDiscountRowCount(t, booking2), "No discounts applied") + backdateBooking(t, booking1, 2, ctx) + booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) + completeBooking(t, booking2, ctx) + assert.Equal(t, 7, getStamps(t, userID, ctx), "6 + 1 = 7") + assert.Equal(t, 0, getDiscountRowCount(t, booking2, tx, ctx), "No discounts applied") - backdateBooking(t, booking2, 2) - booking3 := createPendingBooking(t, userID, serviceID, time.Now().Add(72*time.Hour)) - completeBooking(t, booking3) - assert.Equal(t, 8, getStamps(t, userID), "7 + 1 = 8") - assert.Equal(t, 0, getDiscountRowCount(t, booking3), "No discounts applied") + backdateBooking(t, booking2, 2, ctx) + booking3 := createPendingBooking(t, userID, serviceID, time.Now().Add(72*time.Hour), tx, ctx) + completeBooking(t, booking3, ctx) + assert.Equal(t, 8, getStamps(t, userID, ctx), "7 + 1 = 8") + assert.Equal(t, 0, getDiscountRowCount(t, booking3, tx, ctx), "No discounts applied") } func TestDiscount_DepositBeforeRedemption_Rejected(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) // User pays a deposit upfront without loyalty — the first real payment has been // made, so loyalty redemption should be rejected by the first-payment guard. - userID := createTestUser(t, 10) - serviceID := createTestService(t, 200.00) + userID := createTestUser(t, 10, tx, ctx) + serviceID := createTestService(t, 200.00, tx, ctx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - assert.Equal(t, 1, getPendingRedemptions(t, userID)) + assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) // Pay a deposit first (the first real payment) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2) `, bookingID, userID) @@ -1004,138 +971,134 @@ func TestDiscount_DepositBeforeRedemption_Rejected(t *testing.T) { req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, userID) - ctx = context.WithValue(ctx, mw.UserRoleKey, "customer") - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "customer") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code, "Expected 400 after deposit was paid") // Verify nothing was changed — discount not applied, stamps intact, redemption pending - assert.Equal(t, 0, getDiscountRowCount(t, bookingID), "No loyalty discount applied") - assert.Equal(t, 10, getStamps(t, userID), "Stamps not deducted") - assert.Equal(t, 1, getPendingRedemptions(t, userID), "Redemption still pending") - assert.Equal(t, 1, getTotalPaymentCount(t, bookingID), "Only the deposit payment exists") + assert.Equal(t, 0, getDiscountRowCount(t, bookingID, tx, ctx), "No loyalty discount applied") + assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps not deducted") + assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "Redemption still pending") + assert.Equal(t, 1, getTotalPaymentCount(t, bookingID, ctx), "Only the deposit payment exists") } func TestDiscount_RedemptionBeforeDeposit_DiscountLockedIn(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) // User applies loyalty redemption online first, then pays the deposit. // The discount locks in at 10% of total and persists through to completion. - userID := createTestUser(t, 10) - serviceID := createTestService(t, 200.00) + userID := createTestUser(t, 10, tx, ctx) + serviceID := createTestService(t, 200.00, tx, ctx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) // Apply loyalty redemption (simulating online payment checkbox) - applyLoyaltyRedemption(t, bookingID, userID) + applyLoyaltyRedemption(t, bookingID, userID, ctx) // Discount should be 10% of total - source, amount, exists := getDiscountForBooking(t, bookingID) + source, amount, exists := getDiscountForBooking(t, bookingID, ctx) require.True(t, exists) assert.Equal(t, "loyalty", source) assert.InDelta(t, 20.00, amount, 0.01, "Discount is 10%% of £200 = £20") // Pay a deposit after redemption (£40 deposit on the current due) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2) `, bookingID, userID) require.NoError(t, err) // Verify: 2 payments (discount + deposit), discount still intact - assert.Equal(t, 2, getTotalPaymentCount(t, bookingID), "Expected 2 payment records (discount + deposit)") + assert.Equal(t, 2, getTotalPaymentCount(t, bookingID, ctx), "Expected 2 payment records (discount + deposit)") // Complete the booking — no stamp awarded (take or receive) - completeBooking(t, bookingID) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that used loyalty") - assert.Equal(t, 1, getPaymentDiscountRowCount(t, bookingID), "Discount payment still present after completion") + assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp for a booking that used loyalty") + assert.Equal(t, 1, getPaymentDiscountRowCount(t, bookingID, ctx), "Discount payment still present after completion") // Re-read discount after completion to confirm it wasn't altered - _, amountAfter, existsAfter := getDiscountForBooking(t, bookingID) + _, amountAfter, existsAfter := getDiscountForBooking(t, bookingID, ctx) require.True(t, existsAfter) assert.InDelta(t, 20.00, amountAfter, 0.01, "Discount amount unchanged after completion") } func TestDiscount_MultipleEarnCycles(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) // Two complete earn-and-redeem cycles: earn 10 → redeem → earn 10 more → redeem - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) // Cycle 1: reach 10 stamps - _, err := db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID) + _, err := tx.Exec(ctx, `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID) require.NoError(t, err) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - assert.Equal(t, 10, getStamps(t, userID)) - assert.Equal(t, 1, getPendingRedemptions(t, userID)) + assert.Equal(t, 10, getStamps(t, userID, ctx)) + assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) // Redeem on first booking - booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - applyLoyaltyRedemption(t, booking1, userID) - assert.Equal(t, 0, getStamps(t, userID)) - assert.Equal(t, 0, getPendingRedemptions(t, userID)) + booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + applyLoyaltyRedemption(t, booking1, userID, ctx) + assert.Equal(t, 0, getStamps(t, userID, ctx)) + assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx)) // Complete booking — no stamp (take or receive) - completeBooking(t, booking1) - assert.Equal(t, 0, getStamps(t, userID), "No stamp — this booking used loyalty") + completeBooking(t, booking1, ctx) + assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp — this booking used loyalty") // Cycle 2: set stamps back to 10 via direct DB update - _, err = db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID) + _, err = tx.Exec(ctx, `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID) require.NoError(t, err) // Create a new pending redemption for the second cycle - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) - assert.Equal(t, 10, getStamps(t, userID)) - assert.Equal(t, 1, getPendingRedemptions(t, userID)) + assert.Equal(t, 10, getStamps(t, userID, ctx)) + assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) // Redeem again on a new booking - booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) - applyLoyaltyRedemption(t, booking2, userID) + booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) + applyLoyaltyRedemption(t, booking2, userID, ctx) - assert.Equal(t, 0, getStamps(t, userID), "Stamps deducted again") - assert.Equal(t, 1, getDiscountRowCount(t, booking2), "Second discount applied") + assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps deducted again") + assert.Equal(t, 1, getDiscountRowCount(t, booking2, tx, ctx), "Second discount applied") - source, amount, exists := getDiscountForBooking(t, booking2) + source, amount, exists := getDiscountForBooking(t, booking2, ctx) require.True(t, exists) assert.Equal(t, "loyalty", source) assert.InDelta(t, 5.00, amount, 0.01, "10%% of £50 = £5") } func TestDiscount_MixedFreeAndPaidServices(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 0) - serviceIDFree := createTestService(t, 0) - serviceIDPaid := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceIDFree := createTestService(t, 0, tx, ctx) + serviceIDPaid := createTestService(t, 50.00, tx, ctx) - ctx := context.Background() var bookingID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id @@ -1143,193 +1106,181 @@ func TestDiscount_MixedFreeAndPaidServices(t *testing.T) { require.NoError(t, err) // Insert two booking_services rows: one free, one paid - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2), ($1, $3) `, bookingID, serviceIDFree, serviceIDPaid) require.NoError(t, err) - completeBooking(t, bookingID) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 1, getStamps(t, userID), "Mixed free/paid booking earns 1 stamp (total > 0)") + assert.Equal(t, 1, getStamps(t, userID, ctx), "Mixed free/paid booking earns 1 stamp (total > 0)") } func TestDiscount_CampaignBoundaryStart(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Boundary Start").Scan(&campaignID) require.NoError(t, err) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Campaign at start_date boundary should apply") } func TestDiscount_CampaignBoundaryEnd(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 minute', 0) RETURNING id `, "Boundary End").Scan(&campaignID) require.NoError(t, err) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Campaign at end_date boundary should apply") } func TestDiscount_CampaignExpiredDoesNotApply(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() - INTERVAL '1 day', 0) RETURNING id `, "Expired Campaign").Scan(&campaignID) require.NoError(t, err) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Expired campaign should not apply") } func TestDiscount_CampaignDraftDoesNotApply(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaignWithStatus(t, "Draft Campaign", "time_based", 10.0, "draft", nil, nil, nil, nil) + _ = createTestCampaignWithStatus(t, "Draft Campaign", "time_based", 10.0, "draft", nil, nil, nil, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Draft campaign should not apply") } func TestDiscount_CampaignCancelledDoesNotApply(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaignWithStatus(t, "Cancelled Campaign", "time_based", 10.0, "cancelled", nil, nil, nil, nil) + _ = createTestCampaignWithStatus(t, "Cancelled Campaign", "time_based", 10.0, "cancelled", nil, nil, nil, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Cancelled campaign should not apply") } func TestDiscount_PriceOverrideRespected(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaign(t, "10% Off", "time_based", 10.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "10% Off", "time_based", 10.0, nil, nil, nil, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 100.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 100.00, tx, ctx) // Create booking with override_price = £80 - ctx := context.Background() var bookingID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, 80.00) require.NoError(t, err) - completeBooking(t, bookingID) + completeBooking(t, bookingID, ctx) - source, amount, exists := getDiscountForBooking(t, bookingID) + source, amount, exists := getDiscountForBooking(t, bookingID, ctx) require.True(t, exists) assert.Equal(t, "campaign", source) assert.Equal(t, 8.00, amount, "10%% of £80 override = £8.00") } func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) milestoneValue := 12 milestoneType := "anniversary" milestoneUnit := "months" - _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) - _ = createTestCampaign(t, "Spring Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil, tx, ctx) + _ = createTestCampaign(t, "Spring Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 60.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 60.00, tx, ctx) // Create a completed booking 400 days ago (> 12 months) - ctx := context.Background() fourHundredDaysAgo := time.Now().AddDate(0, 0, -400) var firstBookingID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, fourHundredDaysAgo).Scan(&firstBookingID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, firstBookingID, serviceID, 60.00) require.NoError(t, err) // First booking after anniversary threshold: should get anniversary + time_based - bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID1) + bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID1, ctx) - discounts1 := getAllDiscountsForBooking(t, bookingID1) + discounts1 := getAllDiscountsForBooking(t, bookingID1, ctx) assert.Equal(t, 2, len(discounts1), "First booking should get anniversary + time_based discounts") // Second booking (different day): should only get time_based (anniversary dedup) - bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) - completeBooking(t, bookingID2) + bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) + completeBooking(t, bookingID2, ctx) - discounts2 := getAllDiscountsForBooking(t, bookingID2) + discounts2 := getAllDiscountsForBooking(t, bookingID2, ctx) assert.Equal(t, 1, len(discounts2), "Second booking should only get time_based (anniversary dedup)") // Verify the remaining discount is time_based foundTimeBased := false @@ -1342,35 +1293,34 @@ func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) { } func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) milestoneValue := 3 milestoneType := "per_user_booking_count" - _ = createTestCampaign(t, "3rd Visit", "milestone", 10.0, &milestoneType, nil, &milestoneValue, nil) - _ = createTestCampaign(t, "May Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "3rd Visit", "milestone", 10.0, &milestoneType, nil, &milestoneValue, nil, tx, ctx) + _ = createTestCampaign(t, "May Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) // Create 2 prior completed bookings on different days for i := 0; i < 2; i++ { startTime := time.Now().AddDate(0, 0, -10-i*7) - _ = createCompletedBooking(t, userID, serviceID, startTime, 50.00) + _ = createCompletedBooking(t, userID, serviceID, startTime, 50.00, ctx) } // 3rd booking: milestone + time_based - bookingID3 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID3) + bookingID3 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID3, ctx) - discounts3 := getAllDiscountsForBooking(t, bookingID3) + discounts3 := getAllDiscountsForBooking(t, bookingID3, ctx) assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based") // 4th booking (different day): only time_based (milestone dedup) - bookingID4 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) - completeBooking(t, bookingID4) + bookingID4 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) + completeBooking(t, bookingID4, ctx) - discounts4 := getAllDiscountsForBooking(t, bookingID4) + discounts4 := getAllDiscountsForBooking(t, bookingID4, ctx) assert.Equal(t, 1, len(discounts4), "4th booking should only get time_based (milestone dedup)") foundTimeBased := false for _, d := range discounts4 { @@ -1382,39 +1332,38 @@ func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) { } func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) maxRedemptions := 1 milestoneValue := 5 milestoneType := "global_booking_count" - _ = createTestCampaign(t, "5th Customer", "milestone", 5.0, &milestoneType, nil, &milestoneValue, &maxRedemptions) - _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "5th Customer", "milestone", 5.0, &milestoneType, nil, &milestoneValue, &maxRedemptions, tx, ctx) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) - userID1 := createTestUser(t, 0) - userID2 := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID1 := createTestUser(t, 0, tx, ctx) + userID2 := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) // Create 4 completed bookings (different days, user1) for i := 0; i < 4; i++ { startTime := time.Now().AddDate(0, 0, -10-i*7) - _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00) + _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00, ctx) } // 5th global booking (user1, different day): milestone + time_based - bookingID5 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) - insertInPersonCardPayment(t, bookingID5) - completeBooking(t, bookingID5) + bookingID5 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + insertInPersonCardPayment(t, bookingID5, ctx) + completeBooking(t, bookingID5, ctx) - discounts5 := getAllDiscountsForBooking(t, bookingID5) + discounts5 := getAllDiscountsForBooking(t, bookingID5, ctx) assert.Equal(t, 2, len(discounts5), "5th global booking should get milestone + time_based") // 6th global booking (user2, different day): only time_based (max_redemptions reached) - bookingID6 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) - insertInPersonCardPayment(t, bookingID6) - completeBooking(t, bookingID6) + bookingID6 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour), tx, ctx) + insertInPersonCardPayment(t, bookingID6, ctx) + completeBooking(t, bookingID6, ctx) - discounts6 := getAllDiscountsForBooking(t, bookingID6) + discounts6 := getAllDiscountsForBooking(t, bookingID6, ctx) assert.Equal(t, 1, len(discounts6), "6th global booking should only get time_based (max_redemptions reached)") foundTimeBased := false for _, d := range discounts6 { @@ -1426,52 +1375,49 @@ func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) { } func TestDiscount_BestTimeBasedCampaignSelected(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaign(t, "Low Sale", "time_based", 5.0, nil, nil, nil, nil) - _ = createTestCampaign(t, "High Sale", "time_based", 15.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "Low Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx) + _ = createTestCampaign(t, "High Sale", "time_based", 15.0, nil, nil, nil, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 100.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 100.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Only 1 campaign discount row (best selected)") - source, amount, exists := getDiscountForBooking(t, bookingID) + source, amount, exists := getDiscountForBooking(t, bookingID, ctx) require.True(t, exists) assert.Equal(t, "campaign", source) assert.Equal(t, 15.00, amount, "15%% of £100 = £15 (highest percent selected)") } func TestDiscount_FirstBookingEarnsStamp(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 1, getStamps(t, userID), "First paid booking earns 1 stamp") + assert.Equal(t, 1, getStamps(t, userID, ctx), "First paid booking earns 1 stamp") } func TestDiscount_TenStampsCreatesRedemption(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - userID := createTestUser(t, 9) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 9, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - assert.Equal(t, 10, getStamps(t, userID), "Stamps should reach 10") - assert.Equal(t, 1, getPendingRedemptions(t, userID), "1 pending redemption should be auto-created") + assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps should reach 10") + assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "1 pending redemption should be auto-created") } // ============================================================================= @@ -1479,12 +1425,10 @@ func TestDiscount_TenStampsCreatesRedemption(t *testing.T) { // ============================================================================= func TestCampaign_CreateAsDraft(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id @@ -1492,132 +1436,123 @@ func TestCampaign_CreateAsDraft(t *testing.T) { require.NoError(t, err) var status string - err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) require.NoError(t, err) assert.Equal(t, "draft", status) } func TestCampaign_ActivateDraft(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Draft to Active").Scan(&campaignID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'active' WHERE id = $1`, campaignID) + _, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'active' WHERE id = $1`, campaignID) require.NoError(t, err) var status string - err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) require.NoError(t, err) assert.Equal(t, "active", status) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Activated draft campaign should apply") } func TestCampaign_CompleteActive(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Active to Completed").Scan(&campaignID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'completed' WHERE id = $1`, campaignID) + _, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'completed' WHERE id = $1`, campaignID) require.NoError(t, err) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Completed campaign should not apply") } func TestCampaign_CancelActive(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Active to Cancelled").Scan(&campaignID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'cancelled' WHERE id = $1`, campaignID) + _, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'cancelled' WHERE id = $1`, campaignID) require.NoError(t, err) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Cancelled campaign should not apply") } func TestCampaign_RevertToDraft(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() var campaignID string - err := db.DB.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Active to Draft").Scan(&campaignID) require.NoError(t, err) - _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'draft' WHERE id = $1`, campaignID) + _, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'draft' WHERE id = $1`, campaignID) require.NoError(t, err) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Reverted-to-draft campaign should not apply") } func TestCampaign_DraftDoesNotApplyDiscounts(t *testing.T) { - testutils.SetupTestDB(t) - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) - _ = createTestCampaignWithStatus(t, "Draft Only", "time_based", 10.0, "draft", nil, nil, nil, nil) + _ = createTestCampaignWithStatus(t, "Draft Only", "time_based", 10.0, "draft", nil, nil, nil, nil, tx, ctx) - userID := createTestUser(t, 0) - serviceID := createTestService(t, 50.00) + userID := createTestUser(t, 0, tx, ctx) + serviceID := createTestService(t, 50.00, tx, ctx) - bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - completeBooking(t, bookingID) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) + completeBooking(t, bookingID, ctx) - count := getDiscountRowCount(t, bookingID) + count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 0, count, "Draft campaign should not apply discounts") } diff --git a/backend/handlers/bookings/edit_requests_test.go b/backend/handlers/bookings/edit_requests_test.go index 81b9bb8..ae2f9d1 100644 --- a/backend/handlers/bookings/edit_requests_test.go +++ b/backend/handlers/bookings/edit_requests_test.go @@ -52,38 +52,33 @@ func strPtr(s string) *string { // setupEditRequestTest creates a user, service, confirmed booking, and returns // their IDs along with an auth token. Also seeds working hours and sets deposits=0. -func setupEditRequestTest(t *testing.T) (userID, serviceID, bookingID, token string) { +func setupEditRequestTest(t *testing.T, ctx context.Context, tx db.Querier) (userID, serviceID, bookingID, token string) { t.Helper() - seedDefaultWorkingHours(t) - - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err = fixtures.CreateTestService(db.DB) + serviceID, err = fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) // Use a start time ~36h from now so auto-approval (>=48h) does not fire bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err = fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + bookingID, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) // Confirm the booking (edit requests require confirmed booking) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -94,44 +89,38 @@ func setupEditRequestTest(t *testing.T) (userID, serviceID, bookingID, token str // setupTwoUserEditRequestTest creates two users: one who owns a booking and // another who doesn't. Returns both user IDs, service ID, booking ID, and tokens. -func setupTwoUserEditRequestTest(t *testing.T) (ownerID, otherUserID, serviceID, bookingID, ownerToken string) { +func setupTwoUserEditRequestTest(t *testing.T, ctx context.Context, tx db.Querier) (ownerID, otherUserID, serviceID, bookingID, ownerToken string) { t.Helper() - seedDefaultWorkingHours(t) - - ownerID, err := fixtures.CreateTestUser(db.DB) + ownerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create owner user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, ownerID) }) - otherUserID, err = fixtures.CreateTestUser(db.DB) + otherUserID, err = fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, otherUserID) }) for _, uid := range []string{ownerID, otherUserID} { - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", uid) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", uid) if err != nil { t.Fatalf("failed to set deposits_required for %s: %v", uid, err) } } - serviceID, err = fixtures.CreateTestService(db.DB) + serviceID, err = fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID, err = fixtures.CreateTestBookingAtTime(db.DB, ownerID, serviceID, bookingTime) + bookingID, err = fixtures.CreateTestBookingAtTime(tx, ownerID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -141,10 +130,10 @@ func setupTwoUserEditRequestTest(t *testing.T) (ownerID, otherUserID, serviceID, } // createEditRequestDirectly inserts an edit request into the DB and returns its ID. -func createEditRequestDirectly(t *testing.T, bookingID, userID string, newStartTime *time.Time, newServices []string, notes *string) string { +func createEditRequestDirectly(t *testing.T, ctx context.Context, tx db.Querier, bookingID, userID string, newStartTime *time.Time, newServices []string, notes *string) string { t.Helper() var editRequestID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides) VALUES ($1, $2, $3, $4, $5, false) RETURNING id @@ -156,10 +145,10 @@ func createEditRequestDirectly(t *testing.T, bookingID, userID string, newStartT } // getEditRequestIDFromDB retrieves the edit request ID for a booking. -func getEditRequestIDFromDB(t *testing.T, bookingID string) string { +func getEditRequestIDFromDB(t *testing.T, ctx context.Context, tx db.Querier, bookingID string) string { t.Helper() var editRequestID string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) @@ -168,9 +157,9 @@ func getEditRequestIDFromDB(t *testing.T, bookingID string) string { } // createAdminNotification creates an admin notification for an edit request. -func createAdminNotification(t *testing.T, bookingID, userID string) { +func createAdminNotification(t *testing.T, ctx context.Context, tx db.Querier, bookingID, userID string) { t.Helper() - _, err := db.DB.Exec(context.Background(), + _, err := tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { @@ -218,8 +207,10 @@ func makeAdminEditRequest(method, path, routePattern string, body interface{}) * } // serveChiHandler sets up a chi router with the given route and serves a request. -// Supports an optional JSON body (pass nil for no body). Returns the response recorder. -func serveChiHandler(handler http.HandlerFunc, method, path, routePattern string, body interface{}, setupCtx func(context.Context) context.Context) *httptest.ResponseRecorder { +// Supports an optional JSON body (pass nil for no body). An optional base context +// can be provided as the last argument to carry a per-test transaction. +// Returns the response recorder. +func serveChiHandler(handler http.HandlerFunc, method, path, routePattern string, body interface{}, setupCtx func(context.Context) context.Context, baseCtx ...context.Context) *httptest.ResponseRecorder { r := chi.NewRouter() switch method { case "GET": @@ -241,8 +232,12 @@ func serveChiHandler(handler http.HandlerFunc, method, path, routePattern string } req.Header.Set("Content-Type", "application/json") + base := req.Context() + if len(baseCtx) > 0 { + base = baseCtx[0] + } if setupCtx != nil { - ctx := setupCtx(req.Context()) + ctx := setupCtx(base) req = req.WithContext(ctx) } @@ -253,8 +248,8 @@ func serveChiHandler(handler http.HandlerFunc, method, path, routePattern string // serveAdminHandler is a convenience wrapper around serveChiHandler that sets up // admin authentication context. Useful for testing admin endpoints with chi routing. -func serveAdminHandler(handler http.HandlerFunc, method, path, routePattern string, body interface{}) *httptest.ResponseRecorder { - return serveChiHandler(handler, method, path, routePattern, body, setupAdminContext) +func serveAdminHandler(handler http.HandlerFunc, method, path, routePattern string, body interface{}, baseCtx ...context.Context) *httptest.ResponseRecorder { + return serveChiHandler(handler, method, path, routePattern, body, setupAdminContext, baseCtx...) } // setupAdminContext adds admin JWT, admin role, and user ID to context. @@ -283,9 +278,9 @@ func setupUserContext(ctx context.Context, token string) context.Context { // TestRequestEditHandler_TimeChange verifies that a user can request a time // change for their confirmed booking and a booking_edit_request record is created. func TestRequestEditHandler_TimeChange(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = token notes := "Please add gel polish to my appointment" @@ -293,7 +288,7 @@ func TestRequestEditHandler_TimeChange(t *testing.T) { reqBody := map[string]interface{}{ "notes": notes, } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -317,7 +312,7 @@ func TestRequestEditHandler_TimeChange(t *testing.T) { } var blockerCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { @@ -328,7 +323,7 @@ func TestRequestEditHandler_TimeChange(t *testing.T) { } var dbNotes string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(notes, '') FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&dbNotes) if err != nil { t.Fatalf("failed to query edit request: %v", err) @@ -341,16 +336,16 @@ func TestRequestEditHandler_TimeChange(t *testing.T) { // TestRequestEditHandler_AccessDenied verifies that a user cannot create an edit // request for a booking they don't own. func TestRequestEditHandler_AccessDenied(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, otherUserID, _, bookingID, _ := setupTwoUserEditRequestTest(t) + _, otherUserID, _, bookingID, _ := setupTwoUserEditRequestTest(t, ctx, tx) otherToken := jwt.GenerateUserToken(otherUserID) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Trying to edit someone else's booking", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, otherToken) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, otherToken, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -358,7 +353,7 @@ func TestRequestEditHandler_AccessDenied(t *testing.T) { // Verify no edit request was created var erCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -371,12 +366,12 @@ func TestRequestEditHandler_AccessDenied(t *testing.T) { // TestRequestEditHandler_CompletedBooking verifies that a user cannot request // an edit for a completed booking. func TestRequestEditHandler_CompletedBooking(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID - _, err := db.DB.Exec(context.Background(), + _, err := tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "completed", bookingID) if err != nil { t.Fatalf("failed to set booking to completed: %v", err) @@ -386,7 +381,7 @@ func TestRequestEditHandler_CompletedBooking(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Should be blocked", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403 for completed booking, got %d. body: %s", w.Code, w.Body.String()) @@ -400,12 +395,12 @@ func TestRequestEditHandler_CompletedBooking(t *testing.T) { // TestRequestEditHandler_CancelledBooking verifies that a user cannot request // an edit for a cancelled booking. func TestRequestEditHandler_CancelledBooking(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID - _, err := db.DB.Exec(context.Background(), + _, err := tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "client_cancelled", bookingID) if err != nil { t.Fatalf("failed to set booking to cancelled: %v", err) @@ -415,7 +410,7 @@ func TestRequestEditHandler_CancelledBooking(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Should be blocked", } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403 for cancelled booking, got %d. body: %s", w.Code, w.Body.String()) @@ -429,12 +424,12 @@ func TestRequestEditHandler_CancelledBooking(t *testing.T) { // TestRequestEditHandler_EmptyBody verifies that requesting an edit with no // changes (empty body) returns 400 Bad Request. func TestRequestEditHandler_EmptyBody(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) handler := http.HandlerFunc(RequestEditHandler) - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{}, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{}, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for empty request, got %d. body: %s", w.Code, w.Body.String()) @@ -444,9 +439,9 @@ func TestRequestEditHandler_EmptyBody(t *testing.T) { // TestRequestEditHandler_UpsertBehavior verifies that creating a second edit // request replaces the first (upsert), so only one row exists in the DB. func TestRequestEditHandler_UpsertBehavior(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, serviceID, bookingID, token := setupEditRequestTest(t) + userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID // Create first edit request @@ -455,7 +450,7 @@ func TestRequestEditHandler_UpsertBehavior(t *testing.T) { reqBody1 := map[string]interface{}{ "notes": firstNotes, } - w1 := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody1, token) + w1 := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody1, token, ctx) if w1.Code != http.StatusCreated { t.Fatalf("first edit request failed: %d. body: %s", w1.Code, w1.Body.String()) } @@ -468,14 +463,14 @@ func TestRequestEditHandler_UpsertBehavior(t *testing.T) { "notes": secondNotes, "new_start_time": newStartTime.Format(time.RFC3339), } - w2 := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody2, token) + w2 := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody2, token, ctx) if w2.Code != http.StatusCreated { t.Fatalf("second edit request failed: %d. body: %s", w2.Code, w2.Body.String()) } // Verify only ONE row exists in the DB var erCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1 AND requested_by = $2", bookingID, userID).Scan(&erCount) if err != nil { @@ -488,7 +483,7 @@ func TestRequestEditHandler_UpsertBehavior(t *testing.T) { // Verify the second request's data is what's stored (the old one was replaced) var dbNotes string var dbNewTime *time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT notes, new_start_time FROM booking_edit_requests WHERE booking_id = $1 AND requested_by = $2", bookingID, userID).Scan(&dbNotes, &dbNewTime) if err != nil { @@ -507,15 +502,14 @@ func TestRequestEditHandler_UpsertBehavior(t *testing.T) { // TestRequestEditHandler_BookingNotFound verifies that requesting an edit for // a non-existent booking returns 404. func TestRequestEditHandler_BookingNotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -526,7 +520,7 @@ func TestRequestEditHandler_BookingNotFound(t *testing.T) { reqBody := map[string]interface{}{ "notes": "Some change", } - w := makeRequest(handler, "POST", "/api/bookings/nonexistent-id/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/nonexistent-id/edit-request", reqBody, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for non-existent booking, got %d", w.Code) @@ -536,16 +530,14 @@ func TestRequestEditHandler_BookingNotFound(t *testing.T) { // TestRequestEditHandler_WithServices verifies that a user can request a // services change (new_services) along with a time change. func TestRequestEditHandler_WithServices(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) - serviceID2, err := fixtures.CreateTestService(db.DB) + serviceID2, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create second service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID2) - newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) @@ -554,7 +546,7 @@ func TestRequestEditHandler_WithServices(t *testing.T) { "new_start_time": newStartTime.Format(time.RFC3339), "new_services": []string{serviceID2}, } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -570,7 +562,7 @@ func TestRequestEditHandler_WithServices(t *testing.T) { // Verify time_blocker duration uses new service duration var blockerDuration int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT duration_minutes FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerDuration) if err != nil { @@ -584,28 +576,26 @@ func TestRequestEditHandler_WithServices(t *testing.T) { // TestRequestEditHandler_ServicesOnBookingWithOverrides verifies that a user // cannot change services on a booking that has override prices/durations. func TestRequestEditHandler_ServicesOnBookingWithOverrides(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) - _, err := db.DB.Exec(context.Background(), + _, err := tx.Exec(ctx, "UPDATE booking_services SET override_price = 75.00, override_duration_minutes = 90 WHERE booking_id = $1 AND service_id = $2", bookingID, serviceID) if err != nil { t.Fatalf("failed to set override on booking service: %v", err) } - serviceID2, err := fixtures.CreateTestService(db.DB) + serviceID2, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create second service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID2) - handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_services": []string{serviceID2}, } - w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403 for booking with overrides, got %d. body: %s", w.Code, w.Body.String()) @@ -620,9 +610,9 @@ func TestRequestEditHandler_ServicesOnBookingWithOverrides(t *testing.T) { // pending edit request and the associated time_blocker + admin notification // are removed. func TestDeleteEditRequestHandler_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) @@ -632,13 +622,13 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) { reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } - w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } var blockerID string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT id FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerID) if err != nil { @@ -647,7 +637,7 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) { // Delete the edit request delHandler := http.HandlerFunc(DeleteEditRequestHandler) - w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token) + w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -655,7 +645,7 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) { // Verify edit request deleted from DB var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -665,7 +655,7 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) { } // Verify time_blocker was deleted - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&erCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) @@ -675,7 +665,7 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) { } // Verify admin notification was deleted - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(&erCount) if err != nil { @@ -689,12 +679,12 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) { // TestDeleteEditRequestHandler_NoEditRequest verifies that deleting a // non-existent edit request returns 404. func TestDeleteEditRequestHandler_NoEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) delHandler := http.HandlerFunc(DeleteEditRequestHandler) - w := makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token) + w := makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -704,15 +694,15 @@ func TestDeleteEditRequestHandler_NoEditRequest(t *testing.T) { // TestDeleteEditRequestHandler_AccessDenied verifies that one user cannot // delete another user's edit request. func TestDeleteEditRequestHandler_AccessDenied(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - ownerID, otherUserID, _, bookingID, ownerToken := setupTwoUserEditRequestTest(t) + ownerID, otherUserID, _, bookingID, ownerToken := setupTwoUserEditRequestTest(t, ctx, tx) _ = ownerID // Create edit request as owner createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"notes": "Owner's edit"}, ownerToken) + map[string]interface{}{"notes": "Owner's edit"}, ownerToken, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -720,7 +710,7 @@ func TestDeleteEditRequestHandler_AccessDenied(t *testing.T) { // Other user tries to delete otherToken := jwt.GenerateUserToken(otherUserID) delHandler := http.HandlerFunc(DeleteEditRequestHandler) - w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, otherToken) + w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, otherToken, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -728,7 +718,7 @@ func TestDeleteEditRequestHandler_AccessDenied(t *testing.T) { // Verify edit request still exists var erCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -741,15 +731,14 @@ func TestDeleteEditRequestHandler_AccessDenied(t *testing.T) { // TestDeleteEditRequestHandler_BookingNotFound verifies that deleting an edit // request for a non-existent booking returns 404. func TestDeleteEditRequestHandler_BookingNotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } @@ -757,7 +746,7 @@ func TestDeleteEditRequestHandler_BookingNotFound(t *testing.T) { token := jwt.GenerateUserToken(userID) delHandler := http.HandlerFunc(DeleteEditRequestHandler) - w := makeRequest(delHandler, "DELETE", "/api/bookings/nonexistent-id/edit-request", nil, token) + w := makeRequest(delHandler, "DELETE", "/api/bookings/nonexistent-id/edit-request", nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) @@ -771,9 +760,9 @@ func TestDeleteEditRequestHandler_BookingNotFound(t *testing.T) { // TestGetMyEditRequestHandler_Success verifies that a user can view their // pending edit request for a specific booking. func TestGetMyEditRequestHandler_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) @@ -785,7 +774,7 @@ func TestGetMyEditRequestHandler_Success(t *testing.T) { "new_start_time": newStartTime.Format(time.RFC3339), "notes": "View test notes", } - w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -799,7 +788,7 @@ func TestGetMyEditRequestHandler_Success(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -831,9 +820,9 @@ func TestGetMyEditRequestHandler_Success(t *testing.T) { // TestGetMyEditRequestHandler_NotFound verifies that viewing a non-existent // edit request returns 404. func TestGetMyEditRequestHandler_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) viewHandler := http.HandlerFunc(GetMyEditRequestHandler) w := serveChiHandler(viewHandler, "GET", "/api/bookings/"+bookingID+"/edit-request", @@ -843,7 +832,7 @@ func TestGetMyEditRequestHandler_NotFound(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 (graceful empty response), got %d. body: %s", w.Code, w.Body.String()) @@ -856,15 +845,15 @@ func TestGetMyEditRequestHandler_NotFound(t *testing.T) { // TestGetMyEditRequestHandler_AccessDenied verifies that a user cannot view // another user's edit request. func TestGetMyEditRequestHandler_AccessDenied(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - ownerID, otherUserID, _, bookingID, ownerToken := setupTwoUserEditRequestTest(t) + ownerID, otherUserID, _, bookingID, ownerToken := setupTwoUserEditRequestTest(t, ctx, tx) _ = ownerID // Create edit request as owner createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"notes": "Owner's edit"}, ownerToken) + map[string]interface{}{"notes": "Owner's edit"}, ownerToken, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -879,7 +868,7 @@ func TestGetMyEditRequestHandler_AccessDenied(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -893,19 +882,18 @@ func TestGetMyEditRequestHandler_AccessDenied(t *testing.T) { // TestGetMyEditRequestsHandler_Success verifies that a user can list all their // pending edit requests across bookings. func TestGetMyEditRequestsHandler_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, serviceID, bookingID, token := setupEditRequestTest(t) + userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) // Create a second booking with edit request (within 48h to avoid auto-approval) booking2Time := time.Now().Add(36 * time.Hour).Truncate(time.Second) - bookingID2, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, booking2Time) + bookingID2, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, booking2Time) if err != nil { t.Fatalf("failed to create second booking: %v", err) } - defer fixtures.DeleteBooking(db.DB, bookingID2) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID2) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID2) if err != nil { t.Fatalf("failed to confirm second booking: %v", err) } @@ -914,7 +902,7 @@ func TestGetMyEditRequestsHandler_Success(t *testing.T) { createHandler := http.HandlerFunc(RequestEditHandler) for _, bid := range []string{bookingID, bookingID2} { w := makeRequest(createHandler, "POST", "/api/bookings/"+bid+"/edit-request", - map[string]interface{}{"notes": "Test edit for " + bid}, token) + map[string]interface{}{"notes": "Test edit for " + bid}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request for %s: %d", bid, w.Code) } @@ -929,7 +917,7 @@ func TestGetMyEditRequestsHandler_Success(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -949,13 +937,12 @@ func TestGetMyEditRequestsHandler_Success(t *testing.T) { // TestGetMyEditRequestsHandler_Empty verifies that listing edit requests for a // user with no requests returns an empty list. func TestGetMyEditRequestsHandler_Empty(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) token := jwt.GenerateUserToken(userID) listHandler := http.HandlerFunc(GetMyEditRequestsHandler) @@ -966,7 +953,7 @@ func TestGetMyEditRequestsHandler_Empty(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -993,14 +980,14 @@ func TestGetMyEditRequestsHandler_Empty(t *testing.T) { // TestAdminListEditRequestsHandler_Success verifies that the admin can list // all edit requests with pagination metadata (requests + total). func TestAdminListEditRequestsHandler_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) // Create an edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"notes": "Admin list test"}, token) + map[string]interface{}{"notes": "Admin list test"}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -1015,7 +1002,7 @@ func TestAdminListEditRequestsHandler_Success(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1050,14 +1037,14 @@ func TestAdminListEditRequestsHandler_Success(t *testing.T) { // TestAdminListAllEditRequestsHandler_Success verifies AdminListAllEditRequestsHandler // returns all edit requests as enriched objects. func TestAdminListAllEditRequestsHandler_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) // Create an edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"notes": "Admin enriched list test"}, token) + map[string]interface{}{"notes": "Admin enriched list test"}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -1065,7 +1052,7 @@ func TestAdminListAllEditRequestsHandler_Success(t *testing.T) { // Admin lists all listHandler := http.HandlerFunc(AdminListAllEditRequestsHandler) w = serveAdminHandler(listHandler, "GET", "/api/admin/bookings/edit-requests", - "/api/admin/bookings/edit-requests", nil) + "/api/admin/bookings/edit-requests", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1092,9 +1079,9 @@ func TestAdminListAllEditRequestsHandler_Success(t *testing.T) { // TestAdminGetBookingEditRequestHandler_Success verifies the admin can view // the pending edit request for a specific booking. func TestAdminGetBookingEditRequestHandler_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, serviceID, bookingID, token := setupEditRequestTest(t) + userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _ = userID @@ -1107,7 +1094,7 @@ func TestAdminGetBookingEditRequestHandler_Success(t *testing.T) { map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), "notes": "Admin view test", - }, token) + }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -1115,7 +1102,7 @@ func TestAdminGetBookingEditRequestHandler_Success(t *testing.T) { // Admin views it viewHandler := http.HandlerFunc(AdminGetBookingEditRequestHandler) w = serveAdminHandler(viewHandler, "GET", "/api/admin/bookings/"+bookingID+"/edit-request", - "/api/admin/bookings/{id}/edit-request", nil) + "/api/admin/bookings/{id}/edit-request", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1138,13 +1125,13 @@ func TestAdminGetBookingEditRequestHandler_Success(t *testing.T) { // TestAdminGetBookingEditRequestHandler_NotFound verifies the admin gets 404 // when there is no edit request for the given booking. func TestAdminGetBookingEditRequestHandler_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, _ := setupEditRequestTest(t) + _, _, bookingID, _ := setupEditRequestTest(t, ctx, tx) viewHandler := http.HandlerFunc(AdminGetBookingEditRequestHandler) w := serveAdminHandler(viewHandler, "GET", "/api/admin/bookings/"+bookingID+"/edit-request", - "/api/admin/bookings/{id}/edit-request", nil) + "/api/admin/bookings/{id}/edit-request", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -1159,9 +1146,9 @@ func TestAdminGetBookingEditRequestHandler_NotFound(t *testing.T) { // edit request with a new start time updates the booking and cleans up // the edit request + time_blocker. func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) @@ -1172,19 +1159,19 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), - }, token) + }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Get edit request ID - editRequestID := getEditRequestIDFromDB(t, bookingID) + editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingID) // Admin approves approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w = serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -1192,7 +1179,7 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { // Verify booking start_time was updated var dbStartTime time.Time - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -1203,7 +1190,7 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { // Verify edit request was deleted var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -1213,7 +1200,7 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { } // Verify time_blocker was deleted - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&erCount) if err != nil { @@ -1225,7 +1212,7 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { // Verify admin notification was acknowledged var ackCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NOT NULL", bookingID).Scan(&ackCount) if err != nil { @@ -1239,25 +1226,23 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { // TestAdminApproveEditRequestHandler_WithServices verifies that approving an // edit request with new_services replaces the booking's services. func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, serviceID, bookingID, token := setupEditRequestTest(t) + userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) - serviceID2, err := fixtures.CreateTestService(db.DB) + serviceID2, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create second service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID2) - newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) - editRequestID := createEditRequestDirectly(t, bookingID, userID, &newStartTime, []string{serviceID2}, nil) + editRequestID := createEditRequestDirectly(t, ctx, tx, bookingID, userID, &newStartTime, []string{serviceID2}, nil) _ = serviceID _ = token var dbReqID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&dbReqID) if err != nil { t.Fatalf("edit request should exist: %v", err) @@ -1266,14 +1251,14 @@ func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) { approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w := serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } var serviceCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_services WHERE booking_id = $1", bookingID).Scan(&serviceCount) if err != nil { t.Fatalf("failed to query booking_services: %v", err) @@ -1283,7 +1268,7 @@ func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) { } var actualServiceID string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT service_id FROM booking_services WHERE booking_id = $1", bookingID).Scan(&actualServiceID) if err != nil { t.Fatalf("failed to get booking service: %v", err) @@ -1293,7 +1278,7 @@ func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) { } var dbStartTime time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -1306,12 +1291,12 @@ func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) { // TestAdminApproveEditRequestHandler_NotFound verifies that approving a // non-existent edit request returns 404. func TestAdminApproveEditRequestHandler_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, _ := testutils.SetupTestTx(t) approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w := serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/fake-booking-id/edit-requests/nonexistent-request-id/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -1330,9 +1315,9 @@ func TestAdminApproveEditRequestHandler_NotFound(t *testing.T) { // request removes it, cleans up the time_blocker, and acknowledges the // admin notification. func TestAdminRejectEditRequestHandler_Success(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) @@ -1343,16 +1328,16 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) { w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), - }, token) + }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } - editRequestID := getEditRequestIDFromDB(t, bookingID) + editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingID) // Verify time_blocker exists var blockerID string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT id FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerID) if err != nil { @@ -1363,7 +1348,7 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) { rejectHandler := http.HandlerFunc(AdminRejectEditRequestHandler) w = serveAdminHandler(rejectHandler, "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", - "/api/admin/bookings/{id}/edit-requests/{request_id}/deny", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/deny", nil, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -1371,7 +1356,7 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) { // Verify edit request was deleted var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -1381,7 +1366,7 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) { } // Verify time_blocker was deleted - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&erCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) @@ -1392,7 +1377,7 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) { // Verify admin notification was acknowledged var ackCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NOT NULL", bookingID).Scan(&ackCount) if err != nil { @@ -1406,10 +1391,10 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) { var dbStartTime time.Time var originalStartTime time.Time // Get original start time from before - _ = db.DB.QueryRow(context.Background(), + _ = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime) - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -1422,12 +1407,12 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) { // TestAdminRejectEditRequestHandler_NotFound verifies that rejecting a // non-existent edit request returns 404. func TestAdminRejectEditRequestHandler_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + ctx, _ := testutils.SetupTestTx(t) rejectHandler := http.HandlerFunc(AdminRejectEditRequestHandler) w := serveAdminHandler(rejectHandler, "POST", "/api/admin/bookings/fake-booking-id/edit-requests/nonexistent-request-id/deny", - "/api/admin/bookings/{id}/edit-requests/{request_id}/deny", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/deny", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -1445,9 +1430,9 @@ func TestAdminRejectEditRequestHandler_NotFound(t *testing.T) { // TestRequestEditHandler_TimeBlockerCreated verifies that creating an edit // request with a time change creates a RESERVATION:edit_request time_blocker. func TestRequestEditHandler_TimeBlockerCreated(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) @@ -1456,7 +1441,7 @@ func TestRequestEditHandler_TimeBlockerCreated(t *testing.T) { w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), - }, token) + }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -1465,7 +1450,7 @@ func TestRequestEditHandler_TimeBlockerCreated(t *testing.T) { var description string var blockerStartTime time.Time var durationMinutes int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT description, start_time, duration_minutes FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), ).Scan(&description, &blockerStartTime, &durationMinutes) @@ -1484,19 +1469,19 @@ func TestRequestEditHandler_TimeBlockerCreated(t *testing.T) { // TestRequestEditHandler_TimeBlockerNotCreatedForNotesOnly verifies that a // notes-only edit request does NOT create a time_blocker. func TestRequestEditHandler_TimeBlockerNotCreatedForNotesOnly(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"notes": "Just notes, no time change"}, token) + map[string]interface{}{"notes": "Just notes, no time change"}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } var count int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&count) if err != nil { @@ -1511,9 +1496,9 @@ func TestRequestEditHandler_TimeBlockerNotCreatedForNotesOnly(t *testing.T) { // edit request is replaced (upsert) with a different time, the old // time_blocker is deleted and a new one is created. func TestRequestEditHandler_TimeBlockerReplacedOnUpsert(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) time1 := time.Now().Add(48 * time.Hour).Truncate(time.Second) time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location()) @@ -1525,14 +1510,14 @@ func TestRequestEditHandler_TimeBlockerReplacedOnUpsert(t *testing.T) { // Create first edit request w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": time1.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": time1.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("first edit request failed: %d", w.Code) } // Verify time_blocker for time1 var count int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1 AND start_time = $2", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), time1).Scan(&count) if err != nil { @@ -1544,13 +1529,13 @@ func TestRequestEditHandler_TimeBlockerReplacedOnUpsert(t *testing.T) { // Replace with second time w = makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": time2.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": time2.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("second edit request failed: %d", w.Code) } // Verify old time_blocker is gone - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1 AND start_time = $2", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), time1).Scan(&count) if err != nil { @@ -1561,7 +1546,7 @@ func TestRequestEditHandler_TimeBlockerReplacedOnUpsert(t *testing.T) { } // Verify new time_blocker exists for time2 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1 AND start_time = $2", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), time2).Scan(&count) if err != nil { @@ -1579,9 +1564,9 @@ func TestRequestEditHandler_TimeBlockerReplacedOnUpsert(t *testing.T) { // TestAdminApproveEditRequestHandler_WithNotesOnly verifies that approving a // notes-only edit request updates the booking notes. func TestAdminApproveEditRequestHandler_WithNotesOnly(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, serviceID, bookingID, token := setupEditRequestTest(t) + userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _ = userID @@ -1590,18 +1575,18 @@ func TestAdminApproveEditRequestHandler_WithNotesOnly(t *testing.T) { // Create notes-only edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"notes": notes}, token) + map[string]interface{}{"notes": notes}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } - editRequestID := getEditRequestIDFromDB(t, bookingID) + editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingID) // Admin approves approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w = serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -1609,7 +1594,7 @@ func TestAdminApproveEditRequestHandler_WithNotesOnly(t *testing.T) { // Verify booking notes were updated var dbNotes string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COALESCE(notes, '') FROM bookings WHERE id = $1", bookingID).Scan(&dbNotes) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -1622,30 +1607,27 @@ func TestAdminApproveEditRequestHandler_WithNotesOnly(t *testing.T) { // TestAdminApproveEditRequestHandler_OverlapWithBooking verifies that approving // an edit request that would cause a time overlap returns 409. func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) - userID, err := fixtures.CreateTestUser(db.DB) + + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) - - _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - defer fixtures.DeleteService(db.DB, serviceID) // Get service duration for overlap calculation var serviceDuration int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&serviceDuration) if err != nil { t.Fatalf("failed to get service duration: %v", err) @@ -1659,27 +1641,24 @@ func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) { baseTime := time.Now().Add(40 * time.Hour).Truncate(time.Second) baseTime = time.Date(baseTime.Year(), baseTime.Month(), baseTime.Day(), 9, 0, 0, 0, baseTime.Location()) - booking1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + booking1, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking1: %v", err) } - defer fixtures.DeleteBooking(db.DB, booking1) - - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET start_time = $1, status = 'confirmed' WHERE id = $2", baseTime, booking1) if err != nil { t.Fatalf("failed to update booking1: %v", err) } // Create second booking that overlaps with first - booking2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + booking2, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking2: %v", err) } - defer fixtures.DeleteBooking(db.DB, booking2) // Set booking2 start time during booking1's slot (overlap) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "UPDATE bookings SET start_time = $1, status = 'confirmed' WHERE id = $2", baseTime.Add(time.Duration(serviceDuration/2)*time.Minute), booking2) if err != nil { @@ -1693,18 +1672,18 @@ func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) { w := makeRequest(handler, "POST", "/api/bookings/"+booking2+"/edit-request", map[string]interface{}{ "new_start_time": baseTime.Format(time.RFC3339), // move to time that overlaps booking1 - }, token) + }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request on booking2: %d. body: %s", w.Code, w.Body.String()) } - editRequestID := getEditRequestIDFromDB(t, booking2) + editRequestID := getEditRequestIDFromDB(t, ctx, tx, booking2) // Admin tries to approve — should get overlap conflict approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w = serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+booking2+"/edit-requests/"+editRequestID+"/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 (overlap conflict), got %d. body: %s", w.Code, w.Body.String()) @@ -1714,9 +1693,9 @@ func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) { // TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove verifies // that approving an edit request removes the associated time_blocker. func TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, serviceID, bookingID, token := setupEditRequestTest(t) + userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _ = userID @@ -1725,24 +1704,24 @@ func TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove(t *testing.T handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } - editRequestID := getEditRequestIDFromDB(t, bookingID) + editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingID) approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w = serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } var count int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&count) if err != nil { @@ -1756,9 +1735,9 @@ func TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove(t *testing.T // TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject verifies // that rejecting an edit request removes the associated time_blocker. func TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) @@ -1766,24 +1745,24 @@ func TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject(t *testing.T) handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } - editRequestID := getEditRequestIDFromDB(t, bookingID) + editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingID) rejectHandler := http.HandlerFunc(AdminRejectEditRequestHandler) w = serveAdminHandler(rejectHandler, "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", - "/api/admin/bookings/{id}/edit-requests/{request_id}/deny", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/deny", nil, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } var count int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&count) if err != nil { @@ -1802,9 +1781,9 @@ func TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject(t *testing.T) // cancels a booking with a pending edit request, the edit request, associated // time_blocker, and admin_notification are all deleted. func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) @@ -1813,14 +1792,14 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { // Create edit request (creates time_blocker + admin_notification) createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Verify edit request exists var erCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -1831,7 +1810,7 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { // Verify time_blocker exists var blockerCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { @@ -1843,7 +1822,7 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { // Verify admin_notification exists var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { @@ -1855,13 +1834,13 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { // Cancel the booking cancelHandler := http.HandlerFunc(UserCancelBookingHandler) - w = makeRequest(cancelHandler, "DELETE", "/api/bookings/"+bookingID, nil, token) + w = makeRequest(cancelHandler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204 on cancel, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was deleted - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests after cancel: %v", err) @@ -1871,7 +1850,7 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { } // Verify time_blocker was deleted - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { @@ -1882,7 +1861,7 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { } // Verify admin_notification was deleted - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { @@ -1901,17 +1880,17 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) { // that approving an edit request whose proposed time falls during exceptional // closed hours returns 409 Conflict. func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - seedDefaultWorkingHours(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID // Create an exceptional closed hours group for a fixed date (Thursday) targetDate := time.Date(2026, 2, 26, 0, 0, 0, 0, time.UTC) // Thursday Feb 26, 2026 var groupID int - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id @@ -1919,11 +1898,11 @@ func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testi if err != nil { t.Fatalf("failed to create holiday group: %v", err) } - defer db.DB.Exec(context.Background(), "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID) + defer tx.Exec(ctx, "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID) // Add closed hours for targetDate (closed all day) dbWeekday := (int(targetDate.Weekday()) + 6) % 7 - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, $3, $4, $5) `, groupID, dbWeekday, "00:00:00", "23:59:59", false) @@ -1937,7 +1916,7 @@ func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testi daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) @@ -1949,18 +1928,18 @@ func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testi targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second) // 2 PM on targetDate createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": targetTime.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": targetTime.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } - editRequestID := getEditRequestIDFromDB(t, bookingID) + editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingID) // Admin tries to approve — should be blocked approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler) w = serveAdminHandler(approveHandler, "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusConflict { t.Fatalf("expected 409 Conflict for closed hours, got %d. body: %s", w.Code, w.Body.String()) @@ -1972,7 +1951,7 @@ func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testi // Verify edit request still exists (not consumed by failed approve) var erCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) @@ -1990,13 +1969,13 @@ func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testi // edit request response correctly calculates end_time from start_time + // total service duration for both original and proposed snapshots. func TestGetMyEditRequestHandler_EndTimeCalculation(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) // Get service duration var serviceDuration int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&serviceDuration) if err != nil { t.Fatalf("failed to get service duration: %v", err) @@ -2008,7 +1987,7 @@ func TestGetMyEditRequestHandler_EndTimeCalculation(t *testing.T) { createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -2022,7 +2001,7 @@ func TestGetMyEditRequestHandler_EndTimeCalculation(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2060,15 +2039,15 @@ func TestGetMyEditRequestHandler_EndTimeCalculation(t *testing.T) { // TestGetMyEditRequestsHandler_CrossUserIsolation verifies that user A's // edit requests do not appear in user B's list. func TestGetMyEditRequestsHandler_CrossUserIsolation(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - ownerID, otherUserID, _, bookingID, ownerToken := setupTwoUserEditRequestTest(t) + ownerID, otherUserID, _, bookingID, ownerToken := setupTwoUserEditRequestTest(t, ctx, tx) _ = ownerID // Create edit request as owner createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"notes": "Owner's edit"}, ownerToken) + map[string]interface{}{"notes": "Owner's edit"}, ownerToken, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -2083,7 +2062,7 @@ func TestGetMyEditRequestsHandler_CrossUserIsolation(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2108,7 +2087,7 @@ func TestGetMyEditRequestsHandler_CrossUserIsolation(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200 for owner, got %d. body: %s", w.Code, w.Body.String()) @@ -2130,9 +2109,9 @@ func TestGetMyEditRequestsHandler_CrossUserIsolation(t *testing.T) { // TestAdminGetBookingEditRequestHandler_EnrichedData verifies the admin view // returns full enriched data with original/proposed snapshots and user summary. func TestAdminGetBookingEditRequestHandler_EnrichedData(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) @@ -2144,7 +2123,7 @@ func TestAdminGetBookingEditRequestHandler_EnrichedData(t *testing.T) { map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), "notes": "Admin enriched test", - }, token) + }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -2152,7 +2131,7 @@ func TestAdminGetBookingEditRequestHandler_EnrichedData(t *testing.T) { // Admin views it viewHandler := http.HandlerFunc(AdminGetBookingEditRequestHandler) w = serveAdminHandler(viewHandler, "GET", "/api/admin/bookings/"+bookingID+"/edit-request", - "/api/admin/bookings/{id}/edit-request", nil) + "/api/admin/bookings/{id}/edit-request", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2204,12 +2183,12 @@ func TestAdminGetBookingEditRequestHandler_EnrichedData(t *testing.T) { // override prices/durations, the enriched response uses original services for // both original and proposed snapshots (has_overrides branch). func TestGetMyEditRequestHandler_WithOverrides(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, serviceID, bookingID, token := setupEditRequestTest(t) + _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) // Add an override to the booking service - _, err := db.DB.Exec(context.Background(), + _, err := tx.Exec(ctx, "UPDATE booking_services SET override_price = 75.00, override_duration_minutes = 90 WHERE booking_id = $1 AND service_id = $2", bookingID, serviceID) if err != nil { @@ -2223,7 +2202,7 @@ func TestGetMyEditRequestHandler_WithOverrides(t *testing.T) { createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": newStartTime.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } @@ -2237,7 +2216,7 @@ func TestGetMyEditRequestHandler_WithOverrides(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2279,20 +2258,19 @@ func TestGetMyEditRequestHandler_WithOverrides(t *testing.T) { // TestAdminListEditRequestsHandler_Pagination verifies that the admin list // endpoint returns correct total count for pagination metadata. func TestAdminListEditRequestsHandler_Pagination(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, serviceID, bookingID, token := setupEditRequestTest(t) + userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) // Create 4 additional bookings with edit requests (within 48h to avoid auto-approval) bookingIDs := []string{bookingID} for i := 0; i < 4; i++ { bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) - newBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + newBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } - defer fixtures.DeleteBooking(db.DB, newBookingID) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", newBookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", newBookingID) if err != nil { t.Fatalf("failed to confirm booking %d: %v", i, err) } @@ -2303,7 +2281,7 @@ func TestAdminListEditRequestsHandler_Pagination(t *testing.T) { createHandler := http.HandlerFunc(RequestEditHandler) for i, bid := range bookingIDs { w := makeRequest(createHandler, "POST", "/api/bookings/"+bid+"/edit-request", - map[string]interface{}{"notes": fmt.Sprintf("Edit %d", i)}, token) + map[string]interface{}{"notes": fmt.Sprintf("Edit %d", i)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request %d: %d", i, w.Code) } @@ -2319,7 +2297,7 @@ func TestAdminListEditRequestsHandler_Pagination(t *testing.T) { ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx - }) + }, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2350,9 +2328,9 @@ func TestAdminListEditRequestsHandler_Pagination(t *testing.T) { // submits a second edit request (upsert), the old edit_requested notification // is deleted and a fresh one is created. func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - _, _, bookingID, token := setupEditRequestTest(t) + _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) time1 := time.Now().Add(48 * time.Hour).Truncate(time.Second) time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location()) @@ -2364,14 +2342,14 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) { // Create first edit request w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": time1.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": time1.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("first edit request failed: %d", w.Code) } // Capture first notification's created_at var firstCreatedAt time.Time - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT created_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(&firstCreatedAt) if err != nil { @@ -2379,7 +2357,7 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) { } var notifCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { @@ -2394,13 +2372,13 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) { // Create second edit request (upsert) w = makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", - map[string]interface{}{"new_start_time": time2.Format(time.RFC3339)}, token) + map[string]interface{}{"new_start_time": time2.Format(time.RFC3339)}, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("second edit request failed: %d", w.Code) } // Verify only 1 notification exists (old deleted, new created) - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { @@ -2412,7 +2390,7 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) { // Verify the notification has a fresh created_at (newer than original) var secondCreatedAt time.Time - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT created_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(&secondCreatedAt) if err != nil { @@ -2423,14 +2401,14 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) { // TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected verifies that admin cannot approve an edit request // that lands in a closed period due to exceptional working hours. func TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected(t *testing.T) { - testutils.SetupTestDB(t) + ctx, tx := testutils.SetupTestTx(t) - userID, _, bookingID, _ := setupEditRequestTest(t) + userID, _, bookingID, _ := setupEditRequestTest(t, ctx, tx) // Create exceptional holiday group for a date targetDate := time.Date(2026, 2, 26, 0, 0, 0, 0, time.UTC) var groupID int - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Holiday', 'Closed') RETURNING id @@ -2440,7 +2418,7 @@ func TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected(t *testing.T) { } dbWeekday := (int(targetDate.Weekday()) + 6) % 7 - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, $3, $4, $5) `, groupID, dbWeekday, "00:00:00", "23:59:59", false) @@ -2453,7 +2431,7 @@ func TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected(t *testing.T) { daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) @@ -2463,12 +2441,12 @@ func TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected(t *testing.T) { // Create edit request for that date newTime := targetDate.Add(14 * time.Hour).Truncate(time.Minute) - editRequestID := createEditRequestDirectly(t, bookingID, userID, &newTime, nil, nil) + editRequestID := createEditRequestDirectly(t, ctx, tx, bookingID, userID, &newTime, nil, nil) // Admin approves w := serveAdminHandler(http.HandlerFunc(AdminApproveEditRequestHandler), "POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", - "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil) + "/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 (conflict), got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/bookings/reserve_test.go b/backend/handlers/bookings/reserve_test.go index 65f6fd9..3cf7483 100644 --- a/backend/handlers/bookings/reserve_test.go +++ b/backend/handlers/bookings/reserve_test.go @@ -16,21 +16,15 @@ import ( "crussell/db" "crussell/handlers/scheduling" "crussell/mw" + "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" - "crussell/testutils/testdb" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) -func resetReserveTestData(t *testing.T) { - t.Helper() - testdb.TruncateTables(t, db.DB) - seedDefaultWorkingHours(t) -} - -func makeReserveRequest(method, path string, body interface{}, token string) *httptest.ResponseRecorder { +func makeReserveRequest(ctx context.Context, method, path string, body interface{}, token string) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -49,16 +43,16 @@ func makeReserveRequest(method, path string, body interface{}, token string) *ht req.Header.Set("Authorization", "Bearer "+token) } - // Set user context from token if present + // Set up chi routing context and user context from token if present if token != "" { if info := extractUserFromTestJWT(token); info != nil { rctx := chi.NewRouteContext() - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) - req = req.WithContext(ctx) } } + req = req.WithContext(ctx) w := httptest.NewRecorder() @@ -75,11 +69,11 @@ func reserveTestToken(t *testing.T, userID, role string) string { // TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot. func TestReserveSlot_LoggedIn(t *testing.T) { - resetReserveTestData(t) + ctx, tx := testutils.SetupTestTx(t) - userID, _ := fixtures.CreateTestUser(db.DB) + userID, _ := fixtures.CreateTestUser(tx) token := reserveTestToken(t, userID, "verified_email") - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } @@ -90,7 +84,7 @@ func TestReserveSlot_LoggedIn(t *testing.T) { ServiceIDs: []string{serviceID}, } - w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, token) + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -112,11 +106,11 @@ func TestReserveSlot_LoggedIn(t *testing.T) { // TestReserveSlot_LoggedIn_ReplacesExisting verifies creating a second reservation // for the same user deletes the first one (max 1 per user). func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) { - resetReserveTestData(t) + ctx, tx := testutils.SetupTestTx(t) - userID, _ := fixtures.CreateTestUser(db.DB) + userID, _ := fixtures.CreateTestUser(tx) token := reserveTestToken(t, userID, "verified_email") - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } @@ -124,7 +118,7 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) { // First reservation startTime1 := time.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) - w1 := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w1 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: startTime1, ServiceIDs: serviceIDs, }, token) @@ -134,7 +128,7 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) { // Second reservation (should replace first) startTime2 := time.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) - w2 := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: startTime2, ServiceIDs: serviceIDs, }, token) @@ -144,7 +138,7 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) { // Verify only one user reservation exists var count int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1 `, userID).Scan(&count) @@ -158,9 +152,9 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) { // TestReserveSlot_Anonymous verifies anonymous users can reserve a slot. func TestReserveSlot_Anonymous(t *testing.T) { - resetReserveTestData(t) + ctx, tx := testutils.SetupTestTx(t) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } @@ -172,7 +166,7 @@ func TestReserveSlot_Anonymous(t *testing.T) { ServiceIDs: serviceIDs, } - w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, "") + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, "") if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -191,16 +185,16 @@ func TestReserveSlot_Anonymous(t *testing.T) { // TestReserveSlot_ValidationErrors verifies that missing or invalid // fields result in 400 Bad Request. func TestReserveSlot_ValidationErrors(t *testing.T) { - resetReserveTestData(t) + ctx, tx := testutils.SetupTestTx(t) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } serviceIDs := []string{serviceID} // Missing start_time - w := makeReserveRequest("POST", "/api/bookings/reserve", map[string]interface{}{ + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{ "service_ids": serviceIDs, }, "") if w.Code != http.StatusBadRequest { @@ -209,7 +203,7 @@ func TestReserveSlot_ValidationErrors(t *testing.T) { // Missing service_ids startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) - w = makeReserveRequest("POST", "/api/bookings/reserve", map[string]interface{}{ + w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{ "start_time": startTime, }, "") if w.Code != http.StatusBadRequest { @@ -217,7 +211,7 @@ func TestReserveSlot_ValidationErrors(t *testing.T) { } // Past start_time - w = makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: time.Now().Add(-1 * time.Hour), ServiceIDs: serviceIDs, }, "") @@ -229,17 +223,17 @@ func TestReserveSlot_ValidationErrors(t *testing.T) { // TestReserveSlot_BlockedByExistingBooking verifies that reserving a slot // that overlaps an existing booking returns 409 Conflict. func TestReserveSlot_BlockedByExistingBooking(t *testing.T) { - resetReserveTestData(t) + ctx, tx := testutils.SetupTestTx(t) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create a fixture user and booking at the same time - userID, _ := fixtures.CreateTestUser(db.DB) + userID, _ := fixtures.CreateTestUser(tx) bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, userID, bookingStart) @@ -247,7 +241,7 @@ func TestReserveSlot_BlockedByExistingBooking(t *testing.T) { t.Fatalf("failed to create booking: %v", err) } - w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: bookingStart, ServiceIDs: []string{serviceID}, }, "") @@ -259,15 +253,15 @@ func TestReserveSlot_BlockedByExistingBooking(t *testing.T) { // TestReserveSlot_BlockedByTimeBlocker verifies that reserving a blocked // time slot returns 409 Conflict. func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) { - resetReserveTestData(t) + ctx, tx := testutils.SetupTestTx(t) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Admin Blocked', NULL) `, blockerStart) @@ -275,7 +269,7 @@ func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) { t.Fatalf("failed to create blocker: %v", err) } - w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: blockerStart, ServiceIDs: []string{serviceID}, }, "") @@ -287,16 +281,14 @@ func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) { // TestReserveSlot_DualCleanup verifies that CleanupOldReservations deletes // anon reservations after 10 minutes and user reservations after 1 hour. func TestReserveSlot_DualCleanup(t *testing.T) { - resetReserveTestData(t) - - ctx := context.Background() + ctx, tx := testutils.SetupTestTx(t) // Create fixture users for user reservations - user1ID, _ := fixtures.CreateTestUser(db.DB) - user2ID, _ := fixtures.CreateTestUser(db.DB) + user1ID, _ := fixtures.CreateTestUser(tx) + user2ID, _ := fixtures.CreateTestUser(tx) // Create old anon reservation (15 min ago) - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL) `, time.Now().Add(24*time.Hour), time.Now().Add(-15*time.Minute)) @@ -305,7 +297,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) { } // Create recent anon reservation (5 min ago) - should survive - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL) `, time.Now().Add(48*time.Hour), time.Now().Add(-5*time.Minute)) @@ -314,7 +306,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) { } // Create old user reservation (45 min ago) - should survive (> 10min, < 1hr) - _, err = db.DB.Exec(ctx, fmt.Sprintf(` + _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3) `, user1ID), time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute), user1ID) @@ -323,7 +315,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) { } // Create very old user reservation (2 hours ago) - should be deleted - _, err = db.DB.Exec(ctx, fmt.Sprintf(` + _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3) `, user2ID), time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour), user2ID) @@ -339,28 +331,28 @@ func TestReserveSlot_DualCleanup(t *testing.T) { // Verify old anon was deleted var anonOldCount int - db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:abc12345:%'`).Scan(&anonOldCount) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:abc12345:%'`).Scan(&anonOldCount) if anonOldCount > 0 { t.Error("expected old anon reservation to be deleted") } // Verify recent anon survived var anonRecentCount int - db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:def67890:%'`).Scan(&anonRecentCount) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:def67890:%'`).Scan(&anonRecentCount) if anonRecentCount != 1 { t.Error("expected recent anon reservation to survive") } // Verify 45-min user reservation survived var user45Count int - db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user1ID)).Scan(&user45Count) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user1ID)).Scan(&user45Count) if user45Count != 1 { t.Error("expected 45-min user reservation to survive") } // Verify 2hr user reservation was deleted var user2hrCount int - db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user2ID)).Scan(&user2hrCount) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user2ID)).Scan(&user2hrCount) if user2hrCount > 0 { t.Error("expected 2-hour user reservation to be deleted") } @@ -368,7 +360,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) { // seedCustomWorkingHours replaces working_hours with the given schedule. // DB convention: 0=Monday, 1=Tuesday, ..., 6=Sunday. -func seedCustomWorkingHours(t *testing.T, hours []struct { +func seedCustomWorkingHours(t *testing.T, ctx context.Context, q db.Querier, hours []struct { weekday int startTime string endTime string @@ -376,7 +368,7 @@ func seedCustomWorkingHours(t *testing.T, hours []struct { }) { t.Helper() for _, h := range hours { - _, err := db.DB.Exec(context.Background(), ` + _, err := q.Exec(ctx, ` 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 @@ -405,6 +397,8 @@ func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time { // TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday) // is correctly mapped to the DB's weekday convention (0=Monday). func TestReserveSlot_WeekdayConversion(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + hours := []struct { weekday int startTime string @@ -420,10 +414,9 @@ func TestReserveSlot_WeekdayConversion(t *testing.T) { {6, "00:00", "00:00", false}, } - testdb.TruncateTables(t, db.DB) - seedCustomWorkingHours(t, hours) + seedCustomWorkingHours(t, ctx, tx, hours) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } @@ -451,7 +444,7 @@ func TestReserveSlot_WeekdayConversion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") @@ -467,6 +460,8 @@ func TestReserveSlot_WeekdayConversion(t *testing.T) { // past closing time are rejected, and that the correct day's closing time // is used (not the wrong day due to weekday mismatch). func TestReserveSlot_ClosingHoursValidation(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + hours := []struct { weekday int startTime string @@ -482,10 +477,9 @@ func TestReserveSlot_ClosingHoursValidation(t *testing.T) { {6, "00:00", "00:00", false}, } - testdb.TruncateTables(t, db.DB) - seedCustomWorkingHours(t, hours) + seedCustomWorkingHours(t, ctx, tx, hours) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } @@ -510,7 +504,7 @@ func TestReserveSlot_ClosingHoursValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") @@ -526,6 +520,8 @@ func TestReserveSlot_ClosingHoursValidation(t *testing.T) { // from the browser is correctly interpreted as London local time for the // purpose of working hours lookup. func TestReserveSlot_UTCtoLondonConversion(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + hours := []struct { weekday int startTime string @@ -541,10 +537,9 @@ func TestReserveSlot_UTCtoLondonConversion(t *testing.T) { {6, "00:00", "00:00", false}, } - testdb.TruncateTables(t, db.DB) - seedCustomWorkingHours(t, hours) + seedCustomWorkingHours(t, ctx, tx, hours) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } @@ -570,7 +565,7 @@ func TestReserveSlot_UTCtoLondonConversion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") @@ -586,6 +581,8 @@ func TestReserveSlot_UTCtoLondonConversion(t *testing.T) { // time is used independently — a late-booking on a late-closing day should // succeed while the same time on an early-closing day should fail. func TestReserveSlot_DifferentClosingPerDay(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + hours := []struct { weekday int startTime string @@ -601,10 +598,9 @@ func TestReserveSlot_DifferentClosingPerDay(t *testing.T) { {6, "00:00", "00:00", false}, } - testdb.TruncateTables(t, db.DB) - seedCustomWorkingHours(t, hours) + seedCustomWorkingHours(t, ctx, tx, hours) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } @@ -628,7 +624,7 @@ func TestReserveSlot_DifferentClosingPerDay(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{ + w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ StartTime: tt.startTime, ServiceIDs: []string{serviceID}, }, "") diff --git a/backend/handlers/bookings/testmain_test.go b/backend/handlers/bookings/testmain_test.go index 09aa1d6..ed84da8 100644 --- a/backend/handlers/bookings/testmain_test.go +++ b/backend/handlers/bookings/testmain_test.go @@ -16,10 +16,11 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_bookings") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() square.Client = square.NewDevClient() payments.SquareClient = square.Client + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_bookings") os.Exit(code) diff --git a/backend/handlers/handlers_test.go b/backend/handlers/handlers_test.go index cb6d7fa..ec0c1b0 100644 --- a/backend/handlers/handlers_test.go +++ b/backend/handlers/handlers_test.go @@ -19,8 +19,8 @@ import ( "net/http/httptest" "testing" - "crussell/db" "crussell/mw" + "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" ) @@ -29,6 +29,8 @@ import ( // This test ensures the basic HTTP server is responding and the health // check handler is properly wired up to return a status response. func TestHealthCheck(t *testing.T) { + t.Parallel() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) @@ -53,6 +55,8 @@ func TestHealthCheck(t *testing.T) { // It tests three scenarios: missing auth header (401), valid token (200 with // user context), and invalid token (401). func TestRequireAuthMiddleware(t *testing.T) { + t.Parallel() + handler := mw.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID, _ := r.Context().Value(mw.UserIDKey).(string) role, _ := r.Context().Value(mw.UserRoleKey).(string) @@ -112,6 +116,8 @@ func TestRequireAuthMiddleware(t *testing.T) { // protected resources. It chains RequireAuth before RequireRole to populate // the role in the request context. func TestRequireRoleMiddleware(t *testing.T) { + t.Parallel() + // Chain RequireAuth before RequireRole to set the role in context // RequireRole expects role to be in context, but that's only set by RequireAuth adminOnlyHandler := mw.RequireAuth(mw.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -153,15 +159,18 @@ func TestRequireRoleMiddleware(t *testing.T) { // the token, and context values are correctly propagated to handlers. // This is an integration test that validates the complete middleware chain. func TestIntegration_UserFlow(t *testing.T) { + t.Parallel() + if testing.Short() { t.Skip("skipping integration test in short mode") } - userID, err := fixtures.CreateTestUser(db.DB) + _, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) token := jwt.GenerateUserToken(userID) diff --git a/backend/handlers/notifications/notifications_extended_test.go b/backend/handlers/notifications/notifications_extended_test.go index 7411cfe..d0141c9 100644 --- a/backend/handlers/notifications/notifications_extended_test.go +++ b/backend/handlers/notifications/notifications_extended_test.go @@ -18,10 +18,9 @@ import ( "crussell/mw" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" ) -func makeExtendedAdminRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { +func makeExtendedAdminRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -35,7 +34,7 @@ func makeExtendedAdminRequest(handler http.Handler, method, path string, body in if id, _ := extractIDFromPath(path); id != "" { rctx.URLParams.Add("id", id) } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, "admin001") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) @@ -45,10 +44,10 @@ func makeExtendedAdminRequest(handler http.Handler, method, path string, body in return w } -func createTestUser(t *testing.T) string { +func createTestUser(t *testing.T, ctx context.Context, q db.Querier) string { t.Helper() var userID string - err := db.DB.QueryRow(context.Background(), ` + err := q.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -59,7 +58,7 @@ func createTestUser(t *testing.T) string { return userID } -func createNotification(t *testing.T, reason, userID string, acknowledged bool) string { +func createNotification(t *testing.T, ctx context.Context, q db.Querier, reason, userID string, acknowledged bool) string { t.Helper() var notificationID string if userID == "" { @@ -67,7 +66,7 @@ func createNotification(t *testing.T, reason, userID string, acknowledged bool) if acknowledged { query = `INSERT INTO admin_notifications (reason, acknowledged_at) VALUES ($1, NOW()) RETURNING id` } - err := db.DB.QueryRow(context.Background(), query, reason).Scan(¬ificationID) + err := q.QueryRow(ctx, query, reason).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } @@ -76,7 +75,7 @@ func createNotification(t *testing.T, reason, userID string, acknowledged bool) if acknowledged { query = `INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ($1, $2, NOW()) RETURNING id` } - err := db.DB.QueryRow(context.Background(), query, reason, userID).Scan(¬ificationID) + err := q.QueryRow(ctx, query, reason, userID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } @@ -89,14 +88,15 @@ func createNotification(t *testing.T, reason, userID string, acknowledged bool) // ============================================================================= func TestNotifications_IncludeAcknowledged_Default(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "pending_booking", userID, false) - createNotification(t, "cancelled_booking", userID, true) + createNotification(t, ctx, tx, "pending_booking", userID, false) + createNotification(t, ctx, tx, "cancelled_booking", userID, true) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -109,14 +109,15 @@ func TestNotifications_IncludeAcknowledged_Default(t *testing.T) { } func TestNotifications_IncludeAcknowledged_True(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "pending_booking", userID, false) - createNotification(t, "cancelled_booking", userID, true) + createNotification(t, ctx, tx, "pending_booking", userID, false) + createNotification(t, ctx, tx, "cancelled_booking", userID, true) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -129,13 +130,14 @@ func TestNotifications_IncludeAcknowledged_True(t *testing.T) { } func TestNotifications_IncludeAcknowledged_ResponseHasAcknowledgedAt(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "pending_booking", userID, true) + createNotification(t, ctx, tx, "pending_booking", userID, true) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -156,8 +158,9 @@ func TestNotifications_IncludeAcknowledged_ResponseHasAcknowledgedAt(t *testing. // ============================================================================= func TestNotifications_PriorityOrdering(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) // Create notifications in reverse priority order reasons := []string{ @@ -167,11 +170,11 @@ func TestNotifications_PriorityOrdering(t *testing.T) { "cancelled_booking", } for _, reason := range reasons { - createNotification(t, reason, userID, false) + createNotification(t, ctx, tx, reason, userID, false) } handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -192,16 +195,17 @@ func TestNotifications_PriorityOrdering(t *testing.T) { } func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) // Create two pending_booking notifications with a time gap - createNotification(t, "pending_booking", userID, false) + createNotification(t, ctx, tx, "pending_booking", userID, false) time.Sleep(10 * time.Millisecond) - createNotification(t, "pending_booking", userID, false) + createNotification(t, ctx, tx, "pending_booking", userID, false) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -219,15 +223,16 @@ func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T) } func TestNotifications_AllNotifications_NewestFirst(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "pending_booking", userID, false) + createNotification(t, ctx, tx, "pending_booking", userID, false) time.Sleep(10 * time.Millisecond) - createNotification(t, "cancelled_booking", userID, true) + createNotification(t, ctx, tx, "cancelled_booking", userID, true) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -249,15 +254,16 @@ func TestNotifications_AllNotifications_NewestFirst(t *testing.T) { // ============================================================================= func TestNotifications_UnreadCount(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "pending_booking", userID, false) - createNotification(t, "cancelled_booking", userID, false) - createNotification(t, "affiliate_claim", userID, true) + createNotification(t, ctx, tx, "pending_booking", userID, false) + createNotification(t, ctx, tx, "cancelled_booking", userID, false) + createNotification(t, ctx, tx, "affiliate_claim", userID, true) handler := http.HandlerFunc(GetUnreadCount) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -274,13 +280,14 @@ func TestNotifications_UnreadCount(t *testing.T) { } func TestNotifications_UnreadCount_Zero(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "pending_booking", userID, true) + createNotification(t, ctx, tx, "pending_booking", userID, true) handler := http.HandlerFunc(GetUnreadCount) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil, ctx) var resp map[string]int if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -293,10 +300,11 @@ func TestNotifications_UnreadCount_Zero(t *testing.T) { } func TestNotifications_UnreadCount_Empty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetUnreadCount) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil, ctx) var resp map[string]int if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -313,13 +321,14 @@ func TestNotifications_UnreadCount_Empty(t *testing.T) { // ============================================================================= func TestNotifications_NewBookingReason(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "new_booking", userID, false) + createNotification(t, ctx, tx, "new_booking", userID, false) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -336,13 +345,14 @@ func TestNotifications_NewBookingReason(t *testing.T) { } func TestNotifications_EditRequestedReason(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "edit_requested", userID, false) + createNotification(t, ctx, tx, "edit_requested", userID, false) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -359,14 +369,15 @@ func TestNotifications_EditRequestedReason(t *testing.T) { } func TestNotifications_Priority_NewBookingBelowPendingBooking(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "new_booking", userID, false) - createNotification(t, "pending_booking", userID, false) + createNotification(t, ctx, tx, "new_booking", userID, false) + createNotification(t, ctx, tx, "pending_booking", userID, false) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -387,15 +398,16 @@ func TestNotifications_Priority_NewBookingBelowPendingBooking(t *testing.T) { // ============================================================================= func TestNotifications_IncludeAcknowledgedWithReasonFilter(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - createNotification(t, "pending_booking", userID, false) - createNotification(t, "pending_booking", userID, true) - createNotification(t, "cancelled_booking", userID, false) + createNotification(t, ctx, tx, "pending_booking", userID, false) + createNotification(t, ctx, tx, "pending_booking", userID, true) + createNotification(t, ctx, tx, "cancelled_booking", userID, false) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&reason=pending_booking", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&reason=pending_booking", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -412,11 +424,12 @@ func TestNotifications_IncludeAcknowledgedWithReasonFilter(t *testing.T) { // ============================================================================= func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create a service var serviceID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Test service', 25.00, 30, true) RETURNING id @@ -427,7 +440,7 @@ func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) { // Create a user var userID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Alice', 'Smith', 'alice@test.com', '+447700900001', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -438,7 +451,7 @@ func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) { // Create a booking var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW() + INTERVAL '3 days', 'pending') RETURNING id @@ -448,10 +461,10 @@ func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) { } // Create notification with both user_id and booking_id - createNotificationWithBooking(t, "pending_booking", userID, bookingID, false) + createNotificationWithBooking(t, ctx, tx, "pending_booking", userID, bookingID, false) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -472,13 +485,14 @@ func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) { } func TestNotifications_ResponseEnriched_NoUserOrBooking(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create notification without user_id or booking_id - createNotification(t, "1_week_no_pay", "", false) + createNotification(t, ctx, tx, "1_week_no_pay", "", false) handler := http.HandlerFunc(GetNotifications) - w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { @@ -503,13 +517,14 @@ func TestNotifications_ResponseEnriched_NoUserOrBooking(t *testing.T) { // ============================================================================= func TestNotifications_Acknowledge_ViaExtendedHandler(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - notifID := createNotification(t, "pending_booking", userID, false) + notifID := createNotification(t, ctx, tx, "pending_booking", userID, false) handler := http.HandlerFunc(AcknowledgeNotification) - w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notifID), nil) + w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notifID), nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -517,7 +532,7 @@ func TestNotifications_Acknowledge_ViaExtendedHandler(t *testing.T) { // Verify acknowledged var ackTime *time.Time - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT acknowledged_at FROM admin_notifications WHERE id = $1", notifID).Scan(&ackTime) if err != nil { t.Fatalf("failed to query notification: %v", err) @@ -528,13 +543,14 @@ func TestNotifications_Acknowledge_ViaExtendedHandler(t *testing.T) { } func TestNotifications_Acknowledge_AlreadyAcknowledged_Extended(t *testing.T) { - testutils.SetupTestDB(t) - userID := createTestUser(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) - notifID := createNotification(t, "pending_booking", userID, true) + notifID := createNotification(t, ctx, tx, "pending_booking", userID, true) handler := http.HandlerFunc(AcknowledgeNotification) - w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notifID), nil) + w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notifID), nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for already acknowledged, got %d", w.Code) @@ -542,19 +558,18 @@ func TestNotifications_Acknowledge_AlreadyAcknowledged_Extended(t *testing.T) { } // createNotificationWithBooking creates a notification with both user_id and booking_id -func createNotificationWithBooking(t *testing.T, reason, userID, bookingID string, acknowledged bool) string { +func createNotificationWithBooking(t *testing.T, ctx context.Context, q db.Querier, reason, userID, bookingID string, acknowledged bool) string { t.Helper() var notificationID string query := `INSERT INTO admin_notifications (reason, user_id, booking_id) VALUES ($1, $2, $3) RETURNING id` if acknowledged { query = `INSERT INTO admin_notifications (reason, user_id, booking_id, acknowledged_at) VALUES ($1, $2, $3, NOW()) RETURNING id` } - err := db.DB.QueryRow(context.Background(), query, reason, userID, bookingID).Scan(¬ificationID) + err := q.QueryRow(ctx, query, reason, userID, bookingID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } return notificationID } -// Ensure test compilation -var _ = func() *pgxpool.Pool { return nil } + diff --git a/backend/handlers/notifications/notifications_test.go b/backend/handlers/notifications/notifications_test.go index a587bd3..57177fe 100644 --- a/backend/handlers/notifications/notifications_test.go +++ b/backend/handlers/notifications/notifications_test.go @@ -24,27 +24,25 @@ import ( "testing" "time" - "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" ) // makeAdminRequest creates a request with admin context -func makeAdminRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { - return makeRequestWithContext(handler, method, path, body, "admin001", "admin") +func makeAdminRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { + return makeRequestWithContext(handler, method, path, body, "admin001", "admin", ctx) } // makeUserRequest creates a request with regular user context -func makeUserRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { - return makeRequestWithContext(handler, method, path, body, "user001", "verified_email") +func makeUserRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { + return makeRequestWithContext(handler, method, path, body, "user001", "verified_email", ctx) } // makeRequestWithContext creates a request with specific user context -func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder { +func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -59,7 +57,7 @@ func makeRequestWithContext(handler http.Handler, method, path string, body inte if id, paramName := extractIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, userID) ctx = context.WithValue(ctx, mw.UserRoleKey, role) req = req.WithContext(ctx) @@ -91,11 +89,12 @@ func extractIDFromPath(path string) (string, string) { // TestNotifications_List tests that an admin can list all unacknowledged notifications func TestNotifications_List(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user for notification reference var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -106,7 +105,7 @@ func TestNotifications_List(t *testing.T) { // Create a notification var notificationID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO admin_notifications (reason, user_id) VALUES ('pending_booking', $1) RETURNING id @@ -116,7 +115,7 @@ func TestNotifications_List(t *testing.T) { } handler := http.HandlerFunc(GetNotifications) - w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -143,10 +142,11 @@ func TestNotifications_List(t *testing.T) { // TestNotifications_ListEmpty tests that an empty list is returned when no notifications exist func TestNotifications_ListEmpty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetNotifications) - w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -169,11 +169,12 @@ func TestNotifications_ListEmpty(t *testing.T) { // TestNotifications_ListFilterByReason tests that notifications can be filtered by reason func TestNotifications_ListFilterByReason(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -185,7 +186,7 @@ func TestNotifications_ListFilterByReason(t *testing.T) { // Create notifications with different reasons reasons := []string{"pending_booking", "cancelled_booking", "edit_requested"} for _, reason := range reasons { - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, user_id) VALUES ($1, $2) `, reason, userID) @@ -197,7 +198,7 @@ func TestNotifications_ListFilterByReason(t *testing.T) { handler := http.HandlerFunc(GetNotifications) // Filter by pending_booking - w := makeAdminRequest(handler, "GET", "/api/admin/notifications?reason=pending_booking", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/notifications?reason=pending_booking", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -219,11 +220,12 @@ func TestNotifications_ListFilterByReason(t *testing.T) { // TestNotifications_ListPagination tests that pagination works correctly func TestNotifications_ListPagination(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -234,7 +236,7 @@ func TestNotifications_ListPagination(t *testing.T) { // Create 25 notifications for i := 0; i < 25; i++ { - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, user_id) VALUES ('pending_booking', $1) `, userID) @@ -246,7 +248,7 @@ func TestNotifications_ListPagination(t *testing.T) { handler := http.HandlerFunc(GetNotifications) // Get first page (default 20 items) - w := makeAdminRequest(handler, "GET", "/api/admin/notifications?page=1&per_page=10", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/notifications?page=1&per_page=10", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -268,11 +270,12 @@ func TestNotifications_ListPagination(t *testing.T) { // TestNotifications_ListExcludesAcknowledged tests that acknowledged notifications are not returned func TestNotifications_ListExcludesAcknowledged(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -282,7 +285,7 @@ func TestNotifications_ListExcludesAcknowledged(t *testing.T) { } // Create acknowledged notification - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ('pending_booking', $1, NOW()) `, userID) @@ -292,7 +295,7 @@ func TestNotifications_ListExcludesAcknowledged(t *testing.T) { // Create unacknowledged notification var unackID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO admin_notifications (reason, user_id) VALUES ('cancelled_booking', $1) RETURNING id @@ -302,7 +305,7 @@ func TestNotifications_ListExcludesAcknowledged(t *testing.T) { } handler := http.HandlerFunc(GetNotifications) - w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -329,11 +332,12 @@ func TestNotifications_ListExcludesAcknowledged(t *testing.T) { // TestNotifications_Acknowledge tests that an admin can acknowledge a notification func TestNotifications_Acknowledge(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -344,7 +348,7 @@ func TestNotifications_Acknowledge(t *testing.T) { // Create notification var notificationID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO admin_notifications (reason, user_id) VALUES ('pending_booking', $1) RETURNING id @@ -354,7 +358,7 @@ func TestNotifications_Acknowledge(t *testing.T) { } handler := http.HandlerFunc(AcknowledgeNotification) - w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notificationID), nil) + w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notificationID), nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -362,7 +366,7 @@ func TestNotifications_Acknowledge(t *testing.T) { // Verify the notification is acknowledged var acknowledgedAt *time.Time - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT acknowledged_at FROM admin_notifications WHERE id = $1 `, notificationID).Scan(&acknowledgedAt) if err != nil { @@ -376,10 +380,11 @@ func TestNotifications_Acknowledge(t *testing.T) { // TestNotifications_AcknowledgeNotFound tests that acknowledging a non-existent notification returns 404 func TestNotifications_AcknowledgeNotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(AcknowledgeNotification) - w := makeAdminRequest(handler, "POST", "/api/admin/notifications/ffffffffffff/acknowledge", nil) + w := makeAdminRequest(handler, "POST", "/api/admin/notifications/ffffffffffff/acknowledge", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for non-existent notification, got %d", w.Code) @@ -388,11 +393,12 @@ func TestNotifications_AcknowledgeNotFound(t *testing.T) { // TestNotifications_AcknowledgeAlreadyAcknowledged tests that acknowledging an already-acknowledged notification returns 404 func TestNotifications_AcknowledgeAlreadyAcknowledged(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -403,7 +409,7 @@ func TestNotifications_AcknowledgeAlreadyAcknowledged(t *testing.T) { // Create already-acknowledged notification var notificationID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ('pending_booking', $1, NOW()) RETURNING id @@ -413,7 +419,7 @@ func TestNotifications_AcknowledgeAlreadyAcknowledged(t *testing.T) { } handler := http.HandlerFunc(AcknowledgeNotification) - w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notificationID), nil) + w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notificationID), nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for already acknowledged notification, got %d", w.Code) @@ -422,7 +428,8 @@ func TestNotifications_AcknowledgeAlreadyAcknowledged(t *testing.T) { // TestNotifications_AcknowledgeInvalidID tests that invalid notification IDs are handled func TestNotifications_AcknowledgeInvalidID(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + _, _ = testutils.SetupTestTx(t) handler := http.HandlerFunc(AcknowledgeNotification) @@ -461,7 +468,8 @@ func TestNotifications_AcknowledgeInvalidID(t *testing.T) { // TestNotifications_AcknowledgeMissingID tests that missing ID returns 400 func TestNotifications_AcknowledgeMissingID(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + _, _ = testutils.SetupTestTx(t) // Create a custom request with no ID in path req := httptest.NewRequest("POST", "/api/admin/notifications//acknowledge", nil) @@ -489,11 +497,12 @@ func TestNotifications_AcknowledgeMissingID(t *testing.T) { // TestNotifications_WithBookingReference tests that notifications include booking_id when applicable func TestNotifications_WithBookingReference(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create test user var userID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id @@ -504,7 +513,7 @@ func TestNotifications_WithBookingReference(t *testing.T) { // Create a service var serviceID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Test service', 25.00, 30, true) RETURNING id @@ -515,7 +524,7 @@ func TestNotifications_WithBookingReference(t *testing.T) { // Create a booking var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW() + INTERVAL '1 day', 'pending') RETURNING id @@ -526,7 +535,7 @@ func TestNotifications_WithBookingReference(t *testing.T) { // Create notification with booking reference var notificationID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2) RETURNING id @@ -536,7 +545,7 @@ func TestNotifications_WithBookingReference(t *testing.T) { } handler := http.HandlerFunc(GetNotifications) - w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil) + w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) @@ -558,37 +567,31 @@ func TestNotifications_WithBookingReference(t *testing.T) { } } -// Ensure test compilation - import pgxpool to avoid unused import -var _ = func() *pgxpool.Pool { return nil } - // ============================================================================= // AcknowledgePendingBookingNotification Tests // ============================================================================= func TestAcknowledgePendingBookingNotification_Success(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Now().Add(24*time.Hour)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) // Create a pending notification for this booking - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (booking_id, reason, acknowledged_at) VALUES ($1, 'pending_booking', NULL) `, bookingID) @@ -596,24 +599,14 @@ func TestAcknowledgePendingBookingNotification_Success(t *testing.T) { t.Fatalf("failed to create notification: %v", err) } - tx, err := db.DB.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - err = AcknowledgePendingBookingNotification(tx, ctx, bookingID) if err != nil { - tx.Rollback(ctx) t.Fatalf("AcknowledgePendingBookingNotification failed: %v", err) } - if err := tx.Commit(ctx); err != nil { - t.Fatalf("failed to commit tx: %v", err) - } - // Verify notification was acknowledged var acknowledgedAt *time.Time - err = db.DB.QueryRow(ctx, + err = tx.QueryRow(ctx, "SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&acknowledgedAt) if err != nil { @@ -625,29 +618,26 @@ func TestAcknowledgePendingBookingNotification_Success(t *testing.T) { } func TestAcknowledgePendingBookingNotification_Idempotent(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Now().Add(24*time.Hour)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) // Notification already acknowledged - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (booking_id, reason, acknowledged_at) VALUES ($1, 'pending_booking', NOW()) `, bookingID) @@ -655,77 +645,57 @@ func TestAcknowledgePendingBookingNotification_Idempotent(t *testing.T) { t.Fatalf("failed to create acknowledged notification: %v", err) } - tx, err := db.DB.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - // Calling again on already acknowledged should not error err = AcknowledgePendingBookingNotification(tx, ctx, bookingID) if err != nil { - tx.Rollback(ctx) t.Fatalf("AcknowledgePendingBookingNotification should not error when already acknowledged: %v", err) } - tx.Commit(ctx) } func TestAcknowledgePendingBookingNotification_NoNotification(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Now().Add(24*time.Hour)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) - - tx, err := db.DB.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } // No notification exists - should not error err = AcknowledgePendingBookingNotification(tx, ctx, bookingID) if err != nil { - tx.Rollback(ctx) t.Fatalf("AcknowledgePendingBookingNotification should not error when no notification exists: %v", err) } - tx.Commit(ctx) } func TestAcknowledgePendingBookingNotification_NonTxCaller(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Now().Add(24*time.Hour)) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Now().Add(24*time.Hour)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) // Call with a plain struct (not a tx) - should log warning and not error err = AcknowledgePendingBookingNotification("not-a-tx", ctx, bookingID) diff --git a/backend/handlers/notifications/testmain_test.go b/backend/handlers/notifications/testmain_test.go index e391035..1d05e7b 100644 --- a/backend/handlers/notifications/testmain_test.go +++ b/backend/handlers/notifications/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_notifications") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_notifications") os.Exit(code) diff --git a/backend/handlers/payments/discount_preview_test.go b/backend/handlers/payments/discount_preview_test.go index 74b0b59..b23c3cd 100644 --- a/backend/handlers/payments/discount_preview_test.go +++ b/backend/handlers/payments/discount_preview_test.go @@ -25,24 +25,24 @@ import ( // ============================================================================= // setupDiscountPreviewTest creates a user, service, and booking for discount preview tests. -func setupDiscountPreviewTest(t *testing.T) (string, string, string) { +func setupDiscountPreviewTest(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) { t.Helper() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(context.Background(), `UPDATE bookings SET status = 'confirmed' WHERE id = $1`, bookingID) + _, err = q.Exec(ctx, `UPDATE bookings SET status = 'confirmed' WHERE id = $1`, bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -53,14 +53,17 @@ func setupDiscountPreviewTest(t *testing.T) (string, string, string) { // serveDiscountPreviewHandler wires chi context with {id} param and serves the handler. // Pass userID explicitly so the handler sees the correct user in context. -func serveDiscountPreviewHandler(bookingID, userID, token string) *httptest.ResponseRecorder { +func serveDiscountPreviewHandler(bookingID, userID, token string, baseCtx ...context.Context) *httptest.ResponseRecorder { handler := http.HandlerFunc(GetDiscountPreviewHandler) req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil) req.Header.Set("Authorization", "Bearer "+token) - ctx := req.Context() - ctx = context.WithValue(ctx, mw.UserIDKey, userID) + base := context.Background() + if len(baseCtx) > 0 { + base = baseCtx[0] + } + ctx := context.WithValue(base, mw.UserIDKey, userID) ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") req = req.WithContext(ctx) @@ -75,12 +78,12 @@ func serveDiscountPreviewHandler(bookingID, userID, token string) *httptest.Resp // TestDiscountPreview_NoCampaigns returns eligible=false when no active campaigns exist. func TestDiscountPreview_NoCampaigns(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -108,14 +111,14 @@ func TestDiscountPreview_NoCampaigns(t *testing.T) { // TestDiscountPreview_TimeBasedCampaign returns the correct discount for an active time-based campaign. func TestDiscountPreview_TimeBasedCampaign(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := time.Now() var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ('Test Sale', 'time_based', 15, 'active', $1, $2, 0) RETURNING id @@ -124,7 +127,7 @@ func TestDiscountPreview_TimeBasedCampaign(t *testing.T) { t.Fatalf("failed to create campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -165,14 +168,14 @@ func TestDiscountPreview_TimeBasedCampaign(t *testing.T) { // Verify preview did NOT create any actual booking_discounts or payment records var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount) if discountCount != 0 { t.Errorf("preview should not create booking_discounts, found %d", discountCount) } var paymentCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount) if paymentCount != 0 { t.Errorf("preview should not create discount payment records, found %d", paymentCount) @@ -180,7 +183,7 @@ func TestDiscountPreview_TimeBasedCampaign(t *testing.T) { // Verify campaign redemption count was NOT incremented var timesRedeemed int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed) if timesRedeemed != 0 { t.Errorf("preview should not increment times_redeemed, got %d", timesRedeemed) @@ -189,13 +192,13 @@ func TestDiscountPreview_TimeBasedCampaign(t *testing.T) { // TestDiscountPreview_CampaignExhausted returns not eligible when a campaign has reached max_redemptions. func TestDiscountPreview_CampaignExhausted(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := time.Now() - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) VALUES ('Exhausted Campaign', 'time_based', 20, 'active', $1, $2, 5, 5) `, now.Add(-24*time.Hour), now.Add(24*time.Hour)) @@ -203,7 +206,7 @@ func TestDiscountPreview_CampaignExhausted(t *testing.T) { t.Fatalf("failed to create campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -221,12 +224,12 @@ func TestDiscountPreview_CampaignExhausted(t *testing.T) { // TestDiscountPreview_CampaignExpired returns not eligible for a past campaign. func TestDiscountPreview_CampaignExpired(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Expired Campaign', 'time_based', 10, 'active', $1, $2) `, time.Now().Add(-72*time.Hour), time.Now().Add(-24*time.Hour)) @@ -234,7 +237,7 @@ func TestDiscountPreview_CampaignExpired(t *testing.T) { t.Fatalf("failed to create campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) var resp DiscountPreviewResponse json.NewDecoder(w.Body).Decode(&resp) @@ -246,12 +249,12 @@ func TestDiscountPreview_CampaignExpired(t *testing.T) { // TestDiscountPreview_CampaignNotStarted returns not eligible for a future campaign. func TestDiscountPreview_CampaignNotStarted(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Future Campaign', 'time_based', 10, 'active', $1, $2) `, time.Now().Add(24*time.Hour), time.Now().Add(72*time.Hour)) @@ -259,7 +262,7 @@ func TestDiscountPreview_CampaignNotStarted(t *testing.T) { t.Fatalf("failed to create campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) var resp DiscountPreviewResponse json.NewDecoder(w.Body).Decode(&resp) @@ -271,28 +274,28 @@ func TestDiscountPreview_CampaignNotStarted(t *testing.T) { // TestDiscountPreview_MilestoneCampaign returns the discount for a milestone campaign the user has reached. func TestDiscountPreview_MilestoneCampaign(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) // Create a completed booking so the user has a booking count of at least 1 (plus the current test booking) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - pastBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)) + pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create completed booking: %v", err) } // The fixture creates bookings with status 'pending' — mark as completed for milestone counting - _, err = db.DB.Exec(context.Background(), `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID) + _, err = tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID) if err != nil { t.Fatalf("failed to mark booking as completed: %v", err) } // Create milestone campaign for 1st booking - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value) VALUES ('First Booking Bonus', 'milestone', 25, 'active', $1, $2, 'per_user_booking_count', 1) `, time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour)) @@ -300,7 +303,7 @@ func TestDiscountPreview_MilestoneCampaign(t *testing.T) { t.Fatalf("failed to create milestone campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -329,14 +332,14 @@ func TestDiscountPreview_MilestoneCampaign(t *testing.T) { // TestDiscountPreview_AlreadyApplied excludes discounts already applied to this booking. func TestDiscountPreview_AlreadyApplied(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := time.Now() var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ('Already Applied', 'time_based', 10, 'active', $1, $2, 0) RETURNING id @@ -347,7 +350,7 @@ func TestDiscountPreview_AlreadyApplied(t *testing.T) { // Simulate the discount already being applied by inserting a booking_discounts record var bookingTotal float64 - db.DB.QueryRow(context.Background(), ` + tx.QueryRow(ctx, ` SELECT COALESCE(SUM(price_val), 0) FROM ( SELECT COALESCE(bs.override_price, s.price) AS price_val FROM booking_services bs JOIN services s ON bs.service_id = s.id @@ -356,7 +359,7 @@ func TestDiscountPreview_AlreadyApplied(t *testing.T) { `, bookingID).Scan(&bookingTotal) discountAmount := bookingTotal * 10 / 100 - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 10, $4, $5) `, bookingID, userID, campaignID, bookingTotal, discountAmount) @@ -364,7 +367,7 @@ func TestDiscountPreview_AlreadyApplied(t *testing.T) { t.Fatalf("failed to apply discount: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) var resp DiscountPreviewResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { @@ -399,15 +402,15 @@ func TestDiscountPreview_InvalidBookingID(t *testing.T) { // TestDiscountPreview_MultipleCampaigns returns all eligible discounts. func TestDiscountPreview_MultipleCampaigns(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := time.Now() // Create two active campaigns - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Summer Sale', 'time_based', 15, 'active', $1, $2) `, now.Add(-48*time.Hour), now.Add(48*time.Hour)) @@ -415,7 +418,7 @@ func TestDiscountPreview_MultipleCampaigns(t *testing.T) { t.Fatalf("failed to create first campaign: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) VALUES ('Flash Sale', 'time_based', 20, 'active', $1, $2, 100, 0) `, now.Add(-24*time.Hour), now.Add(24*time.Hour)) @@ -423,7 +426,7 @@ func TestDiscountPreview_MultipleCampaigns(t *testing.T) { t.Fatalf("failed to create second campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -444,18 +447,18 @@ func TestDiscountPreview_MultipleCampaigns(t *testing.T) { // TestDiscountPreview_AnniversaryCampaign returns the correct discount for an anniversary milestone. func TestDiscountPreview_AnniversaryCampaign(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, token := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) // Set first booking to be years ago so anniversary qualifies - db.DB.Exec(context.Background(), ` + tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') `, userID, time.Date(2020, 1, 15, 10, 0, 0, 0, time.UTC)) // Create anniversary milestone campaign - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit) VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years') `, "One Year Anniversary") @@ -463,7 +466,7 @@ func TestDiscountPreview_AnniversaryCampaign(t *testing.T) { t.Fatalf("failed to create anniversary campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -494,14 +497,14 @@ func TestDiscountPreview_AnniversaryCampaign(t *testing.T) { // After the first payment, the apply function applies discounts. After a second payment, // no new discounts should be added. func TestDiscountPreview_PaymentLock(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupDiscountPreviewTest(t) - defer fixtures.DeleteUser(db.DB, userID) + userID, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx) now := time.Now() var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) RETURNING id @@ -511,7 +514,7 @@ func TestDiscountPreview_PaymentLock(t *testing.T) { } // First payment — discount should be applied - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 1000, 'completed', NOW(), NOW()) `, bookingID) @@ -519,17 +522,17 @@ func TestDiscountPreview_PaymentLock(t *testing.T) { t.Fatalf("failed to create first payment: %v", err) } - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 discount after first payment, got %d", discountCount) } // Second payment — NO new discounts should be added (lock active) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'online_square', 4000, 'completed', NOW(), NOW()) `, bookingID) @@ -537,9 +540,9 @@ func TestDiscountPreview_PaymentLock(t *testing.T) { t.Fatalf("failed to create second payment: %v", err) } - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected still 1 discount after second payment (lock active), got %d", discountCount) @@ -547,7 +550,7 @@ func TestDiscountPreview_PaymentLock(t *testing.T) { // Verify campaign was only redeemed once var timesRedeemed int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed) if timesRedeemed != 1 { t.Errorf("expected 1 redemption, got %d", timesRedeemed) @@ -556,17 +559,17 @@ func TestDiscountPreview_PaymentLock(t *testing.T) { // TestDiscountPreview_BookingNoServices returns not eligible for a booking with no services. func TestDiscountPreview_BookingNoServices(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - defer fixtures.DeleteUser(db.DB, userID) // Create a booking without any services var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW(), 'confirmed') RETURNING id `, userID).Scan(&bookingID) if err != nil { @@ -574,7 +577,7 @@ func TestDiscountPreview_BookingNoServices(t *testing.T) { } now := time.Now() - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Test Sale', 'time_based', 10, 'active', $1, $2) `, now.Add(-24*time.Hour), now.Add(24*time.Hour)) @@ -583,7 +586,7 @@ func TestDiscountPreview_BookingNoServices(t *testing.T) { } token := jwt.GenerateUserToken(userID) - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) var resp DiscountPreviewResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { @@ -601,14 +604,15 @@ func TestDiscountPreview_BookingNoServices(t *testing.T) { // TestDiscountPreview_ReturnsReadOnly confirms the preview endpoint never modifies state // even when eligible campaigns exist. func TestDiscountPreview_ReturnsReadOnly(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, token := setupDiscountPreviewTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := time.Now() // Create an active campaign var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) RETURNING id @@ -617,7 +621,7 @@ func TestDiscountPreview_ReturnsReadOnly(t *testing.T) { t.Fatalf("failed to create campaign: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } @@ -641,41 +645,28 @@ func TestDiscountPreview_ReturnsReadOnly(t *testing.T) { } func TestDiscountPreview_ReferralDiscount(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, token := setupDiscountPreviewTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) - // Insert a referral discount for the user - _, err := db.DB.Exec(context.Background(), ` - INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) - VALUES ($1, (SELECT id FROM user_referrals LIMIT 1), 10.00, false) - `, userID) - // If no user_referrals exist, create a minimal one + var refID string + err := tx.QueryRow(ctx, ` + INSERT INTO user_referrals (referrer_id, referred_id) + VALUES ($1, $2) + RETURNING id + `, userID, userID).Scan(&refID) if err != nil { - // Create a minimal referral so the FK works - var refUserID string - db.DB.QueryRow(context.Background(), `SELECT id FROM users WHERE id != $1 LIMIT 1`, userID).Scan(&refUserID) - if refUserID == "" { - refUserID = userID - } - var refID string - err = db.DB.QueryRow(context.Background(), ` - INSERT INTO user_referrals (referrer_id, referred_id) - VALUES ($1, $2) - RETURNING id - `, refUserID, userID).Scan(&refID) - if err != nil { - t.Fatalf("failed to create referral: %v", err) - } - _, err = db.DB.Exec(context.Background(), ` - INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) - VALUES ($1, $2, 10.00, false) - `, userID, refID) - if err != nil { - t.Fatalf("failed to insert referral discount: %v", err) - } + t.Fatalf("failed to create referral: %v", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) + VALUES ($1, $2, 10.00, false) + `, userID, refID) + if err != nil { + t.Fatalf("failed to insert referral discount: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } @@ -708,12 +699,13 @@ func TestDiscountPreview_ReferralDiscount(t *testing.T) { } func TestDiscountPreview_ReferralDiscount_AlreadyUsed(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, token := setupDiscountPreviewTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) // Insert a used referral discount var refID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id @@ -721,7 +713,7 @@ func TestDiscountPreview_ReferralDiscount_AlreadyUsed(t *testing.T) { if err != nil { t.Fatalf("failed to create referral: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, true) `, userID, refID) @@ -729,7 +721,7 @@ func TestDiscountPreview_ReferralDiscount_AlreadyUsed(t *testing.T) { t.Fatalf("failed to insert used referral discount: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } @@ -747,11 +739,12 @@ func TestDiscountPreview_ReferralDiscount_AlreadyUsed(t *testing.T) { } func TestDiscountPreview_ReferralDiscount_AlreadyApplied(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, token := setupDiscountPreviewTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) var refID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id @@ -760,7 +753,7 @@ func TestDiscountPreview_ReferralDiscount_AlreadyApplied(t *testing.T) { t.Fatalf("failed to create referral: %v", err) } var rdID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, false) RETURNING id @@ -770,7 +763,7 @@ func TestDiscountPreview_ReferralDiscount_AlreadyApplied(t *testing.T) { } // Apply it to the booking already - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'referral', $3, 10.00, 100.00, 10.00) `, bookingID, userID, rdID) @@ -778,7 +771,7 @@ func TestDiscountPreview_ReferralDiscount_AlreadyApplied(t *testing.T) { t.Fatalf("failed to insert booking discount: %v", err) } - w := serveDiscountPreviewHandler(bookingID, userID, token) + w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } diff --git a/backend/handlers/payments/giftcards_test.go b/backend/handlers/payments/giftcards_test.go index 0be44bd..b34aecd 100644 --- a/backend/handlers/payments/giftcards_test.go +++ b/backend/handlers/payments/giftcards_test.go @@ -15,27 +15,24 @@ import ( "crussell/db" "crussell/mw" + "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" - "crussell/testutils/testdb" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" ) -func resetGiftCardsTestData(t *testing.T) { - t.Helper() - testdb.TruncateTables(t, db.DB) -} func TestAdminCreateGiftCard(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") @@ -43,6 +40,7 @@ func TestAdminCreateGiftCard(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() @@ -66,20 +64,20 @@ func TestAdminCreateGiftCard(t *testing.T) { } func TestAdminTopUpGiftCard(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Create gift card var cardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) VALUES (50.00, 50.00, $1) RETURNING id @@ -95,6 +93,7 @@ func TestAdminTopUpGiftCard(t *testing.T) { req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() @@ -118,20 +117,20 @@ func TestAdminTopUpGiftCard(t *testing.T) { } func TestAdminTransferGiftCard(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Create card 1 with £100 var card1ID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) VALUES (100.00, 100.00, $1) RETURNING id @@ -142,7 +141,7 @@ func TestAdminTransferGiftCard(t *testing.T) { // Create card 2 with £20 var card2ID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) VALUES (20.00, 20.00, $1) RETURNING id @@ -159,6 +158,7 @@ func TestAdminTransferGiftCard(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards/"+card1ID+"/transfer", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() @@ -173,7 +173,7 @@ func TestAdminTransferGiftCard(t *testing.T) { // Verify card 1 has £70 remaining var card1Remaining float64 - err = db.DB.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card1ID).Scan(&card1Remaining) + err = tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card1ID).Scan(&card1Remaining) if err != nil { t.Fatalf("failed to query card 1: %v", err) } @@ -183,7 +183,7 @@ func TestAdminTransferGiftCard(t *testing.T) { // Verify card 2 has £50 remaining and £50 total funds added var card2Remaining, card2Added float64 - err = db.DB.QueryRow(ctx, "SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1", card2ID).Scan(&card2Remaining, &card2Added) + err = tx.QueryRow(ctx, "SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1", card2ID).Scan(&card2Remaining, &card2Added) if err != nil { t.Fatalf("failed to query card 2: %v", err) } @@ -193,10 +193,10 @@ func TestAdminTransferGiftCard(t *testing.T) { } func TestUserRedeemGiftCard(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } @@ -205,7 +205,7 @@ func TestUserRedeemGiftCard(t *testing.T) { // Create gift card with £100 var cardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining) VALUES (100.00, 100.00) RETURNING id @@ -218,6 +218,7 @@ func TestUserRedeemGiftCard(t *testing.T) { req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() @@ -233,7 +234,7 @@ func TestUserRedeemGiftCard(t *testing.T) { // Verify card marked as spent (remaining = 0) and claimed var amountRemaining float64 var redeemedBy string - err = db.DB.QueryRow(ctx, "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&amountRemaining, &redeemedBy) + err = tx.QueryRow(ctx, "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&amountRemaining, &redeemedBy) if err != nil { t.Fatalf("failed to query gift card: %v", err) } @@ -246,7 +247,7 @@ func TestUserRedeemGiftCard(t *testing.T) { // Verify balance added to user var balance float64 - err = db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { t.Fatalf("failed to query user balance: %v", err) } @@ -256,10 +257,10 @@ func TestUserRedeemGiftCard(t *testing.T) { } func TestBuyGiftCard_Self(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } @@ -276,6 +277,7 @@ func TestBuyGiftCard_Self(t *testing.T) { req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() @@ -290,7 +292,7 @@ func TestBuyGiftCard_Self(t *testing.T) { // Verify user balance is now £20.00 var balance float64 - err = db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { t.Fatalf("failed to query user balance: %v", err) } @@ -300,7 +302,7 @@ func TestBuyGiftCard_Self(t *testing.T) { // Verify purchase payment record was created var payCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount) if err != nil { t.Fatalf("failed to query payments: %v", err) } @@ -310,10 +312,10 @@ func TestBuyGiftCard_Self(t *testing.T) { } func TestBuyGiftCard_Friend(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } @@ -329,6 +331,7 @@ func TestBuyGiftCard_Friend(t *testing.T) { req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() @@ -348,7 +351,7 @@ func TestBuyGiftCard_Friend(t *testing.T) { // Verify card was created with £50.00 remaining (stays active, unredeemed) var remaining, added float64 var redeemedBy sql.NullString - err = db.DB.QueryRow(ctx, "SELECT amount_remaining, total_funds_added, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&remaining, &added, &redeemedBy) + err = tx.QueryRow(ctx, "SELECT amount_remaining, total_funds_added, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&remaining, &added, &redeemedBy) if err != nil { t.Fatalf("failed to query card: %v", err) } @@ -361,33 +364,33 @@ func TestBuyGiftCard_Friend(t *testing.T) { } func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, adminID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Update booking to in_progress so it is payable - _, _ = db.DB.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) // Create a physical gift card code with £100 balance var cardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining) VALUES (100.00, 100.00) RETURNING id @@ -405,6 +408,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() @@ -419,7 +423,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { // Verify cash payment recorded var cashPayCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'", bookingID).Scan(&cashPayCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'", bookingID).Scan(&cashPayCount) if err != nil { t.Fatalf("failed to query payments: %v", err) } @@ -437,6 +441,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2)) req2.Header.Set("Authorization", "Bearer "+token) req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx))) w2 := httptest.NewRecorder() r2 := chi.NewRouter() @@ -450,7 +455,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { // Verify gift card balance deducted from card directly var remaining float64 - err = db.DB.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining) + err = tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining) if err != nil { t.Fatalf("failed to query card: %v", err) } @@ -460,7 +465,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { // Verify gift card payment record created var gcPayCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'", bookingID).Scan(&gcPayCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'", bookingID).Scan(&gcPayCount) if err != nil { t.Fatalf("failed to query payments: %v", err) } @@ -470,7 +475,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { // 3. Redeem remaining £60 of gift card to user account // Setup user account with some balance first - _, _ = db.DB.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 60.00)", adminID) + _, _ = tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 60.00)", adminID) // Now pay £25 using user account balance reqBody3, _ := json.Marshal(map[string]interface{}{ @@ -481,6 +486,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3)) req3.Header.Set("Authorization", "Bearer "+token) req3.Header.Set("Content-Type", "application/json") + req3 = req3.WithContext(db.ContextWithTx(req3.Context(), tx.(pgx.Tx))) w3 := httptest.NewRecorder() r3 := chi.NewRouter() @@ -494,7 +500,7 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { // Verify user account balance was deducted var userBalance float64 - err = db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", adminID).Scan(&userBalance) + err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", adminID).Scan(&userBalance) if err != nil { t.Fatalf("failed to query user balance: %v", err) } @@ -506,20 +512,20 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { // --- New Tests for branch features --- func TestGetExpiredBalances(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Seed expired balances for i := 0; i < 2; i++ { - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) VALUES ($1, $2, NOW() - interval '30 days') `, adminID, float64(25.00*(i+1))) @@ -530,6 +536,7 @@ func TestGetExpiredBalances(t *testing.T) { req := httptest.NewRequest("GET", "/api/admin/gift-cards/expired-balances", nil) req.Header.Set("Authorization", "Bearer "+token) + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -558,20 +565,20 @@ func TestGetExpiredBalances(t *testing.T) { } func TestClaimExpiredBalance_HappyPath(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Seed an expired balance with known ID var balanceID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) VALUES ($1, 50.00, NOW() - interval '30 days') RETURNING id @@ -588,6 +595,7 @@ func TestClaimExpiredBalance_HappyPath(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -609,7 +617,7 @@ func TestClaimExpiredBalance_HappyPath(t *testing.T) { // Verify claimed_at is set in DB var claimedAt sql.NullTime - err = db.DB.QueryRow(ctx, "SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1", balanceID).Scan(&claimedAt) + err = tx.QueryRow(ctx, "SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1", balanceID).Scan(&claimedAt) if err != nil { t.Fatalf("failed to query expired balance: %v", err) } @@ -619,20 +627,20 @@ func TestClaimExpiredBalance_HappyPath(t *testing.T) { } func TestClaimExpiredBalance_AlreadyClaimed(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Seed an expired balance that is already claimed var balanceID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at, claimed_at, claimed_by_admin) VALUES ($1, 50.00, NOW() - interval '30 days', NOW(), $2) RETURNING id @@ -647,6 +655,7 @@ func TestClaimExpiredBalance_AlreadyClaimed(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -660,14 +669,14 @@ func TestClaimExpiredBalance_AlreadyClaimed(t *testing.T) { } func TestClaimExpiredBalance_NotFound(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") @@ -677,6 +686,7 @@ func TestClaimExpiredBalance_NotFound(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -690,20 +700,20 @@ func TestClaimExpiredBalance_NotFound(t *testing.T) { } func TestGetGiftCards_Pagination(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Create 15 gift cards for i := 0; i < 15; i++ { - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) VALUES (10.00, 10.00, $1) `, adminID) @@ -715,6 +725,7 @@ func TestGetGiftCards_Pagination(t *testing.T) { // Request page 1 with per_page=5 req := httptest.NewRequest("GET", "/api/admin/gift-cards?page=1&per_page=5", nil) req.Header.Set("Authorization", "Bearer "+token) + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -750,6 +761,7 @@ func TestGetGiftCards_Pagination(t *testing.T) { // Request page 3 to verify last page req3 := httptest.NewRequest("GET", "/api/admin/gift-cards?page=3&per_page=5", nil) req3.Header.Set("Authorization", "Bearer "+token) + req3 = req3.WithContext(db.ContextWithTx(req3.Context(), tx.(pgx.Tx))) w3 := httptest.NewRecorder() r3 := chi.NewRouter() @@ -775,14 +787,14 @@ func TestGetGiftCards_Pagination(t *testing.T) { } func TestGetGiftCards_Search(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") @@ -790,7 +802,7 @@ func TestGetGiftCards_Search(t *testing.T) { searchableID := "aaaaaabbbbcc" nonSearchableID := "ddddeeeeffff" - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by) VALUES ($1, 10.00, 10.00, $2) `, searchableID, adminID) @@ -798,7 +810,7 @@ func TestGetGiftCards_Search(t *testing.T) { t.Fatalf("failed to create searchable card: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by) VALUES ($1, 20.00, 20.00, $2) `, nonSearchableID, adminID) @@ -809,6 +821,7 @@ func TestGetGiftCards_Search(t *testing.T) { // Search by partial ID match req := httptest.NewRequest("GET", "/api/admin/gift-cards?q=aaaa", nil) req.Header.Set("Authorization", "Bearer "+token) + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -837,14 +850,14 @@ func TestGetGiftCards_Search(t *testing.T) { } func TestCreateGiftCard_NegativeAmount(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") @@ -852,6 +865,7 @@ func TestCreateGiftCard_NegativeAmount(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -865,14 +879,14 @@ func TestCreateGiftCard_NegativeAmount(t *testing.T) { } func TestCreateGiftCard_ZeroAmountNoInventory(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") @@ -880,6 +894,7 @@ func TestCreateGiftCard_ZeroAmountNoInventory(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -893,14 +908,14 @@ func TestCreateGiftCard_ZeroAmountNoInventory(t *testing.T) { } func TestCreateGiftCard_ZeroAmountInventory(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") @@ -908,6 +923,7 @@ func TestCreateGiftCard_ZeroAmountInventory(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -933,20 +949,20 @@ func TestCreateGiftCard_ZeroAmountInventory(t *testing.T) { } func TestTopUpGiftCard_InvalidPaymentMethod(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Create gift card var cardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) VALUES (50.00, 50.00, $1) RETURNING id @@ -962,6 +978,7 @@ func TestTopUpGiftCard_InvalidPaymentMethod(t *testing.T) { req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -975,20 +992,20 @@ func TestTopUpGiftCard_InvalidPaymentMethod(t *testing.T) { } func TestTopUpGiftCard_RedeemedCard(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Create a card that's already redeemed var cardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by) VALUES (50.00, 0, $1, NOW(), $1) RETURNING id @@ -1004,6 +1021,7 @@ func TestTopUpGiftCard_RedeemedCard(t *testing.T) { req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -1017,10 +1035,10 @@ func TestTopUpGiftCard_RedeemedCard(t *testing.T) { } func TestBuyGiftCard_Idempotency(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } @@ -1041,6 +1059,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { req1 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body1)) req1.Header.Set("Authorization", "Bearer "+token) req1.Header.Set("Content-Type", "application/json") + req1 = req1.WithContext(db.ContextWithTx(req1.Context(), tx.(pgx.Tx))) w1 := httptest.NewRecorder() r1 := chi.NewRouter() @@ -1054,7 +1073,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { // Verify exactly one payment record was created for this key var payCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount) if err != nil { t.Fatalf("failed to query payments: %v", err) } @@ -1064,7 +1083,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { // Verify exactly one user balance record var balCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount) if err != nil { t.Fatalf("failed to query user balances: %v", err) } @@ -1074,7 +1093,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { // Verify exactly one gift card was created for self-purchase (amount_remaining=0, redeemed) var cardCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount) if err != nil { t.Fatalf("failed to query gift cards: %v", err) } @@ -1087,6 +1106,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { req2 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body2)) req2.Header.Set("Authorization", "Bearer "+token) req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx))) w2 := httptest.NewRecorder() r2 := chi.NewRouter() @@ -1096,7 +1116,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { // Verify counts remain unchanged (idempotent) var payCount2 int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount2) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount2) if err != nil { t.Fatalf("failed to query payments: %v", err) } @@ -1105,7 +1125,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { } var balCount2 int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount2) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount2) if err != nil { t.Fatalf("failed to query user balances: %v", err) } @@ -1114,7 +1134,7 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { } var cardCount2 int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount2) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount2) if err != nil { t.Fatalf("failed to query gift cards: %v", err) } @@ -1126,14 +1146,14 @@ func TestBuyGiftCard_Idempotency(t *testing.T) { // TestAdminCreateGiftCard_ExpiryDateIsNull verifies that gift cards created via // CreateGiftCard no longer have expiry_date set (rolling 24-month expiry via last_used_at). func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") @@ -1141,6 +1161,7 @@ func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) { req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() @@ -1159,7 +1180,7 @@ func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) { // Verify expiry_date is NULL in the database var expiryDate *time.Time - err = db.DB.QueryRow(ctx, `SELECT expiry_date FROM gift_cards WHERE id = $1`, gc.ID).Scan(&expiryDate) + err = tx.QueryRow(ctx, `SELECT expiry_date FROM gift_cards WHERE id = $1`, gc.ID).Scan(&expiryDate) if err != nil { t.Fatalf("failed to query gift card expiry_date: %v", err) } @@ -1170,23 +1191,23 @@ func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) { // TestGetGiftCards_InventoryFilter verifies the ?type=customer|inventory query parameter. func TestGetGiftCards_InventoryFilter(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") // Insert one customer card and one inventory card - _, err = db.DB.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (10.00, 10.00, $1, FALSE)`, adminID) + _, err = tx.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (10.00, 10.00, $1, FALSE)`, adminID) if err != nil { t.Fatalf("failed to create customer card: %v", err) } - _, err = db.DB.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (20.00, 20.00, $1, TRUE)`, adminID) + _, err = tx.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (20.00, 20.00, $1, TRUE)`, adminID) if err != nil { t.Fatalf("failed to create inventory card: %v", err) } @@ -1194,6 +1215,7 @@ func TestGetGiftCards_InventoryFilter(t *testing.T) { // Test ?type=customer filter reqCustomer := httptest.NewRequest("GET", "/api/admin/gift-cards?type=customer", nil) reqCustomer.Header.Set("Authorization", "Bearer "+token) + reqCustomer = reqCustomer.WithContext(db.ContextWithTx(reqCustomer.Context(), tx.(pgx.Tx))) wCustomer := httptest.NewRecorder() r := chi.NewRouter() @@ -1233,6 +1255,7 @@ func TestGetGiftCards_InventoryFilter(t *testing.T) { // Test ?type=inventory filter reqInventory := httptest.NewRequest("GET", "/api/admin/gift-cards?type=inventory", nil) reqInventory.Header.Set("Authorization", "Bearer "+token) + reqInventory = reqInventory.WithContext(db.ContextWithTx(reqInventory.Context(), tx.(pgx.Tx))) wInventory := httptest.NewRecorder() rInventory := chi.NewRouter() @@ -1261,6 +1284,7 @@ func TestGetGiftCards_InventoryFilter(t *testing.T) { // Test no filter (should return both) reqAll := httptest.NewRequest("GET", "/api/admin/gift-cards", nil) reqAll.Header.Set("Authorization", "Bearer "+token) + reqAll = reqAll.WithContext(db.ContextWithTx(reqAll.Context(), tx.(pgx.Tx))) wAll := httptest.NewRecorder() rAll := chi.NewRouter() @@ -1284,22 +1308,22 @@ func TestGetGiftCards_InventoryFilter(t *testing.T) { // TestGetUserGiftCardBalanceAdmin_AuditLog verifies admin balance checks // are recorded in the admin_audit_log table. func TestGetUserGiftCardBalanceAdmin_AuditLog(t *testing.T) { - resetGiftCardsTestData(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } - _, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } // Give the user a balance - _, err = db.DB.Exec(ctx, `INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 42.50)`, userID) + _, err = tx.Exec(ctx, `INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 42.50)`, userID) if err != nil { t.Fatalf("failed to insert user balance: %v", err) } @@ -1309,6 +1333,7 @@ func TestGetUserGiftCardBalanceAdmin_AuditLog(t *testing.T) { // Set up request with chi route context for URL param extraction req := httptest.NewRequest("GET", "/"+userID+"/giftcard-balance", nil) req.Header.Set("Authorization", "Bearer "+token) + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", userID) @@ -1340,7 +1365,7 @@ func TestGetUserGiftCardBalanceAdmin_AuditLog(t *testing.T) { // Verify audit log entry was created var logCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_log WHERE admin_id = $1 AND target_user_id = $2 AND action_type = 'balance_check'`, adminID, userID).Scan(&logCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_log WHERE admin_id = $1 AND target_user_id = $2 AND action_type = 'balance_check'`, adminID, userID).Scan(&logCount) if err != nil { t.Fatalf("failed to query admin_audit_log: %v", err) } diff --git a/backend/handlers/payments/loyalty_test.go b/backend/handlers/payments/loyalty_test.go index efda8f9..0cb9bff 100644 --- a/backend/handlers/payments/loyalty_test.go +++ b/backend/handlers/payments/loyalty_test.go @@ -24,14 +24,14 @@ import ( // ApplyLoyaltyRedemption — POST /api/bookings/{id}/apply-redemption // ============================================================================= -func setupLoyaltyUser(t *testing.T, stamps int) (string, string, string) { +func setupLoyaltyUser(t *testing.T, ctx context.Context, q db.Querier, stamps int) (string, string, string) { t.Helper() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create user: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = q.Exec(ctx, "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID) if err != nil { t.Fatalf("failed to set loyalty_stamps: %v", err) @@ -39,7 +39,7 @@ func setupLoyaltyUser(t *testing.T, stamps int) (string, string, string) { // Create a pending loyalty_redemption if stamps >= 10 if stamps >= 10 { - _, err = db.DB.Exec(context.Background(), ` + _, err = q.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, redeemed_at, expires_at) VALUES ($1, 'pending', NOW(), NOW() + INTERVAL '6 months') `, userID) @@ -48,17 +48,17 @@ func setupLoyaltyUser(t *testing.T, stamps int) (string, string, string) { } } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = q.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set booking status: %v", err) @@ -68,7 +68,7 @@ func setupLoyaltyUser(t *testing.T, stamps int) (string, string, string) { return userID, bookingID, userToken } -func makeApplyRedemptionRequest(bookingID, token string) *httptest.ResponseRecorder { +func makeApplyRedemptionRequest(bookingID, token string, ctx context.Context) *httptest.ResponseRecorder { handler := http.HandlerFunc(ApplyLoyaltyRedemption) req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil) @@ -76,7 +76,7 @@ func makeApplyRedemptionRequest(bookingID, token string) *httptest.ResponseRecor rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) userID := extractUserFromTestJWT(token) if userID != nil { @@ -91,10 +91,11 @@ func makeApplyRedemptionRequest(bookingID, token string) *httptest.ResponseRecor } func TestApplyLoyaltyRedemption_Success(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupLoyaltyUser(t, 10) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10) - w := makeApplyRedemptionRequest(bookingID, userToken) + w := makeApplyRedemptionRequest(bookingID, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } @@ -114,7 +115,7 @@ func TestApplyLoyaltyRedemption_Success(t *testing.T) { // Verify booking_discounts was created var discountCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount) if err != nil { t.Fatalf("failed to query booking_discounts: %v", err) @@ -125,7 +126,7 @@ func TestApplyLoyaltyRedemption_Success(t *testing.T) { // Verify stamps were deducted (10 - 10 = 0) var stamps int - err = db.DB.QueryRow(context.Background(), "SELECT loyalty_stamps FROM users WHERE id = (SELECT user_id FROM bookings WHERE id = $1)", bookingID).Scan(&stamps) + err = tx.QueryRow(ctx, "SELECT loyalty_stamps FROM users WHERE id = (SELECT user_id FROM bookings WHERE id = $1)", bookingID).Scan(&stamps) if err != nil { t.Fatalf("failed to query stamps: %v", err) } @@ -135,7 +136,7 @@ func TestApplyLoyaltyRedemption_Success(t *testing.T) { // Verify a discount payment record was created var paymentCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to query payments: %v", err) @@ -146,34 +147,36 @@ func TestApplyLoyaltyRedemption_Success(t *testing.T) { } func TestApplyLoyaltyRedemption_InsufficientStamps(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupLoyaltyUser(t, 5) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 5) - w := makeApplyRedemptionRequest(bookingID, userToken) + w := makeApplyRedemptionRequest(bookingID, userToken, ctx) if w.Code != http.StatusBadRequest && w.Code != http.StatusConflict { t.Fatalf("expected 4xx for insufficient stamps, got %d: %s", w.Code, w.Body.String()) } } func TestApplyLoyaltyRedemption_AlreadyApplied(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupLoyaltyUser(t, 10) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10) // First call should succeed - w := makeApplyRedemptionRequest(bookingID, userToken) + w := makeApplyRedemptionRequest(bookingID, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("first call expected 200, got %d: %s", w.Code, w.Body.String()) } // Second call should be rejected - w = makeApplyRedemptionRequest(bookingID, userToken) + w = makeApplyRedemptionRequest(bookingID, userToken, ctx) if w.Code != http.StatusConflict && w.Code != http.StatusBadRequest { t.Fatalf("second call expected 4xx, got %d: %s", w.Code, w.Body.String()) } // Verify still only 1 discount record var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 loyalty discount record, got %d", discountCount) @@ -181,17 +184,18 @@ func TestApplyLoyaltyRedemption_AlreadyApplied(t *testing.T) { } func TestApplyLoyaltyRedemption_TerminalBooking(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupLoyaltyUser(t, 10) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10) // Set booking to a terminal status - _, err := db.DB.Exec(context.Background(), + _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set booking status: %v", err) } - w := makeApplyRedemptionRequest(bookingID, userToken) + w := makeApplyRedemptionRequest(bookingID, userToken, ctx) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for terminal booking, got %d: %s", w.Code, w.Body.String()) } @@ -201,24 +205,24 @@ func TestApplyLoyaltyRedemption_TerminalBooking(t *testing.T) { // applyEligibleCampaignsAtPayment — campaign auto-apply at payment time // ============================================================================= -func setupCampaignTest(t *testing.T) (string, string, string) { +func setupCampaignTest(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) { t.Helper() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = q.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set booking status: %v", err) @@ -229,13 +233,14 @@ func setupCampaignTest(t *testing.T) (string, string, string) { } func TestCampaignAutoApply_TimeBased(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) // Create an active time-based campaign now := time.Now() var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) RETURNING id @@ -245,7 +250,7 @@ func TestCampaignAutoApply_TimeBased(t *testing.T) { } // Insert a deposit payment to trigger campaign auto-apply - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) `, bookingID) @@ -254,11 +259,11 @@ func TestCampaignAutoApply_TimeBased(t *testing.T) { } // Call applyEligibleCampaignsAtPayment - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) // Verify booking_discounts was created var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount, got %d", discountCount) @@ -266,7 +271,7 @@ func TestCampaignAutoApply_TimeBased(t *testing.T) { // Verify times_redeemed was incremented var timesRedeemed int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed) if timesRedeemed != 1 { t.Errorf("expected 1 redemption, got %d", timesRedeemed) @@ -274,20 +279,21 @@ func TestCampaignAutoApply_TimeBased(t *testing.T) { } func TestCampaignAutoApply_UserMilestone(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) // Give user 5 completed bookings to match milestone_value=5 for i := 0; i < 5; i++ { var bid string - db.DB.QueryRow(context.Background(), ` + tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, time.Date(2024, time.Month(i+1), 15, 10, 0, 0, 0, time.UTC)).Scan(&bid) } // Create user milestone campaign for 5th booking var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ($1, 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'per_user_booking_count', 5, 1, 0) RETURNING id @@ -297,7 +303,7 @@ func TestCampaignAutoApply_UserMilestone(t *testing.T) { } // Insert a payment - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) `, bookingID) @@ -305,10 +311,10 @@ func TestCampaignAutoApply_UserMilestone(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount, got %d", discountCount) @@ -316,20 +322,21 @@ func TestCampaignAutoApply_UserMilestone(t *testing.T) { } func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) // Set global completed count high enough now := time.Now() for i := 0; i < 100; i++ { - db.DB.QueryRow(context.Background(), ` + tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, now.Add(-time.Duration(i)*24*time.Hour)).Scan(new(string)) } // Create global milestone campaign at milestone_value=100 var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0) RETURNING id @@ -339,7 +346,7 @@ func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) { } // Insert an ONLINE payment first (not in_person_card) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) `, bookingID) @@ -347,11 +354,11 @@ func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) // Verify NO discount was applied (global milestone skipped for online payment) var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 0 { t.Errorf("expected 0 campaign discounts (global milestone skipped for online), got %d", discountCount) @@ -359,17 +366,18 @@ func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) { } func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) for i := 0; i < 100; i++ { - db.DB.QueryRow(context.Background(), ` + tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, time.Date(2024, time.Month(i%12+1), 15, 10, 0, 0, 0, time.UTC)).Scan(new(string)) } var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0) RETURNING id @@ -379,7 +387,7 @@ func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) { } // Insert an IN-PERSON payment - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW()) `, bookingID) @@ -388,13 +396,13 @@ func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) { } // Simulate what CreateBookingPayment does: set status to confirmed after payment - db.DB.Exec(context.Background(), + tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1", bookingID) - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount (in-person global milestone), got %d", discountCount) @@ -402,12 +410,13 @@ func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) { } func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) now := time.Now() var campaignID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) RETURNING id @@ -417,7 +426,7 @@ func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) { } // Manually insert a booking_discount to simulate it was already applied at payment time - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500) `, bookingID, userID, campaignID) @@ -426,20 +435,20 @@ func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) { } // Pretend campaign was already redeemed - db.DB.Exec(context.Background(), + tx.Exec(ctx, "UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1", campaignID) // Insert payment to trigger applyEligibleCampaignsAtPayment - db.DB.Exec(context.Background(), ` + tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) `, bookingID) - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) // Verify still only 1 discount var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount (no double-apply), got %d", discountCount) @@ -451,12 +460,13 @@ func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) { // ============================================================================= func TestCampaignAutoApply_ReferralDiscount(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) // Create the referral + discount var refID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id @@ -466,7 +476,7 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) { } var rdID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, false) RETURNING id @@ -476,7 +486,7 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) { } // Insert payment to trigger auto-apply - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) `, bookingID) @@ -484,11 +494,11 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) // Verify referral discount was applied var discountCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount) if err != nil { t.Fatalf("failed to count referral discounts: %v", err) @@ -499,7 +509,7 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) { // Verify referral discount was marked as used var used bool - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT used FROM referral_discounts WHERE id = $1", rdID).Scan(&used) if err != nil { t.Fatalf("failed to query referral discount: %v", err) @@ -510,7 +520,7 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) { // Verify discount payment was created var paymentCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&paymentCount) if paymentCount == 0 { t.Error("expected at least 1 discount payment to be created") @@ -518,11 +528,12 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) { } func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) var refID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id @@ -532,7 +543,7 @@ func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) { } var rdID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, false) RETURNING id @@ -542,7 +553,7 @@ func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) { } // Pre-apply the referral discount to simulate it was applied on a previous attempt - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'referral', $3, 10.00, 5000, 500) `, bookingID, userID, rdID) @@ -550,11 +561,11 @@ func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) { t.Fatalf("failed to insert existing booking discount: %v", err) } - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) // Verify no second referral discount was applied var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 referral discount (no double-apply), got %d", discountCount) @@ -562,7 +573,7 @@ func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) { // Verify referral discount is still unused (since the function should skip it) var used bool - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT used FROM referral_discounts WHERE id = $1", rdID).Scan(&used) if used { t.Error("expected referral discount to remain unused (skipped by double-apply guard)") @@ -570,11 +581,12 @@ func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) { } func TestCampaignAutoApply_ReferralDiscount_AlreadyUsed(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, _ := setupCampaignTest(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupCampaignTest(t, ctx, tx) var refID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id @@ -585,7 +597,7 @@ func TestCampaignAutoApply_ReferralDiscount_AlreadyUsed(t *testing.T) { // Create an already-used referral discount var rdID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, true) RETURNING id @@ -594,10 +606,10 @@ func TestCampaignAutoApply_ReferralDiscount_AlreadyUsed(t *testing.T) { t.Fatalf("failed to insert used referral discount: %v", err) } - applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + applyEligibleCampaignsAtPayment(ctx, bookingID, userID) var discountCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount) if discountCount != 0 { t.Errorf("expected 0 referral discounts (already used), got %d", discountCount) diff --git a/backend/handlers/payments/payment_status_test.go b/backend/handlers/payments/payment_status_test.go index ac21b85..5d87755 100644 --- a/backend/handlers/payments/payment_status_test.go +++ b/backend/handlers/payments/payment_status_test.go @@ -24,54 +24,63 @@ import ( // ============================================================================= func TestIsValidBookingStatusForPayment_Confirmed(t *testing.T) { + t.Parallel() if !IsValidBookingStatusForPayment("confirmed") { t.Error("expected 'confirmed' to be valid for payment") } } func TestIsValidBookingStatusForPayment_Pending(t *testing.T) { + t.Parallel() if !IsValidBookingStatusForPayment("pending") { t.Error("expected 'pending' to be valid for payment") } } func TestIsValidBookingStatusForPayment_PendingRelease(t *testing.T) { + t.Parallel() if !IsValidBookingStatusForPayment("pending_release") { t.Error("expected 'pending_release' to be valid for payment") } } func TestIsValidBookingStatusForPayment_InProgress(t *testing.T) { + t.Parallel() if !IsValidBookingStatusForPayment("in_progress") { t.Error("expected 'in_progress' to be valid for payment") } } func TestIsValidBookingStatusForPayment_RejectsDepositLapsed(t *testing.T) { + t.Parallel() if IsValidBookingStatusForPayment("deposit_lapsed") { t.Error("expected 'deposit_lapsed' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsClientCancelled(t *testing.T) { + t.Parallel() if IsValidBookingStatusForPayment("client_cancelled") { t.Error("expected 'client_cancelled' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsWeCancelled(t *testing.T) { + t.Parallel() if IsValidBookingStatusForPayment("we_cancelled") { t.Error("expected 'we_cancelled' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsCompleted(t *testing.T) { + t.Parallel() if IsValidBookingStatusForPayment("completed") { t.Error("expected 'completed' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsNoShow(t *testing.T) { + t.Parallel() if IsValidBookingStatusForPayment("no_show") { t.Error("expected 'no_show' to be rejected for payment") } @@ -83,26 +92,26 @@ func TestIsValidBookingStatusForPayment_RejectsNoShow(t *testing.T) { // setupPaymentStatusTest creates a user, service, and booking with the given // status, returning the userID, bookingID, and user token. -func setupPaymentStatusTest(t *testing.T, status string) (string, string, string) { +func setupPaymentStatusTest(t *testing.T, ctx context.Context, q db.Querier, status string) (string, string, string) { t.Helper() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Use a far-future date so the booking is never in the cleanup window. - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", status, bookingID) + _, err = q.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", status, bookingID) if err != nil { t.Fatalf("failed to set booking status to %q: %v", status, err) } @@ -112,9 +121,10 @@ func setupPaymentStatusTest(t *testing.T, status string) (string, string, string } func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ @@ -126,7 +136,7 @@ func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) @@ -134,7 +144,7 @@ func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) { // Verify the booking was promoted back to confirmed. var status string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) @@ -145,9 +155,10 @@ func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) { } func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") // Pay 15% (£7.50 on a £50 booking) — below 20% threshold. cardToken := "cnon:test-card-nonce" @@ -160,7 +171,7 @@ func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -168,7 +179,7 @@ func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) { // Payment below 20% threshold — booking should remain pending_release. var status string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -179,9 +190,10 @@ func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) { } func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") // Pay £25 via "balance" type — still should meet the 20% threshold. cardToken := "cnon:test-card-nonce" @@ -194,14 +206,14 @@ func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var status string - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) @@ -212,9 +224,10 @@ func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) } func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ @@ -226,7 +239,7 @@ func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) @@ -234,9 +247,10 @@ func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) { } func TestCreateBookingPayment_RejectsPending(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ @@ -248,7 +262,7 @@ func TestCreateBookingPayment_RejectsPending(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for pending booking (not yet confirmed), got %d. body: %s", w.Code, w.Body.String()) @@ -256,9 +270,10 @@ func TestCreateBookingPayment_RejectsPending(t *testing.T) { } func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "deposit_lapsed") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ @@ -270,7 +285,7 @@ func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) @@ -278,9 +293,10 @@ func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) { } func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ @@ -292,7 +308,7 @@ func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) @@ -303,7 +319,7 @@ func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) { // Payment Lock Tests // ============================================================================= -func paymentLockRequest(method, path string, token string) *httptest.ResponseRecorder { +func paymentLockRequest(method, path string, token string, baseCtx ...context.Context) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+token) @@ -311,7 +327,12 @@ func paymentLockRequest(method, path string, token string) *httptest.ResponseRec rctx := chi.NewRouteContext() bookingID, _ := extractPaymentIDFromPath(path) rctx.URLParams.Add("id", bookingID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + + ctx := context.Background() + if len(baseCtx) > 0 { + ctx = baseCtx[0] + } + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) if token != "" { if info := extractUserFromTestJWT(token); info != nil { @@ -325,81 +346,90 @@ func paymentLockRequest(method, path string, token string) *httptest.ResponseRec } func TestAcquirePaymentLock_Confirmed(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_InProgress(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "in_progress") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "in_progress") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200 for in_progress booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_PendingRelease(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsDepositLapsed(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "deposit_lapsed") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsClientCancelled(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsWeCancelled(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "we_cancelled") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "we_cancelled") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for we_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsNoShow(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "no_show") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "no_show") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for no_show booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsPending(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for pending booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsCompleted(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "completed") - w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "completed") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for completed booking, got %d. body: %s", w.Code, w.Body.String()) } @@ -409,7 +439,7 @@ func TestAcquirePaymentLock_RejectsCompleted(t *testing.T) { // ReleasePaymentLock — DELETE /api/bookings/{id}/payment-lock // ============================================================================= -func releasePaymentLockRequest(path string, token string) *httptest.ResponseRecorder { +func releasePaymentLockRequest(path string, token string, baseCtx ...context.Context) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest("DELETE", path, nil) req.Header.Set("Authorization", "Bearer "+token) @@ -417,7 +447,12 @@ func releasePaymentLockRequest(path string, token string) *httptest.ResponseReco rctx := chi.NewRouteContext() bookingID, _ := extractPaymentIDFromPath(path) rctx.URLParams.Add("id", bookingID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + + ctx := context.Background() + if len(baseCtx) > 0 { + ctx = baseCtx[0] + } + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) if token != "" { if info := extractUserFromTestJWT(token); info != nil { @@ -431,18 +466,19 @@ func releasePaymentLockRequest(path string, token string) *httptest.ResponseReco } func TestReleasePaymentLock_HappyPath(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") // First, acquire the lock to create a PAYMENT_IN_FLIGHT time_blocker - lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 when acquiring lock, got %d", lockW.Code) } // Verify the lock exists in the DB var lockCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) @@ -452,13 +488,13 @@ func TestReleasePaymentLock_HappyPath(t *testing.T) { } // Now release the lock - w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) + w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content, got %d. body: %s", w.Code, w.Body.String()) } // Verify the lock was removed from the DB - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers after release: %v", err) @@ -469,11 +505,12 @@ func TestReleasePaymentLock_HappyPath(t *testing.T) { } func TestReleasePaymentLock_NoExistingLock(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") // Release without acquiring first — should be idempotent (204) - w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) + w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content for idempotent release, got %d. body: %s", w.Code, w.Body.String()) } @@ -494,28 +531,29 @@ func TestReleasePaymentLock_EmptyBookingID(t *testing.T) { } func TestReleasePaymentLock_AfterMultipleAcquires(t *testing.T) { - testutils.SetupTestDB(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") // Acquire the lock twice — AcquirePaymentLock should be idempotent - lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 on first acquire, got %d", lockW.Code) } - lockW = paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + lockW = paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 on second acquire, got %d", lockW.Code) } // Release should still succeed - w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) + w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content after multiple acquires, got %d. body: %s", w.Code, w.Body.String()) } // Verify all locks are gone var lockCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index da053bf..73f5b0b 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -23,11 +23,11 @@ import ( "github.com/go-chi/chi/v5" ) -func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder { - return makePaymentAuthRequest(handler, method, path, body, token, "") +func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder { + return makePaymentAuthRequest(handler, method, path, body, token, "", ctx) } -func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string) *httptest.ResponseRecorder { +func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -44,7 +44,7 @@ func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body if id, paramName := extractPaymentIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) var userID, userRole string if userIDOverride != "" { @@ -160,9 +160,10 @@ func parsePaymentResponseBody(w *httptest.ResponseRecorder, dest interface{}) er } func TestTerminalPayment_HappyPath(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() @@ -173,7 +174,7 @@ func TestTerminalPayment_HappyPath(t *testing.T) { } handler := CreateTerminalPayment - w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -193,7 +194,7 @@ func TestTerminalPayment_HappyPath(t *testing.T) { } var count int - err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } @@ -202,34 +203,34 @@ func TestTerminalPayment_HappyPath(t *testing.T) { } } -func setupTestData(t *testing.T) (string, string, string) { - return setupTestDataAtTime(t, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) +func setupTestData(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) { + return setupTestDataAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) } // setupTestDataPast creates a booking with start_time in the past (1 hour ago) // to prevent payment-split logic from triggering. Used by tests that verify // payment sequencing or idempotency rather than deposit allocation. -func setupTestDataPast(t *testing.T) (string, string, string) { - return setupTestDataAtTime(t, time.Now().Add(-1*time.Hour)) +func setupTestDataPast(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) { + return setupTestDataAtTime(t, ctx, q, time.Now().Add(-1*time.Hour)) } -func setupTestDataAtTime(t *testing.T, startTime time.Time) (string, string, string) { - userID, err := fixtures.CreateTestUser(db.DB) +func setupTestDataAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string, string) { + userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create test service: %v", err) } - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime) + bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + _, err = q.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } @@ -238,9 +239,10 @@ func setupTestDataAtTime(t *testing.T, startTime time.Time) (string, string, str } func TestTerminalPayment_PriceOverride(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() @@ -253,7 +255,7 @@ func TestTerminalPayment_PriceOverride(t *testing.T) { } handler := CreateTerminalPayment - w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -266,19 +268,20 @@ func TestTerminalPayment_PriceOverride(t *testing.T) { } func TestTerminalPayment_BookingNotInProgress(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } @@ -291,7 +294,7 @@ func TestTerminalPayment_BookingNotInProgress(t *testing.T) { } handler := CreateTerminalPayment - w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -301,7 +304,8 @@ func TestTerminalPayment_BookingNotInProgress(t *testing.T) { } func TestTerminalPayment_BookingNotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) adminToken := jwt.GenerateAdminToken() @@ -311,7 +315,7 @@ func TestTerminalPayment_BookingNotFound(t *testing.T) { } handler := CreateTerminalPayment - w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -319,9 +323,10 @@ func TestTerminalPayment_BookingNotFound(t *testing.T) { } func TestOnlinePayment_NewCard_Deposit(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) @@ -335,7 +340,7 @@ func TestOnlinePayment_NewCard_Deposit(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -359,7 +364,7 @@ func TestOnlinePayment_NewCard_Deposit(t *testing.T) { } var count int - err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } @@ -369,11 +374,12 @@ func TestOnlinePayment_NewCard_Deposit(t *testing.T) { } func TestOnlinePayment_SavedCard(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) - cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_mock_card_123", "VISA", "4242") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_mock_card_123", "VISA", "4242") if err != nil { t.Fatalf("failed to create payment method: %v", err) } @@ -388,7 +394,7 @@ func TestOnlinePayment_SavedCard(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -405,11 +411,12 @@ func TestOnlinePayment_SavedCard(t *testing.T) { } func TestOnlinePayment_BookingNotOwned(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) - otherUserID, err := fixtures.CreateTestUser(db.DB) + otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } @@ -425,7 +432,7 @@ func TestOnlinePayment_BookingNotOwned(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -433,19 +440,20 @@ func TestOnlinePayment_BookingNotOwned(t *testing.T) { } func TestGetUserPaymentMethods_HasCards(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_1", "VISA", "1111") + _, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_1", "VISA", "1111") if err != nil { t.Fatalf("failed to create payment method 1: %v", err) } - _, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_2", "MASTERCARD", "2222") + _, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_2", "MASTERCARD", "2222") if err != nil { t.Fatalf("failed to create payment method 2: %v", err) } @@ -453,7 +461,7 @@ func TestGetUserPaymentMethods_HasCards(t *testing.T) { userToken := jwt.GenerateUserToken(userID) handler := GetUserPaymentMethods - w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken) + w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -470,14 +478,15 @@ func TestGetUserPaymentMethods_HasCards(t *testing.T) { } func TestDeletePaymentMethod(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_delete", "VISA", "9999") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_delete", "VISA", "9999") if err != nil { t.Fatalf("failed to create payment method: %v", err) } @@ -485,7 +494,7 @@ func TestDeletePaymentMethod(t *testing.T) { userToken := jwt.GenerateUserToken(userID) handler := DeletePaymentMethod - w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, userToken) + w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -502,24 +511,25 @@ func TestDeletePaymentMethod(t *testing.T) { } func TestRefund_FullRefund(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed") + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_123" - _, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } @@ -530,7 +540,7 @@ func TestRefund_FullRefund(t *testing.T) { } handler := RefundPayment - w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -551,24 +561,25 @@ func TestRefund_FullRefund(t *testing.T) { } func TestRefund_PartialRefund(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed") + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_456" - _, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } @@ -579,7 +590,7 @@ func TestRefund_PartialRefund(t *testing.T) { } handler := RefundPayment - w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -596,19 +607,20 @@ func TestRefund_PartialRefund(t *testing.T) { } func TestRefund_OverRefundRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50.00, "in_person_card", "full", "completed") + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_789" - _, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } @@ -619,7 +631,7 @@ func TestRefund_OverRefundRejected(t *testing.T) { } handler := RefundPayment - w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -627,7 +639,8 @@ func TestRefund_OverRefundRejected(t *testing.T) { } func TestRefund_PaymentNotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) adminToken := jwt.GenerateAdminToken() @@ -637,7 +650,7 @@ func TestRefund_PaymentNotFound(t *testing.T) { } handler := RefundPayment - w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -645,13 +658,14 @@ func TestRefund_PaymentNotFound(t *testing.T) { } func TestRefund_PendingPaymentRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "pending") + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "pending") if err != nil { t.Fatalf("failed to create payment: %v", err) } @@ -662,7 +676,7 @@ func TestRefund_PendingPaymentRejected(t *testing.T) { } handler := RefundPayment - w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken) + w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -670,11 +684,12 @@ func TestRefund_PendingPaymentRejected(t *testing.T) { } func TestTipPayment_HappyPath(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) - _, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed") + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } @@ -688,7 +703,7 @@ func TestTipPayment_HappyPath(t *testing.T) { } handler := CreateTipPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -709,9 +724,10 @@ func TestTipPayment_HappyPath(t *testing.T) { } func TestTipPayment_NoPriorPayment(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) @@ -722,7 +738,7 @@ func TestTipPayment_NoPriorPayment(t *testing.T) { } handler := CreateTipPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -730,9 +746,10 @@ func TestTipPayment_NoPriorPayment(t *testing.T) { } func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestDataPast(t) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) @@ -747,7 +764,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { } handler := CreateBookingPayment - w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) @@ -765,7 +782,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { IdempotencyKey: idempotencyKey, } - w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) @@ -781,7 +798,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { } var count int - err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } @@ -791,9 +808,10 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { } func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestDataPast(t) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) @@ -807,7 +825,7 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { } handler := CreateBookingPayment - w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) @@ -820,7 +838,7 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { IdempotencyKey: "key-2", } - w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) // The second request is blocked because only one "full" payment is // allowed per booking (the payment-type duplicate guard prevents the @@ -830,7 +848,7 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { } var count int - err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } @@ -844,9 +862,10 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { // ============================================================================= func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") // First: a deposit payment should succeed. cardToken := "cnon:diff-type-card" @@ -858,7 +877,7 @@ func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) { } handler := CreateBookingPayment - w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", depositReq, userToken) + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", depositReq, userToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("deposit payment expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } @@ -871,7 +890,7 @@ func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) { IdempotencyKey: "diff-type-balance-" + bookingID, } - w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", balanceReq, userToken) + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", balanceReq, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("balance payment expected 200 (different type allowed), got %d. body: %s", w2.Code, w2.Body.String()) } @@ -880,7 +899,7 @@ func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) { // create extra records (e.g. a 'balance' portion alongside 'deposit'), so // we check DISTINCT types rather than a raw row count. var distinctTypes []string - rows, err := db.DB.Query(context.Background(), + rows, err := tx.Query(ctx, "SELECT DISTINCT payment_type FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY payment_type", bookingID) if err != nil { @@ -899,9 +918,10 @@ func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) { } func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") cardToken := "cnon:dup-type-card" @@ -914,7 +934,7 @@ func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) { } handler := CreateBookingPayment - w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first payment expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } @@ -928,7 +948,7 @@ func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) { IdempotencyKey: "dup-type-second-" + bookingID, } - w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusConflict { t.Errorf("duplicate 'full' payment expected 409, got %d. body: %s", w2.Code, w2.Body.String()) } @@ -937,7 +957,7 @@ func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) { // first 'full' payment into 'deposit' + 'balance', so we count deposit records // rather than 'full' — the exact guard above confirmed the 409 rejection. var depositCount int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'deposit' AND payment_method NOT IN ('discount', 'on_the_house')", bookingID).Scan(&depositCount) if err != nil { @@ -949,9 +969,10 @@ func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) { } func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") cardToken := "cnon:partial-card" @@ -964,7 +985,7 @@ func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) { } handler := CreateBookingPayment - w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first partial expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } @@ -978,7 +999,7 @@ func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) { IdempotencyKey: "partial-second-" + bookingID, } - w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("second partial expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } @@ -986,7 +1007,7 @@ func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) { // Count all real payments (buildSplitRecords converts partials to deposit // when within the 50% deposit cap). Both should have been created. var total int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')", bookingID).Scan(&total) if err != nil { @@ -998,15 +1019,7 @@ func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) { } func TestSquareWebhook_DevMode_NoSignature(t *testing.T) { - testutils.SetupTestDB(t) - - req := httptest.NewRequest("POST", "/api/webhooks/square", nil) - req.Header.Set("Content-Type", "application/json") - - w := httptest.NewRecorder() - - _ = req - _ = w + t.Parallel() t.Skip("webhook handler tested in webhooks package") } @@ -1014,34 +1027,34 @@ func TestSquareWebhook_DevMode_NoSignature(t *testing.T) { // User Booking Payment Tests — deposit, full, partial, balance // ============================================================ -func setupDepositBooking(t *testing.T) (string, string) { - return setupDepositBookingAtTime(t, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) +func setupDepositBooking(t *testing.T, ctx context.Context, q db.Querier) (string, string) { + return setupDepositBookingAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) } // setupDepositBookingPast creates a confirmed booking with start_time in the past // (1 hour ago). This prevents the payment-split logic from triggering, which is // useful for tests that verify payment sequencing rather than deposit splitting. -func setupDepositBookingPast(t *testing.T) (string, string) { - return setupDepositBookingAtTime(t, time.Now().Add(-1*time.Hour)) +func setupDepositBookingPast(t *testing.T, ctx context.Context, q db.Querier) (string, string) { + return setupDepositBookingAtTime(t, ctx, q, time.Now().Add(-1*time.Hour)) } -func setupDepositBookingAtTime(t *testing.T, startTime time.Time) (string, string) { - userID, err := fixtures.CreateTestUser(db.DB) +func setupDepositBookingAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string) { + userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create test service: %v", err) } - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime) + bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } - _, err = db.DB.Exec(context.Background(), + _, err = q.Exec(ctx, "UPDATE bookings SET status = 'confirmed', deposit_required = TRUE WHERE id = $1", bookingID) if err != nil { @@ -1052,9 +1065,10 @@ func setupDepositBookingAtTime(t *testing.T, startTime time.Time) (string, strin } func TestBookingPayment_Deposit_HappyPath(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:deposit-card" @@ -1067,7 +1081,7 @@ func TestBookingPayment_Deposit_HappyPath(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1091,7 +1105,7 @@ func TestBookingPayment_Deposit_HappyPath(t *testing.T) { } var count int - err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'", bookingID).Scan(&count) + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } @@ -1101,9 +1115,10 @@ func TestBookingPayment_Deposit_HappyPath(t *testing.T) { } func TestBookingPayment_FullPayment(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:full-card" @@ -1115,7 +1130,7 @@ func TestBookingPayment_FullPayment(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1136,9 +1151,10 @@ func TestBookingPayment_FullPayment(t *testing.T) { } func TestBookingPayment_PartialPayment(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:partial-card" @@ -1150,7 +1166,7 @@ func TestBookingPayment_PartialPayment(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1171,9 +1187,10 @@ func TestBookingPayment_PartialPayment(t *testing.T) { } func TestBookingPayment_BalancePayment(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:balance-card" @@ -1185,7 +1202,7 @@ func TestBookingPayment_BalancePayment(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1211,9 +1228,10 @@ func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) { // A full payment of £50 on a £50 booking (future-dated) should be split: // record 1: payment_type='deposit', amount=25.00 // record 2: payment_type='balance', amount=25.00 - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:split-full-card" @@ -1225,14 +1243,14 @@ func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Should be exactly 2 payment records. - rows, err := db.DB.Query(context.Background(), + rows, err := tx.Query(ctx, `SELECT payment_type, amount, square_payment_id FROM payments WHERE booking_id = $1 ORDER BY amount DESC`, bookingID) if err != nil { @@ -1281,9 +1299,10 @@ func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) { func TestBookingPayment_FullPayment_PastBooking_DoesNotSplit(t *testing.T) { // A full payment on a PAST booking should NOT split (single record). - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBookingPast(t) + userID, bookingID := setupDepositBookingPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:nosplit-card" @@ -1295,14 +1314,14 @@ func TestBookingPayment_FullPayment_PastBooking_DoesNotSplit(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var count int - err := db.DB.QueryRow(context.Background(), + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Fatalf("failed to query payments: %v", err) @@ -1316,9 +1335,10 @@ func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) // Verify that when the split-record insert fails, the entire group rolls // back atomically. We simulate a failure by causing the second INSERT to // violate a NOT NULL constraint (passing an invalid record). - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID := setupDepositBooking(t) + _, bookingID := setupDepositBooking(t, ctx, tx) // Use a nil idempotency key on the split record — this works fine for both. // Instead we rely on the fact that the handler wraps both inserts in a @@ -1327,18 +1347,17 @@ func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) // Because we can't easily inject a DB error through the handler, we verify // the architecture at the service level instead: - ctx := context.Background() - tx, err := db.DB.Begin(ctx) + innerTx, err := db.Conn.Begin(ctx) if err != nil { t.Fatalf("failed to begin tx: %v", err) } - defer tx.Rollback(ctx) + defer innerTx.Rollback(ctx) svc := NewPaymentService() now := time.Now() // First record — valid. - pid1, err := svc.CreatePaymentRecordTx(ctx, tx, PaymentRecord{ + pid1, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{ BookingID: bookingID, PaymentType: "deposit", PaymentMethod: "cash", @@ -1355,7 +1374,7 @@ func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) } // Second record — also valid. - pid2, err := svc.CreatePaymentRecordTx(ctx, tx, PaymentRecord{ + pid2, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{ BookingID: bookingID, PaymentType: "balance", PaymentMethod: "cash", @@ -1371,19 +1390,19 @@ func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) t.Fatal("expected non-empty payment id") } - if err := tx.Commit(ctx); err != nil { + if err := innerTx.Commit(ctx); err != nil { t.Fatalf("failed to commit tx: %v", err) } // Both records should exist. var count int - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1 OR id = $2", pid1, pid2).Scan(&count) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1 OR id = $2", pid1, pid2).Scan(&count) if count != 2 { t.Errorf("expected 2 committed records, got %d", count) } - // Now test rollback: start a new tx, insert, then rollback. - tx2, err := db.DB.Begin(ctx) + // Now test rollback: start a new inner tx, insert, then rollback. + tx2, err := db.Conn.Begin(ctx) if err != nil { t.Fatalf("failed to begin tx2: %v", err) } @@ -1405,7 +1424,7 @@ func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) // Rolled-back record should NOT exist. var rollbackCount int - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", pid3).Scan(&rollbackCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", pid3).Scan(&rollbackCount) if rollbackCount != 0 { t.Errorf("expected 0 records after rollback, got %d", rollbackCount) } @@ -1579,9 +1598,10 @@ func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) { // --------------------------------------------------------------------------- func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:atomic-card" @@ -1593,7 +1613,7 @@ func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1616,14 +1636,14 @@ func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) { // Verify both split records exist and the total paid is correct. var recordCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&recordCount) if recordCount != 2 { t.Errorf("expected 2 completed payment records from split, got %d", recordCount) } var totalPaid float64 - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&totalPaid) if totalPaid != 50.00 { t.Errorf("expected total paid £50.00, got £%.2f", totalPaid) @@ -1631,7 +1651,7 @@ func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) { // Deposit threshold should have been met — verify booking promoted from pending_release. var status string - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if status == "pending_release" { t.Error("expected booking to be promoted from pending_release after payment meets 20% threshold") @@ -1639,9 +1659,10 @@ func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) { } func TestBookingPayment_ZeroAmountRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:zero-card" @@ -1653,7 +1674,7 @@ func TestBookingPayment_ZeroAmountRejected(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -1661,9 +1682,10 @@ func TestBookingPayment_ZeroAmountRejected(t *testing.T) { } func TestBookingPayment_NegativeAmountRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:neg-card" @@ -1675,7 +1697,7 @@ func TestBookingPayment_NegativeAmountRejected(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -1683,9 +1705,10 @@ func TestBookingPayment_NegativeAmountRejected(t *testing.T) { } func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:invalid-type-card" @@ -1697,7 +1720,7 @@ func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -1705,9 +1728,10 @@ func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) { } func TestBookingPayment_NoAuthRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID := setupDepositBooking(t) + _, bookingID := setupDepositBooking(t, ctx, tx) cardToken := "cnon:no-auth-card" req := CreateBookingPaymentRequest{ @@ -1718,7 +1742,7 @@ func TestBookingPayment_NoAuthRejected(t *testing.T) { } handler := CreateBookingPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, "") + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) @@ -1726,9 +1750,10 @@ func TestBookingPayment_NoAuthRejected(t *testing.T) { } func TestBookingPayment_DepositFollowedByBalance(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID := setupDepositBooking(t) + userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:deposit-balance-card" @@ -1741,7 +1766,7 @@ func TestBookingPayment_DepositFollowedByBalance(t *testing.T) { } handler := CreateBookingPayment - w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("deposit: expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) @@ -1754,14 +1779,14 @@ func TestBookingPayment_DepositFollowedByBalance(t *testing.T) { IdempotencyKey: "deposit-balance-2", } - w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var count int - err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } @@ -1771,10 +1796,11 @@ func TestBookingPayment_DepositFollowedByBalance(t *testing.T) { } func TestBookingPayment_PartialFollowedByBalance(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Past booking to avoid payment-split; we're testing sequence not deposit allocation. - userID, bookingID := setupDepositBookingPast(t) + userID, bookingID := setupDepositBookingPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:partial-balance-card" @@ -1787,7 +1813,7 @@ func TestBookingPayment_PartialFollowedByBalance(t *testing.T) { } handler := CreateBookingPayment - w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("partial: expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) @@ -1800,14 +1826,14 @@ func TestBookingPayment_PartialFollowedByBalance(t *testing.T) { IdempotencyKey: "partial-balance-2", } - w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var count int - err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) + err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } @@ -1817,16 +1843,17 @@ func TestBookingPayment_PartialFollowedByBalance(t *testing.T) { } func TestTipPayment_WrongOwnerRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t) + _, bookingID, _ := setupTestData(t, ctx, tx) - _, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed") + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } - otherUserID, err := fixtures.CreateTestUser(db.DB) + otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } @@ -1839,7 +1866,7 @@ func TestTipPayment_WrongOwnerRejected(t *testing.T) { } handler := CreateTipPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, otherToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, otherToken, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -1847,11 +1874,12 @@ func TestTipPayment_WrongOwnerRejected(t *testing.T) { } func TestTipPayment_MultipleTipsAllowed(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) - _, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed") + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } @@ -1865,7 +1893,7 @@ func TestTipPayment_MultipleTipsAllowed(t *testing.T) { } handler := CreateTipPayment - w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken) + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("tip %d: expected status 200, got %d. body: %s", i, w.Code, w.Body.String()) @@ -1873,7 +1901,7 @@ func TestTipPayment_MultipleTipsAllowed(t *testing.T) { } var count int - err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&count) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query tip payments: %v", err) } @@ -1883,9 +1911,10 @@ func TestTipPayment_MultipleTipsAllowed(t *testing.T) { } func TestGetUserPaymentMethods_NoCards(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -1893,7 +1922,7 @@ func TestGetUserPaymentMethods_NoCards(t *testing.T) { userToken := jwt.GenerateUserToken(userID) handler := GetUserPaymentMethods - w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken) + w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1910,19 +1939,20 @@ func TestGetUserPaymentMethods_NoCards(t *testing.T) { } func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_wrong_owner", "VISA", "0000") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_wrong_owner", "VISA", "0000") if err != nil { t.Fatalf("failed to create payment method: %v", err) } - otherUserID, err := fixtures.CreateTestUser(db.DB) + otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } @@ -1930,14 +1960,14 @@ func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) { otherToken := jwt.GenerateUserToken(otherUserID) handler := DeletePaymentMethod - w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, otherToken) + w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, otherToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var count int - err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL", cardID).Scan(&count) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL", cardID).Scan(&count) if err != nil { t.Errorf("failed to query card: %v", err) } @@ -1974,26 +2004,27 @@ func TestValidatePartialAmount(t *testing.T) { } func TestGetBookingRemainingBalanceCents(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } service := NewPaymentService() - initialRemaining, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID) + initialRemaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2001,7 +2032,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { t.Fatalf("expected positive remaining balance, got %d", initialRemaining) } - _, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{ + _, err = service.CreatePaymentRecord(ctx, PaymentRecord{ BookingID: bookingID, PaymentType: "partial", PaymentMethod: "cash", @@ -2012,7 +2043,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } - afterPartial, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID) + afterPartial, err := service.GetBookingRemainingBalanceCents(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2020,7 +2051,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { t.Errorf("expected %d cents remaining after £20 payment, got %d", initialRemaining-2000, afterPartial) } - _, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{ + _, err = service.CreatePaymentRecord(ctx, PaymentRecord{ BookingID: bookingID, PaymentType: "balance", PaymentMethod: "cash", @@ -2031,7 +2062,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { t.Fatalf("failed to create payment: %v", err) } - afterFull, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID) + afterFull, err := service.GetBookingRemainingBalanceCents(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2041,9 +2072,10 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { } func TestCreatePaymentMethod_HappyPath(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -2057,7 +2089,7 @@ func TestCreatePaymentMethod_HappyPath(t *testing.T) { CVC: "123", } - w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token) + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -2081,9 +2113,10 @@ func TestCreatePaymentMethod_HappyPath(t *testing.T) { } func TestCreatePaymentMethod_ExpiredCardRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -2097,7 +2130,7 @@ func TestCreatePaymentMethod_ExpiredCardRejected(t *testing.T) { CVC: "123", } - w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token) + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -2105,9 +2138,10 @@ func TestCreatePaymentMethod_ExpiredCardRejected(t *testing.T) { } func TestCreatePaymentMethod_InvalidExpiryRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -2132,7 +2166,7 @@ func TestCreatePaymentMethod_InvalidExpiryRejected(t *testing.T) { CVC: "123", } - w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token) + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -2142,9 +2176,10 @@ func TestCreatePaymentMethod_InvalidExpiryRejected(t *testing.T) { } func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -2163,7 +2198,7 @@ func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := CreatePaymentMethod - w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", tt.body, token) + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", tt.body, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) @@ -2173,14 +2208,15 @@ func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) { } func TestCreatePaymentMethod_NoAuthRejected(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := CreatePaymentMethod w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{ CardNumber: "4111111111111111", Expiry: "12/30", CVC: "123", - }, "") + }, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) @@ -2188,9 +2224,10 @@ func TestCreatePaymentMethod_NoAuthRejected(t *testing.T) { } func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -2205,7 +2242,7 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) { CVC: "123", } - w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token) + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusOK { t.Fatalf("failed to create first card: %d. body: %s", w.Code, w.Body.String()) } @@ -2216,7 +2253,7 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) { CVC: "456", } - w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token) + w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token, ctx) if w.Code != http.StatusOK { t.Fatalf("failed to create second card: %d. body: %s", w.Code, w.Body.String()) } diff --git a/backend/handlers/payments/refund_exclude_test.go b/backend/handlers/payments/refund_exclude_test.go index 8fcc362..3f34c77 100644 --- a/backend/handlers/payments/refund_exclude_test.go +++ b/backend/handlers/payments/refund_exclude_test.go @@ -13,23 +13,23 @@ import ( "crussell/testutils/fixtures" ) -func setupRefundTestWithDiscount(t *testing.T) (string, string, float64) { +func setupRefundTestWithDiscount(t *testing.T, ctx context.Context, q db.Querier) (string, string, float64) { t.Helper() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Insert a real cash payment of 50 - _, err = db.DB.Exec(context.Background(), ` + _, err = q.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'cash', 5000, 'completed', NOW(), NOW()) `, bookingID) @@ -38,7 +38,7 @@ func setupRefundTestWithDiscount(t *testing.T) (string, string, float64) { } // Insert a discount payment record (should be excluded from refund) - _, err = db.DB.Exec(context.Background(), ` + _, err = q.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'partial', 'discount', 500, 'completed', NOW(), NOW()) `, bookingID) @@ -47,7 +47,7 @@ func setupRefundTestWithDiscount(t *testing.T) (string, string, float64) { } // Insert an on_the_house payment record (should also be excluded) - _, err = db.DB.Exec(context.Background(), ` + _, err = q.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'partial', 'on_the_house', 1000, 'completed', NOW(), NOW()) `, bookingID) @@ -59,14 +59,15 @@ func setupRefundTestWithDiscount(t *testing.T) (string, string, float64) { } func TestProcessCancellationRefund_ExcludesDiscountPayments(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, total := setupRefundTestWithDiscount(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, total := setupRefundTestWithDiscount(t, ctx, tx) farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour) // >72h before result, err := ProcessCancellationRefund( - context.Background(), bookingID, total, 50, + ctx, bookingID, total, 50, farFuture, now, "client_cancelled", &userID, ) if err != nil { @@ -80,12 +81,13 @@ func TestProcessCancellationRefund_ExcludesDiscountPayments(t *testing.T) { } func TestProcessCancellationRefund_ExcludesOnTheHousePayments(t *testing.T) { - testutils.SetupTestDB(t) - userID, bookingID, total := setupRefundTestWithDiscount(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, total := setupRefundTestWithDiscount(t, ctx, tx) // Make on_the_house the only non-discount payment by marking the 50 cash as a payment that gets refunded // but also add a pure on_the_house booking with no real money - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'partial', 'discount', 2500, 'completed', NOW(), NOW()) `, bookingID) @@ -97,7 +99,7 @@ func TestProcessCancellationRefund_ExcludesOnTheHousePayments(t *testing.T) { now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour) result, err := ProcessCancellationRefund( - context.Background(), bookingID, total, 50, + ctx, bookingID, total, 50, farFuture, now, "client_cancelled", &userID, ) if err != nil { diff --git a/backend/handlers/payments/refunds_test.go b/backend/handlers/payments/refunds_test.go index b2d4414..a564c07 100644 --- a/backend/handlers/payments/refunds_test.go +++ b/backend/handlers/payments/refunds_test.go @@ -4,11 +4,9 @@ package payments import ( - "context" "testing" "time" - "crussell/db" "crussell/testutils" "crussell/testutils/fixtures" ) @@ -18,6 +16,7 @@ import ( // ============================================================================= func TestCalculateRefundForCancellation_FullRefund_Over72h(t *testing.T) { + t.Parallel() now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) // >72h away @@ -149,45 +148,42 @@ func TestCalculateRefundForCancellation_Exact24hBoundary(t *testing.T) { // ============================================================================= func TestProcessCancellationRefund_CreatesRefundRecords(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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(), + _, err = tx.Exec(ctx, "UPDATE bookings SET deposit_required = true WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set deposit_required: %v", err) } // Add a completed payment - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed") + _, err = fixtures.CreateTestPayment(tx, 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) }) // Cancel >72h before — full refund expected now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) - result, err := ProcessCancellationRefund(context.Background(), bookingID, 50, 50, start, now, "client_cancelled", &userID) + result, err := ProcessCancellationRefund(ctx, bookingID, 50, 50, start, now, "client_cancelled", &userID) if err != nil { t.Fatalf("ProcessCancellationRefund failed: %v", err) } @@ -200,7 +196,7 @@ func TestProcessCancellationRefund_CreatesRefundRecords(t *testing.T) { // Check refund record was created var refundCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if refundCount != 1 { t.Errorf("expected 1 refund record, got %d", refundCount) @@ -208,32 +204,30 @@ func TestProcessCancellationRefund_CreatesRefundRecords(t *testing.T) { } func TestProcessCancellationRefund_NoRefundWhenNotNeeded(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) // Cancel <24h before — refundable should be 0 now := time.Date(2099, 12, 31, 9, 0, 0, 0, time.UTC) start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) - result, err := ProcessCancellationRefund(context.Background(), bookingID, 100, 0, start, now, "no_show", &userID) + result, err := ProcessCancellationRefund(ctx, bookingID, 100, 0, start, now, "no_show", &userID) if err != nil { t.Fatalf("ProcessCancellationRefund failed: %v", err) } @@ -246,31 +240,29 @@ func TestProcessCancellationRefund_NoRefundWhenNotNeeded(t *testing.T) { } func TestProcessCancellationRefund_NoPaymentsNoop(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) - result, err := ProcessCancellationRefund(context.Background(), bookingID, 100, 0, start, now, "client_cancelled", &userID) + result, err := ProcessCancellationRefund(ctx, bookingID, 100, 0, start, now, "client_cancelled", &userID) if err != nil { t.Fatalf("ProcessCancellationRefund failed: %v", err) } @@ -279,7 +271,7 @@ func TestProcessCancellationRefund_NoPaymentsNoop(t *testing.T) { } var refundCount int - db.DB.QueryRow(context.Background(), + tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if refundCount != 0 { t.Errorf("expected 0 refund records, got %d", refundCount) @@ -291,61 +283,52 @@ func TestProcessCancellationRefund_NoPaymentsNoop(t *testing.T) { // ============================================================================= func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } var giftCardID string - if err := db.DB.QueryRow(context.Background(), ` + if err := tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date, last_used_at) VALUES (100, 40, $1, false, NULL, NOW()) RETURNING id `, userID).Scan(&giftCardID); err != nil { t.Fatalf("failed to create gift card: %v", err) } - t.Cleanup(func() { - db.DB.Exec(context.Background(), "DELETE FROM gift_card_transactions WHERE gift_card_id = $1", giftCardID) - db.DB.Exec(context.Background(), "DELETE FROM gift_cards WHERE id = $1", giftCardID) - }) var paymentID string - if err := db.DB.QueryRow(context.Background(), ` + if err := tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at) VALUES ($1, 'full', 'giftcard', 'completed', 60, $2, NOW(), NOW()) RETURNING id `, bookingID, giftCardID).Scan(&paymentID); err != nil { t.Fatalf("failed to create giftcard payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) // Booking is far in the future — full refund. farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 60, + ctx, bookingID, 100, 60, farFuture, time.Now(), "client_cancelled", &userID, ) if err != nil { @@ -356,7 +339,7 @@ func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) { } var amountRemaining float64 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining) if err != nil { t.Fatalf("failed to query gift card balance: %v", err) @@ -367,7 +350,7 @@ func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) { // Verify refund record exists (primary audit trail for cancellation refunds). var refundCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) @@ -377,7 +360,7 @@ func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) { } var txCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount) if err != nil { t.Fatalf("failed to query gift card transactions: %v", err) @@ -388,44 +371,39 @@ func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) { } func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a cash payment of 30. - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 30, "cash", "deposit", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "deposit", "completed") if err != nil { t.Fatalf("failed to create cash payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 30, + ctx, bookingID, 100, 30, farFuture, time.Now(), "client_cancelled", &userID, ) if err != nil { @@ -437,7 +415,7 @@ func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) { // Verify user balance was credited. var balance float64 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { t.Fatalf("failed to query balance: %v", err) @@ -448,45 +426,39 @@ func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) { } func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create an online_square payment — this will be handled by Square mock. - var paymentID string - paymentID, err = fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create card payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 100, + ctx, bookingID, 100, 100, farFuture, time.Now(), "client_cancelled", &userID, ) if err != nil { @@ -500,7 +472,7 @@ func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testi // the refund and the amount falls through to a balance credit. In production // with a real square_payment_id the Square API would handle the refund instead. var balance float64 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { t.Fatalf("failed to query balance: %v", err) @@ -515,42 +487,39 @@ func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testi // ============================================================================= func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a discount payment (no real money exchanged). - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 20, "discount", "partial", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 20, "discount", "partial", "completed") if err != nil { t.Fatalf("failed to create discount payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 20, + ctx, bookingID, 100, 20, farFuture, time.Now(), "client_cancelled", &userID, ) if err != nil { @@ -562,7 +531,7 @@ func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) { // Discount payments should NOT create a balance credit. var balance float64 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { balance = 0 @@ -573,42 +542,39 @@ func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) { } func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create an on_the_house payment (no real money exchanged). - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "on_the_house", "full", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 100, "on_the_house", "full", "completed") if err != nil { t.Fatalf("failed to create on_the_house payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 100, + ctx, bookingID, 100, 100, farFuture, time.Now(), "client_cancelled", &userID, ) if err != nil { @@ -620,7 +586,7 @@ func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) { // on_the_house payments should NOT create a balance credit. var balance float64 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { balance = 0 @@ -635,48 +601,45 @@ func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) { // ============================================================================= func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a cash payment. - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "cash", "deposit", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 50, "cash", "deposit", "completed") if err != nil { t.Fatalf("failed to create cash payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) // Set user_id to NULL on the booking to simulate a purged guest account. - _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET user_id = NULL WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET user_id = NULL WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to nullify booking user_id: %v", err) } farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 50, + ctx, bookingID, 100, 50, farFuture, time.Now(), "client_cancelled", nil, ) if err != nil { @@ -688,7 +651,7 @@ func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) { // Refund record should still be created even without user_id. var refundCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) @@ -703,47 +666,44 @@ func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) { // ============================================================================= func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create a user and promote them to guest role. - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a gift card payment. - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "giftcard", "deposit", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 50, "giftcard", "deposit", "completed") if err != nil { t.Fatalf("failed to create giftcard payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 50, + ctx, bookingID, 100, 50, farFuture, time.Now(), "client_cancelled", &userID, ) if err != nil { @@ -755,7 +715,7 @@ func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing. // Guest must NOT have a balance credit. var balance float64 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { // No row means balance is 0 — this is the expected outcome. @@ -767,7 +727,7 @@ func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing. // Refund record should still exist. var refundCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) @@ -778,46 +738,43 @@ func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing. } func TestProcessCancellationRefund_GuestCashDoesNotCreditBalance(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) - _, err = db.DB.Exec(context.Background(), "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) + _, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a cash payment. - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 30, "cash", "full", "completed") + _, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create cash payment: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 30, + ctx, bookingID, 100, 30, farFuture, time.Now(), "client_cancelled", &userID, ) if err != nil { @@ -829,7 +786,7 @@ func TestProcessCancellationRefund_GuestCashDoesNotCreditBalance(t *testing.T) { // Guest must NOT have a balance credit. var balance float64 - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { balance = 0 @@ -847,28 +804,26 @@ func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *test // When a single Square charge is split into 2 DB payment records (deposit + balance) // sharing the same square_payment_id, the refund loop must only call Square once. // The second record should be credited to the user balance instead. - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) 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) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) 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' WHERE id = $1", bookingID) + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } @@ -880,7 +835,7 @@ func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *test // split charge where one Square payment was recorded as deposit + balance. svc := NewPaymentService() - pid1, err := svc.CreatePaymentRecord(context.Background(), PaymentRecord{ + _, err = svc.CreatePaymentRecord(ctx, PaymentRecord{ BookingID: bookingID, PaymentType: "deposit", PaymentMethod: "online_square", @@ -893,9 +848,8 @@ func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *test if err != nil { t.Fatalf("failed to create deposit record: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, pid1) }) - pid2, err := svc.CreatePaymentRecord(context.Background(), PaymentRecord{ + _, err = svc.CreatePaymentRecord(ctx, PaymentRecord{ BookingID: bookingID, PaymentType: "balance", PaymentMethod: "online_square", @@ -908,14 +862,13 @@ func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *test if err != nil { t.Fatalf("failed to create balance record: %v", err) } - t.Cleanup(func() { fixtures.DeletePayment(db.DB, pid2) }) // Cancel 72+ hours before → full refund of £50. farFuture := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund( - context.Background(), bookingID, 100, 50, + ctx, bookingID, 100, 50, start, farFuture, "client_cancelled", &userID, ) if err != nil { @@ -928,7 +881,7 @@ func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *test // Should have created 1 Square refund (for the deposit record) and credited // the balance portion via user balance. var refundCount int - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) diff --git a/backend/handlers/payments/testmain_test.go b/backend/handlers/payments/testmain_test.go index 59c2f13..ce50f0c 100644 --- a/backend/handlers/payments/testmain_test.go +++ b/backend/handlers/payments/testmain_test.go @@ -15,10 +15,11 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_payments") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() square.Client = square.NewDevClient() SquareClient = square.Client + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_payments") os.Exit(code) diff --git a/backend/handlers/payments/till_test.go b/backend/handlers/payments/till_test.go index c8b1ee8..5b951f0 100644 --- a/backend/handlers/payments/till_test.go +++ b/backend/handlers/payments/till_test.go @@ -5,27 +5,27 @@ package payments import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" "testing" "crussell/db" - "crussell/testutils" "crussell/mw" + "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" ) func TestCreateTillSale_OnTheHouse(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } @@ -44,6 +44,8 @@ func TestCreateTillSale_OnTheHouse(t *testing.T) { req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) + w := httptest.NewRecorder() r := chi.NewRouter() @@ -78,7 +80,7 @@ func TestCreateTillSale_OnTheHouse(t *testing.T) { // Verify till_sale was created in DB var saleCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount) if err != nil { t.Errorf("failed to query till_sales: %v", err) } @@ -88,7 +90,7 @@ func TestCreateTillSale_OnTheHouse(t *testing.T) { // Verify gift card was created var gcCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", *resp.ItemID).Scan(&gcCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", *resp.ItemID).Scan(&gcCount) if err != nil { t.Errorf("failed to query gift_cards: %v", err) } @@ -98,11 +100,11 @@ func TestCreateTillSale_OnTheHouse(t *testing.T) { } func TestCreateTillSale_Idempotency(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } @@ -126,6 +128,8 @@ func TestCreateTillSale_Idempotency(t *testing.T) { req1.Header.Set("Authorization", "Bearer "+adminToken) req1.Header.Set("Content-Type", "application/json") + req1 = req1.WithContext(db.ContextWithTx(req1.Context(), tx.(pgx.Tx))) + w1 := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) @@ -150,6 +154,8 @@ func TestCreateTillSale_Idempotency(t *testing.T) { req2.Header.Set("Authorization", "Bearer "+adminToken) req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx))) + w2 := httptest.NewRecorder() r2 := chi.NewRouter() r2.Use(mw.RequireAuth) @@ -172,7 +178,7 @@ func TestCreateTillSale_Idempotency(t *testing.T) { // Verify only one till_sale exists var saleCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount) if err != nil { t.Errorf("failed to query till_sales: %v", err) } @@ -182,11 +188,11 @@ func TestCreateTillSale_Idempotency(t *testing.T) { } func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } @@ -205,6 +211,8 @@ func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) { req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) + w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) @@ -226,7 +234,7 @@ func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) { // Verify gift_card_transactions was created var txCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase'", *resp.ItemID).Scan(&txCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase'", *resp.ItemID).Scan(&txCount) if err != nil { t.Errorf("failed to query gift_card_transactions: %v", err) } @@ -236,7 +244,7 @@ func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) { // Verify the transaction has reference_type = 'till_sale' and reference_id is set var refCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id IS NOT NULL", *resp.ItemID).Scan(&refCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id IS NOT NULL", *resp.ItemID).Scan(&refCount) if err != nil { t.Errorf("failed to query gift_card_transactions with reference: %v", err) } @@ -246,9 +254,10 @@ func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) { } func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + _, tx := testutils.SetupTestTx(t) - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } @@ -267,6 +276,8 @@ func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) { req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) + w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) @@ -279,11 +290,11 @@ func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) { } func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - ctx := context.Background() - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } @@ -291,14 +302,14 @@ func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) { adminToken := jwt.GenerateTestToken(adminID, "admin") // Create a test user to act as the redeemer - redeemerID, err := fixtures.CreateTestUser(db.DB) + redeemerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create redeemer user: %v", err) } // Insert a gift card that is already redeemed var cardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at) VALUES (50.00, 0.00, $1, $2, NOW()) RETURNING id @@ -321,6 +332,8 @@ func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) { req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) + w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) diff --git a/backend/handlers/portfolio/images_test.go b/backend/handlers/portfolio/images_test.go index 5129806..fb1f2be 100644 --- a/backend/handlers/portfolio/images_test.go +++ b/backend/handlers/portfolio/images_test.go @@ -27,7 +27,6 @@ import ( "strings" "testing" - "crussell/db" "crussell/testutils" "crussell/mw" @@ -35,35 +34,37 @@ import ( "github.com/kovidgoyal/imaging" ) -func makeRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { +func makeRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req = req.WithContext(ctx) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) + req = req.WithContext(ctx) } - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - return w + + return makeRequestWithContext(handler, method, path, body, "", "", ctx) } -func makeRequestWithContext(handler http.HandlerFunc, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder { +func makeRequestWithContext(handler http.HandlerFunc, method, path string, body interface{}, userID, role string, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req = req.WithContext(ctx) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) + req = req.WithContext(ctx) } // Add user context - ctx := req.Context() - ctx = context.WithValue(ctx, mw.UserIDKey, userID) - ctx = context.WithValue(ctx, mw.UserRoleKey, role) - req = req.WithContext(ctx) + chiCtx := context.WithValue(req.Context(), mw.UserIDKey, userID) + chiCtx = context.WithValue(chiCtx, mw.UserRoleKey, role) + req = req.WithContext(chiCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -76,10 +77,11 @@ func makeRequestWithContext(handler http.HandlerFunc, method, path string, body // TestPortfolio_ListImages verifies that listing portfolio images returns all images in the database. func TestPortfolio_ListImages(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test images - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest', 'color:green']), @@ -90,7 +92,7 @@ func TestPortfolio_ListImages(t *testing.T) { } handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -109,10 +111,11 @@ func TestPortfolio_ListImages(t *testing.T) { // TestPortfolio_ListImages_WithTagFilter verifies that images can be filtered by tag using the 'tag' query parameter. func TestPortfolio_ListImages_WithTagFilter(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test images - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']), @@ -123,7 +126,7 @@ func TestPortfolio_ListImages_WithTagFilter(t *testing.T) { } handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images?tag=forest", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?tag=forest", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -142,10 +145,11 @@ func TestPortfolio_ListImages_WithTagFilter(t *testing.T) { // TestPortfolio_ListImages_Empty verifies that an empty database returns an empty images array (not an error). func TestPortfolio_ListImages_Empty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -165,9 +169,10 @@ func TestPortfolio_ListImages_Empty(t *testing.T) { // TestPortfolio_ListImages_WithTagsFilter verifies the comma-separated `tags` // parameter, matching images whose tag_names contain any of the given values. func TestPortfolio_ListImages_WithTagsFilter(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest', 'color:green']), @@ -180,7 +185,7 @@ func TestPortfolio_ListImages_WithTagsFilter(t *testing.T) { handler := http.HandlerFunc(ListImages) // Match images tagged 'forest' OR 'ocean' - w := makeRequest(handler, "GET", "/api/portfolio/images?tags=forest,ocean", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?tags=forest,ocean", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -200,9 +205,10 @@ func TestPortfolio_ListImages_WithTagsFilter(t *testing.T) { // parameter, which narrows results to images whose tag_names include a // category:value combination. func TestPortfolio_ListImages_WithCategoryFilter(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['color:green', 'nature:forest']), @@ -214,7 +220,7 @@ func TestPortfolio_ListImages_WithCategoryFilter(t *testing.T) { } handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]=green", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]=green", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -233,9 +239,10 @@ func TestPortfolio_ListImages_WithCategoryFilter(t *testing.T) { // TestPortfolio_ListImages_WithCategoryAndTagFilter verifies that a category // filter can be combined with a single tag filter. func TestPortfolio_ListImages_WithCategoryAndTagFilter(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['color:green', 'nature:forest']), @@ -248,7 +255,7 @@ func TestPortfolio_ListImages_WithCategoryAndTagFilter(t *testing.T) { handler := http.HandlerFunc(ListImages) // Only images that are color:green AND match tag 'ocean' - w := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]=green&tag=ocean", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]=green&tag=ocean", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -268,10 +275,11 @@ func TestPortfolio_ListImages_WithCategoryAndTagFilter(t *testing.T) { // requesting a small limit returns the correct number of results, and // the response includes a next_cursor when more results are available. func TestPortfolio_ListImages_Pagination(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert 3 images with staggered created_at so ordering is deterministic - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, created_at) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['test'], '2025-01-03T00:00:00Z'), @@ -285,7 +293,7 @@ func TestPortfolio_ListImages_Pagination(t *testing.T) { handler := http.HandlerFunc(ListImages) // First page: limit=2 - w := makeRequest(handler, "GET", "/api/portfolio/images?limit=2", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?limit=2", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -302,7 +310,7 @@ func TestPortfolio_ListImages_Pagination(t *testing.T) { } // Second page: use cursor - w2 := makeRequest(handler, "GET", "/api/portfolio/images?limit=2&cursor="+*page1.NextCursor, nil) + w2 := makeRequest(handler, "GET", "/api/portfolio/images?limit=2&cursor="+*page1.NextCursor, nil, ctx) if w2.Code != http.StatusOK { t.Fatalf("expected 200 for page 2, got %d. body: %s", w2.Code, w2.Body.String()) } @@ -320,9 +328,10 @@ func TestPortfolio_ListImages_Pagination(t *testing.T) { // TestPortfolio_ListImages_NoMoreResults verifies that when all results fit // in one page, the cursor still exists but returns zero results on the next page. func TestPortfolio_ListImages_NoMoreResults(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['test']) @@ -332,7 +341,7 @@ func TestPortfolio_ListImages_NoMoreResults(t *testing.T) { } handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images?limit=5", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?limit=5", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -349,7 +358,7 @@ func TestPortfolio_ListImages_NoMoreResults(t *testing.T) { t.Fatal("expected next_cursor to be present, got nil") } - w2 := makeRequest(handler, "GET", "/api/portfolio/images?limit=5&cursor="+*page1.NextCursor, nil) + w2 := makeRequest(handler, "GET", "/api/portfolio/images?limit=5&cursor="+*page1.NextCursor, nil, ctx) if w2.Code != http.StatusOK { t.Fatalf("expected 200 for next page, got %d. body: %s", w2.Code, w2.Body.String()) } @@ -366,9 +375,10 @@ func TestPortfolio_ListImages_NoMoreResults(t *testing.T) { // TestPortfolio_ListImages_InputValidation verifies that requests exceeding // the maximum input length receive a 400 Bad Request. func TestPortfolio_ListImages_InputValidation(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img.jpg', 'https://example.com/img_thumb.jpg', ARRAY['color:red']) `) @@ -380,12 +390,12 @@ func TestPortfolio_ListImages_InputValidation(t *testing.T) { longTag := strings.Repeat("a", MaxInputLength+1) - w := makeRequest(handler, "GET", "/api/portfolio/images?tag="+longTag, nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?tag="+longTag, nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for too-long tag param, got %d", w.Code) } - w2 := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]="+longTag, nil) + w2 := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]="+longTag, nil, ctx) if w2.Code != http.StatusBadRequest { t.Errorf("expected 400 for too-long filter value, got %d", w2.Code) } @@ -397,10 +407,11 @@ func TestPortfolio_ListImages_InputValidation(t *testing.T) { // TestPortfolio_ListTags verifies that listing tags returns all unique tags from portfolio images. func TestPortfolio_ListTags(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test images with tag_names instead of directly into tags table - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']), @@ -412,7 +423,7 @@ func TestPortfolio_ListTags(t *testing.T) { } handler := http.HandlerFunc(ListTags) - w := makeRequest(handler, "GET", "/api/portfolio/tags", nil) + w := makeRequest(handler, "GET", "/api/portfolio/tags", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -430,10 +441,11 @@ func TestPortfolio_ListTags(t *testing.T) { // TestPortfolio_ListTags_WithQuery verifies that tags can be filtered by a query string. func TestPortfolio_ListTags_WithQuery(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test images with tag_names instead of directly into tags table - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']), @@ -445,7 +457,7 @@ func TestPortfolio_ListTags_WithQuery(t *testing.T) { } handler := http.HandlerFunc(ListTags) - w := makeRequest(handler, "GET", "/api/portfolio/tags?q=forest", nil) + w := makeRequest(handler, "GET", "/api/portfolio/tags?q=forest", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -463,10 +475,11 @@ func TestPortfolio_ListTags_WithQuery(t *testing.T) { // TestPortfolio_ListTags_Empty verifies that an empty database returns an empty tags array. func TestPortfolio_ListTags_Empty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(ListTags) - w := makeRequest(handler, "GET", "/api/portfolio/tags", nil) + w := makeRequest(handler, "GET", "/api/portfolio/tags", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -488,10 +501,11 @@ func TestPortfolio_ListTags_Empty(t *testing.T) { // TestPortfolio_ListFilters verifies that filter categories are derived from tags (e.g., 'nature', 'color' from 'nature:forest'). func TestPortfolio_ListFilters(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test images with tags - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest', 'color:green']), @@ -502,7 +516,7 @@ func TestPortfolio_ListFilters(t *testing.T) { } handler := http.HandlerFunc(ListFilters) - w := makeRequest(handler, "GET", "/api/portfolio/filters", nil) + w := makeRequest(handler, "GET", "/api/portfolio/filters", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -520,10 +534,11 @@ func TestPortfolio_ListFilters(t *testing.T) { // TestPortfolio_ListFilters_Empty verifies that an empty database returns an empty filters array. func TestPortfolio_ListFilters_Empty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(ListFilters) - w := makeRequest(handler, "GET", "/api/portfolio/filters", nil) + w := makeRequest(handler, "GET", "/api/portfolio/filters", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -545,7 +560,8 @@ func TestPortfolio_ListFilters_Empty(t *testing.T) { // TestPortfolio_GetImage verifies that a single image can be retrieved by its timestamp ID. func TestPortfolio_GetImage(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Use timestamp-based image URL (matches upload pattern: portfolio/{timestamp}.jpg) timestamp := "1234567890123456789" // 19 digits = valid nanosecond timestamp @@ -554,7 +570,7 @@ func TestPortfolio_GetImage(t *testing.T) { // Insert test image with timestamp-based URL var imageID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ($1, $2, ARRAY['nature:forest']) RETURNING id @@ -567,7 +583,7 @@ func TestPortfolio_GetImage(t *testing.T) { req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", timestamp) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetImage(w, req) @@ -588,10 +604,11 @@ func TestPortfolio_GetImage(t *testing.T) { // TestPortfolio_GetImage_NotFound verifies that requesting a non-existent image returns 404. func TestPortfolio_GetImage_NotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetImage) - w := makeRequest(handler, "GET", "/api/portfolio/images/nonexistent-id", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images/nonexistent-id", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) @@ -604,14 +621,15 @@ func TestPortfolio_GetImage_NotFound(t *testing.T) { // TestPortfolio_Upload_Admin verifies that an admin user passes the authentication check for image upload. func TestPortfolio_Upload_Admin(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) // Create a minimal S3 client mock by setting it to nil (handler will check and return error) // The handler requires S3 client, so we test the auth check first // Since S3 client setup is complex, we test that admin gets past auth check handler := http.HandlerFunc(UploadImage) - w := makeRequestWithContext(handler, "POST", "/api/portfolio/images", nil, "admin-001", "admin") + w := makeRequestWithContext(handler, "POST", "/api/portfolio/images", nil, "admin-001", "admin", ctx) // Should not get 403 (forbidden), will get another error due to missing S3 or file // The important thing is it's not 403 for admin @@ -622,10 +640,11 @@ func TestPortfolio_Upload_Admin(t *testing.T) { // TestPortfolio_Upload_NonAdmin verifies that non-admin users receive 403 Forbidden on image upload attempts. func TestPortfolio_Upload_NonAdmin(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) - w := makeRequestWithContext(handler, "POST", "/api/portfolio/images", nil, "user-001", "verified_email") + w := makeRequestWithContext(handler, "POST", "/api/portfolio/images", nil, "user-001", "verified_email", ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -634,10 +653,11 @@ func TestPortfolio_Upload_NonAdmin(t *testing.T) { // TestPortfolio_Upload_Unauthenticated verifies that unauthenticated requests receive 401 Unauthorized on image upload. func TestPortfolio_Upload_Unauthenticated(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) - w := makeRequest(handler, "POST", "/api/portfolio/images", nil) + w := makeRequest(handler, "POST", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) @@ -650,11 +670,12 @@ func TestPortfolio_Upload_Unauthenticated(t *testing.T) { // TestPortfolio_Delete_Admin verifies that an admin user passes the authentication check for image deletion. func TestPortfolio_Delete_Admin(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test image var imageID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']) RETURNING id @@ -664,7 +685,7 @@ func TestPortfolio_Delete_Admin(t *testing.T) { } handler := http.HandlerFunc(DeleteImage) - w := makeRequestWithContext(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, "admin-001", "admin") + w := makeRequestWithContext(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, "admin-001", "admin", ctx) // Should not get 403 (forbidden) - will get error due to S3 client being nil // but the important thing is admin auth passed @@ -675,11 +696,12 @@ func TestPortfolio_Delete_Admin(t *testing.T) { // TestPortfolio_Delete_NonAdmin verifies that non-admin users receive 403 Forbidden on image deletion attempts. func TestPortfolio_Delete_NonAdmin(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test image var imageID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']) RETURNING id @@ -689,7 +711,7 @@ func TestPortfolio_Delete_NonAdmin(t *testing.T) { } handler := http.HandlerFunc(DeleteImage) - w := makeRequestWithContext(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, "user-001", "verified_email") + w := makeRequestWithContext(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, "user-001", "verified_email", ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -698,11 +720,12 @@ func TestPortfolio_Delete_NonAdmin(t *testing.T) { // TestPortfolio_Delete_Unauthenticated verifies that unauthenticated requests receive 401 Unauthorized on image deletion. func TestPortfolio_Delete_Unauthenticated(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Insert test image var imageID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']) RETURNING id @@ -712,7 +735,7 @@ func TestPortfolio_Delete_Unauthenticated(t *testing.T) { } handler := http.HandlerFunc(DeleteImage) - w := makeRequest(handler, "DELETE", "/api/portfolio/images/"+imageID, nil) + w := makeRequest(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) @@ -924,7 +947,8 @@ func adminRequest(method, path string, body *bytes.Buffer, contentType string) * } func TestPortfolio_Upload_MissingFields(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + _, _ = testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) @@ -947,7 +971,8 @@ func TestPortfolio_Upload_MissingFields(t *testing.T) { } func TestPortfolio_Upload_InvalidFormat(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + _, _ = testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) @@ -975,9 +1000,10 @@ func TestPortfolio_Upload_InvalidFormat(t *testing.T) { } func TestPortfolio_ListImages_FormatURLs(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) @@ -991,7 +1017,7 @@ func TestPortfolio_ListImages_FormatURLs(t *testing.T) { } handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1032,9 +1058,10 @@ func TestPortfolio_ListImages_FormatURLs(t *testing.T) { } func TestPortfolio_ListImages_LegacyFallback(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['legacy']) `) @@ -1043,7 +1070,7 @@ func TestPortfolio_ListImages_LegacyFallback(t *testing.T) { } handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1069,13 +1096,14 @@ func TestPortfolio_ListImages_LegacyFallback(t *testing.T) { } func TestPortfolio_GetImage_FormatURLs(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) timestamp := "1234567890123456789" url := "https://example.com/portfolio/" + timestamp + ".avif" thumbURL := "https://example.com/portfolio/" + timestamp + "_thumb.webp" - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) @@ -1090,7 +1118,7 @@ func TestPortfolio_GetImage_FormatURLs(t *testing.T) { req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", timestamp) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetImage(w, req) @@ -1113,13 +1141,14 @@ func TestPortfolio_GetImage_FormatURLs(t *testing.T) { } func TestPortfolio_GetImage_LegacyFallback(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) timestamp := "1234567890123456789" url := "https://example.com/portfolio/" + timestamp + ".jpg" thumbURL := "https://example.com/portfolio/" + timestamp + "_thumb.jpg" - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ($1, $2, ARRAY['legacy']) `, url, thumbURL) @@ -1130,7 +1159,7 @@ func TestPortfolio_GetImage_LegacyFallback(t *testing.T) { req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", timestamp) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetImage(w, req) @@ -1150,10 +1179,11 @@ func TestPortfolio_GetImage_LegacyFallback(t *testing.T) { } func TestPortfolio_DeleteImage_MultiFormat(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) var imageID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) @@ -1169,10 +1199,10 @@ func TestPortfolio_DeleteImage_MultiFormat(t *testing.T) { req := httptest.NewRequest("DELETE", "/api/portfolio/images/"+imageID, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", imageID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, "admin-001") - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() DeleteImage(w, req) @@ -1185,7 +1215,7 @@ func TestPortfolio_DeleteImage_MultiFormat(t *testing.T) { } var count int - db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM images WHERE id = $1`, imageID).Scan(&count) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM images WHERE id = $1`, imageID).Scan(&count) if count != 1 { t.Log("image record preserved (S3 client nil in test env)") } @@ -1284,9 +1314,10 @@ func TestMimeTypeForField(t *testing.T) { } func TestPortfolio_ListImages_WithFormatFilter(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) @@ -1303,7 +1334,7 @@ func TestPortfolio_ListImages_WithFormatFilter(t *testing.T) { } handler := http.HandlerFunc(ListImages) - w := makeRequest(handler, "GET", "/api/portfolio/images?tag=forest", nil) + w := makeRequest(handler, "GET", "/api/portfolio/images?tag=forest", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1328,9 +1359,10 @@ func TestPortfolio_ListImages_WithFormatFilter(t *testing.T) { } func TestPortfolio_ListTags_WithMultiFormatImages(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) @@ -1344,7 +1376,7 @@ func TestPortfolio_ListTags_WithMultiFormatImages(t *testing.T) { } handler := http.HandlerFunc(ListTags) - w := makeRequest(handler, "GET", "/api/portfolio/tags", nil) + w := makeRequest(handler, "GET", "/api/portfolio/tags", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) @@ -1364,9 +1396,10 @@ func TestPortfolio_ListTags_WithMultiFormatImages(t *testing.T) { } func TestPortfolio_ListFilters_WithMultiFormatImages(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) @@ -1383,7 +1416,7 @@ func TestPortfolio_ListFilters_WithMultiFormatImages(t *testing.T) { } handler := http.HandlerFunc(ListFilters) - w := makeRequest(handler, "GET", "/api/portfolio/filters", nil) + w := makeRequest(handler, "GET", "/api/portfolio/filters", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/portfolio/testmain_test.go b/backend/handlers/portfolio/testmain_test.go index 6e99dbb..71a7551 100644 --- a/backend/handlers/portfolio/testmain_test.go +++ b/backend/handlers/portfolio/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_portfolio") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_portfolio") os.Exit(code) diff --git a/backend/handlers/scheduling/scheduling_test.go b/backend/handlers/scheduling/scheduling_test.go index ab08a5b..ff78383 100644 --- a/backend/handlers/scheduling/scheduling_test.go +++ b/backend/handlers/scheduling/scheduling_test.go @@ -28,50 +28,17 @@ import ( "crussell/db" "crussell/mw" + "crussell/testutils" "crussell/testutils/jwt" - "crussell/testutils/testdb" ) -func resetTestData(t *testing.T) { +func resetTestData(t *testing.T) (context.Context, db.Querier) { t.Helper() - testdb.TruncateTables(t, db.DB) - // Also truncate financial_aggregates which is not in the default truncation list - db.DB.Exec(context.Background(), "TRUNCATE financial_aggregates CASCADE") - seedDefaultWorkingHours(t) + ctx, tx := testutils.SetupTestTx(t) + return ctx, tx } -func seedDefaultWorkingHours(t *testing.T) { - t.Helper() - - // Seed 7 days of working hours (Monday=0 to Sunday=6) - hours := []struct { - weekday int - startTime string - endTime string - isOpen bool - }{ - {0, "09:00", "17:00", true}, // Monday - {1, "09:00", "17:00", true}, // Tuesday - {2, "09:00", "17:00", true}, // Wednesday - {3, "09:00", "17:00", true}, // Thursday - {4, "09:00", "17:00", true}, // Friday - {5, "10:00", "16:00", true}, // Saturday - {6, "00:00", "00:00", false}, // 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) - } - } -} - -func makeRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { +func makeRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -80,12 +47,13 @@ func makeRequest(handler http.HandlerFunc, method, path string, body interface{} } else { req = httptest.NewRequest(method, path, nil) } + req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } -func makeAuthRequest(handler http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder { +func makeAuthRequest(handler http.Handler, method, path, token string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -97,6 +65,7 @@ func makeAuthRequest(handler http.Handler, method, path, token string, body inte if token != "" { req.Header.Set("Authorization", "Bearer "+token) } + req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w @@ -108,10 +77,11 @@ func makeAuthRequest(handler http.Handler, method, path, token string, body inte // can be retrieved. The test checks that all 7 days are returned with correct // opening times, closing times, and is_open status. func TestScheduling_GetDefaultHours(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(GetDefaultHours) - w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil) + w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -155,7 +125,8 @@ func TestScheduling_GetDefaultHours(t *testing.T) { // the default weekly working hours. The new schedule is persisted to the // database and returned on subsequent requests. func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) adminToken := jwt.GenerateAdminToken() handler := http.HandlerFunc(UpdateDefaultHours) @@ -170,7 +141,7 @@ func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) { {Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false}, } - w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, newHours) + w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, newHours, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -178,7 +149,7 @@ func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) { // Verify the update persisted var hours []DefaultHours - rows, err := db.DB.Query(context.Background(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours ORDER BY weekday`) + rows, err := tx.Query(ctx, `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours ORDER BY weekday`) if err != nil { t.Fatalf("failed to query hours: %v", err) } @@ -200,7 +171,8 @@ func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) { // TestScheduling_UpdateDefaultHours_NonAdmin verifies that non-admin users // receive HTTP 403 Forbidden when attempting to update default hours. func TestScheduling_UpdateDefaultHours_NonAdmin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) userToken := jwt.GenerateUserToken("user-123") @@ -215,7 +187,7 @@ func TestScheduling_UpdateDefaultHours_NonAdmin(t *testing.T) { } // Wrap handler with RequireAuth + RequireAdmin middleware (auth first to populate context) - w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateDefaultHours))), "PUT", "/api/scheduling/default-hours", userToken, newHours) + w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateDefaultHours))), "PUT", "/api/scheduling/default-hours", userToken, newHours, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -227,10 +199,11 @@ func TestScheduling_UpdateDefaultHours_NonAdmin(t *testing.T) { // TestScheduling_ListExceptionalGroups verifies that admins can list all // exceptional working hours groups (holidays, special events). func TestScheduling_ListExceptionalGroups(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) // Create an exceptional group - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Holiday Hours', 'Christmas holiday schedule') `) @@ -239,7 +212,7 @@ func TestScheduling_ListExceptionalGroups(t *testing.T) { } handler := http.HandlerFunc(ListExceptionalGroups) - w := makeRequest(handler, "GET", "/api/scheduling/exceptional-groups", nil) + w := makeRequest(handler, "GET", "/api/scheduling/exceptional-groups", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -264,7 +237,8 @@ func TestScheduling_ListExceptionalGroups(t *testing.T) { // TestScheduling_CreateExceptionalGroup_Admin tests that an admin can // create a new exceptional working hours group with specific hours for each day. func TestScheduling_CreateExceptionalGroup_Admin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) adminToken := jwt.GenerateAdminToken() handler := http.HandlerFunc(CreateExceptionalGroup) @@ -284,7 +258,7 @@ func TestScheduling_CreateExceptionalGroup_Admin(t *testing.T) { WeekStarts: []string{"2026-06-01"}, } - w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, newGroup) + w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, newGroup, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -306,7 +280,8 @@ func TestScheduling_CreateExceptionalGroup_Admin(t *testing.T) { // TestScheduling_CreateExceptionalGroup_NonAdmin verifies that non-admin // users receive HTTP 403 when attempting to create exceptional groups. func TestScheduling_CreateExceptionalGroup_NonAdmin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) userToken := jwt.GenerateUserToken("user-123") @@ -325,7 +300,7 @@ func TestScheduling_CreateExceptionalGroup_NonAdmin(t *testing.T) { WeekStarts: []string{"2026-06-01"}, } - w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(CreateExceptionalGroup))), "POST", "/api/scheduling/exceptional-groups", userToken, newGroup) + w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(CreateExceptionalGroup))), "POST", "/api/scheduling/exceptional-groups", userToken, newGroup, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -363,8 +338,9 @@ func TestScheduling_UpdateDefaultHours_InvalidTimes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resetTestData(t) - w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, tt.hours) + t.Parallel() + ctx, _ := resetTestData(t) + w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, tt.hours, ctx) if w.Code != tt.wantStatus { t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String()) } @@ -418,7 +394,8 @@ func TestScheduling_CreateExceptionalGroup_InvalidTimes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) hours := tt.modify(baseHours()) group := ExceptionalGroup{ Name: "Test Group", @@ -426,7 +403,7 @@ func TestScheduling_CreateExceptionalGroup_InvalidTimes(t *testing.T) { Hours: hours, WeekStarts: []string{"2026-06-01"}, } - w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, group) + w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, group, ctx) if w.Code != tt.wantStatus { t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String()) } @@ -440,13 +417,14 @@ func TestScheduling_CreateExceptionalGroup_InvalidTimes(t *testing.T) { // an exceptional working hours group. This removes the group and its associated // hours from the system. func TestScheduling_DeleteExceptionalGroup_Admin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) adminToken := jwt.GenerateAdminToken() // Create a group to delete var groupID int - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('To Delete', 'Will be deleted') RETURNING id @@ -459,6 +437,7 @@ func TestScheduling_DeleteExceptionalGroup_Admin(t *testing.T) { // Use proper URL query with strconv.Itoa req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id="+strconv.Itoa(groupID), nil) req.Header.Set("Authorization", "Bearer "+adminToken) + req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -468,7 +447,7 @@ func TestScheduling_DeleteExceptionalGroup_Admin(t *testing.T) { // Verify group was deleted var count int - err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM exceptional_working_hours_groups WHERE id = $1`, groupID).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM exceptional_working_hours_groups WHERE id = $1`, groupID).Scan(&count) if err != nil { t.Fatalf("failed to check group: %v", err) } @@ -480,7 +459,8 @@ func TestScheduling_DeleteExceptionalGroup_Admin(t *testing.T) { // TestScheduling_DeleteExceptionalGroup_NonAdmin verifies that non-admin // users receive HTTP 403 when attempting to delete exceptional groups. func TestScheduling_DeleteExceptionalGroup_NonAdmin(t *testing.T) { - resetTestData(t) + t.Parallel() + _, _ = resetTestData(t) userToken := jwt.GenerateUserToken("user-123") @@ -501,7 +481,8 @@ func TestScheduling_DeleteExceptionalGroup_NonAdmin(t *testing.T) { // retrieved for a given date range. The response includes whether hours come // from default schedule or exceptional groups. func TestScheduling_GetWorkingHours(t *testing.T) { - resetTestData(t) + t.Parallel() + _, _ = resetTestData(t) handler := http.HandlerFunc(GetWorkingHours) req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22", nil) @@ -536,7 +517,8 @@ func TestScheduling_GetWorkingHours(t *testing.T) { // slots can be calculated for a date range based on working hours and service // durations. func TestScheduling_GetAvailableHours(t *testing.T) { - resetTestData(t) + t.Parallel() + _, _ = resetTestData(t) handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22", nil) @@ -573,13 +555,14 @@ func TestScheduling_GetAvailableHours(t *testing.T) { // admin can apply an exceptional hours group to specific weeks, activating // holiday schedules for those periods. func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) adminToken := jwt.GenerateAdminToken() // Create a group var groupID int - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Test Group', 'Test') RETURNING id @@ -595,7 +578,7 @@ func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) { "weekStarts": []string{"2026-03-02", "2026-03-09"}, } - w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", adminToken, reqBody) + w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", adminToken, reqBody, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) @@ -603,7 +586,7 @@ func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) { // Verify applications were created var count int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM exceptional_group_applications WHERE group_id = $1 `, groupID).Scan(&count) if err != nil { @@ -617,7 +600,8 @@ func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) { // TestScheduling_UpdateExceptionalApplications_NonAdmin verifies that // non-admin users receive HTTP 403 when attempting to apply exceptional hours. func TestScheduling_UpdateExceptionalApplications_NonAdmin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) userToken := jwt.GenerateUserToken("user-123") handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateExceptionalApplications))) @@ -627,7 +611,7 @@ func TestScheduling_UpdateExceptionalApplications_NonAdmin(t *testing.T) { "weekStarts": []string{"2026-03-02"}, } - w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", userToken, reqBody) + w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", userToken, reqBody, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) @@ -641,12 +625,13 @@ func TestScheduling_UpdateExceptionalApplications_NonAdmin(t *testing.T) { // TestScheduling_GetAvailableHours_WithBlocker_NonAdmin verifies that non-admin // users do NOT see blocked time slots in their available hours. func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) // Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day) ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL) `, blockerTime) @@ -659,9 +644,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) { req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil) // Set non-admin context - ctx := context.WithValue(req.Context(), mw.UserIDKey, "user001") - ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -708,12 +693,13 @@ 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) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) // Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day) ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL) `, blockerTime) @@ -726,9 +712,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) { req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil) // Set admin context - ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001") - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") - req = req.WithContext(ctx) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -782,6 +768,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) { // TestIsValidTime15Min tests the time validation helper directly for all // supported formats (HH:MM, HH:MM:SS) and edge cases. func TestIsValidTime15Min(t *testing.T) { + t.Parallel() tests := []struct { name string time string diff --git a/backend/handlers/scheduling/testmain_test.go b/backend/handlers/scheduling/testmain_test.go index 1952515..e253dfa 100644 --- a/backend/handlers/scheduling/testmain_test.go +++ b/backend/handlers/scheduling/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_scheduling") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaselineScheduling(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_scheduling") os.Exit(code) diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 6e4aa87..ed1c1a9 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -26,14 +26,13 @@ import ( "testing" "time" - "crussell/db" "crussell/mw" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" ) -func makeTimeBlockerRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { +func makeTimeBlockerRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -42,12 +41,13 @@ func makeTimeBlockerRequest(handler http.HandlerFunc, method, path string, body } else { req = httptest.NewRequest(method, path, nil) } + req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } -func makeTimeBlockerAuthRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { +func makeTimeBlockerAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -57,9 +57,9 @@ func makeTimeBlockerAuthRequest(handler http.HandlerFunc, method, path string, b req = httptest.NewRequest(method, path, nil) } - // Add admin context (no user ID - created_by will be NULL) - ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") - req = req.WithContext(ctx) + // Layer admin context on top of test transaction context + chiCtx := context.WithValue(ctx, mw.UserRoleKey, "admin") + req = req.WithContext(chiCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -71,13 +71,14 @@ func makeTimeBlockerAuthRequest(handler http.HandlerFunc, method, path string, b // TestTimeBlockers_List verifies that all time blockers can be listed. // Returns 200 OK with an array of blockers. func TestTimeBlockers_List(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") 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(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Blocker 1', NULL), ($2, 30, 'Blocker 2', NULL) @@ -87,7 +88,7 @@ func TestTimeBlockers_List(t *testing.T) { } handler := http.HandlerFunc(ListTimeBlockers) - w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers", nil) + w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -106,7 +107,8 @@ func TestTimeBlockers_List(t *testing.T) { // TestTimeBlockers_ListWithDateFilter verifies that time blockers can be // filtered by start/end query parameters. func TestTimeBlockers_ListWithDateFilter(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") // Create blockers on different dates @@ -114,7 +116,7 @@ func TestTimeBlockers_ListWithDateFilter(t *testing.T) { blockerTime2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation) // Out of range blockerTime3 := time.Date(2026, 3, 12, 9, 0, 0, 0, ukLocation) // In range - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'In Range 1', NULL), ($2, 30, 'Out of Range', NULL), @@ -124,7 +126,7 @@ func TestTimeBlockers_ListWithDateFilter(t *testing.T) { t.Fatalf("failed to create time blockers: %v", err) } handler := http.HandlerFunc(ListTimeBlockers) - w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2026-03-10&end=2026-03-13", nil) + w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2026-03-10&end=2026-03-13", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -145,7 +147,8 @@ func TestTimeBlockers_ListWithDateFilter(t *testing.T) { // TestTimeBlockers_Create verifies that an admin can create a new time blocker. func TestTimeBlockers_Create(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, ukLocation) @@ -157,7 +160,7 @@ func TestTimeBlockers_Create(t *testing.T) { } handler := http.HandlerFunc(CreateTimeBlocker) - w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody) + w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) @@ -178,7 +181,7 @@ func TestTimeBlockers_Create(t *testing.T) { // Verify it exists in DB var count int - err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, response.ID).Scan(&count) + err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, response.ID).Scan(&count) if err != nil { t.Fatalf("failed to verify blocker in DB: %v", err) } @@ -190,7 +193,8 @@ func TestTimeBlockers_Create(t *testing.T) { // TestTimeBlockers_Create_ValidationErrors verifies that missing or invalid // fields result in 400 Bad Request. func TestTimeBlockers_Create_ValidationErrors(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) handler := http.HandlerFunc(CreateTimeBlocker) @@ -199,7 +203,7 @@ func TestTimeBlockers_Create_ValidationErrors(t *testing.T) { "duration_minutes": 60, "description": "Test", } - w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody1) + w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody1, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String()) } @@ -211,7 +215,7 @@ func TestTimeBlockers_Create_ValidationErrors(t *testing.T) { "start_time": blockerTime, "description": "Test", } - w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody2) + w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody2, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing duration_minutes, got %d. body: %s", w.Code, w.Body.String()) } @@ -222,7 +226,7 @@ func TestTimeBlockers_Create_ValidationErrors(t *testing.T) { "duration_minutes": 0, "description": "Test", } - w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody3) + w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody3, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for zero duration_minutes, got %d. body: %s", w.Code, w.Body.String()) } @@ -233,7 +237,7 @@ func TestTimeBlockers_Create_ValidationErrors(t *testing.T) { "duration_minutes": -10, "description": "Test", } - w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody4) + w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody4, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for negative duration_minutes, got %d. body: %s", w.Code, w.Body.String()) } @@ -243,14 +247,15 @@ func TestTimeBlockers_Create_ValidationErrors(t *testing.T) { // TestTimeBlockers_Delete verifies that an admin can delete a time blocker. func TestTimeBlockers_Delete(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2026, 3, 25, 10, 0, 0, 0, ukLocation) // Create a blocker to delete var blockerID string - err := db.DB.QueryRow(context.Background(), ` + err := tx.QueryRow(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'To be deleted', NULL) RETURNING id @@ -262,15 +267,15 @@ func TestTimeBlockers_Delete(t *testing.T) { // Set up chi router for URL param r := chi.NewRouter() r.Delete("/api/admin/time-blockers/{id}", DeleteTimeBlocker) - // Create request with chi context req := httptest.NewRequest("DELETE", "/api/admin/time-blockers/"+blockerID, nil) - ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001") - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", blockerID) - ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) - req = req.WithContext(ctx) + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) + + req = req.WithContext(reqCtx) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -281,7 +286,7 @@ func TestTimeBlockers_Delete(t *testing.T) { // Verify blocker was deleted var count int - err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, blockerID).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, blockerID).Scan(&count) if err != nil { t.Fatalf("failed to check blocker: %v", err) } @@ -293,7 +298,8 @@ func TestTimeBlockers_Delete(t *testing.T) { // TestTimeBlockers_Delete_NotFound verifies that attempting to delete a // non-existent blocker returns 404 Not Found. func TestTimeBlockers_Delete_NotFound(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) // Set up chi router for URL param r := chi.NewRouter() @@ -301,12 +307,12 @@ func TestTimeBlockers_Delete_NotFound(t *testing.T) { // Create request with non-existent ID req := httptest.NewRequest("DELETE", "/api/admin/time-blockers/nonexistent-id", nil) - ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001") - ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "nonexistent-id") - ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) - req = req.WithContext(ctx) + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) + req = req.WithContext(reqCtx) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -321,13 +327,14 @@ func TestTimeBlockers_Delete_NotFound(t *testing.T) { // TestCheckTimeBlockerOverlap verifies that the overlap detection function // correctly identifies overlapping time ranges. func TestCheckTimeBlockerOverlap(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") // Create blocker for 10:00-11:00 (60 minutes) blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Existing blocker', NULL) `, blockerTime) @@ -335,8 +342,6 @@ func TestCheckTimeBlockerOverlap(t *testing.T) { t.Fatalf("failed to create blocker: %v", err) } - ctx := context.Background() - // Test case 1: Exact overlap (10:00-11:00) hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx, time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation), @@ -412,7 +417,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) { // TestGetTimeBlockersInRange verifies that blockers can be retrieved // for a specific date range. func TestGetTimeBlockersInRange(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") // Create blockers on different dates @@ -420,7 +426,7 @@ func TestGetTimeBlockersInRange(t *testing.T) { blocker2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation) blocker3 := time.Date(2026, 3, 20, 9, 0, 0, 0, ukLocation) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Day 10', NULL), ($2, 30, 'Day 15', NULL), @@ -430,7 +436,6 @@ func TestGetTimeBlockersInRange(t *testing.T) { t.Fatalf("failed to create blockers: %v", err) } - ctx := context.Background() // Query range that includes blocker1 and blocker2 but not blocker3 start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation) @@ -466,13 +471,14 @@ func TestGetTimeBlockersInRange(t *testing.T) { // TestGetTimeBlockersInRange_Empty verifies that an empty array is // returned when no blockers exist in the range. func TestGetTimeBlockersInRange_Empty(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") // Create a blocker blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'March 15', NULL) `, blockerTime) @@ -480,7 +486,6 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) { t.Fatalf("failed to create blocker: %v", err) } - ctx := context.Background() // Query range with no blockers start := time.Date(2026, 4, 1, 0, 0, 0, 0, ukLocation) @@ -499,7 +504,8 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) { // TestGetTimeBlockersInRange_IncludesRecurring verifies that recurring blockers // are expanded to actual occurrences within the query range. func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") // Create one-off blocker for March 15 @@ -507,7 +513,7 @@ func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) { // Cron: every Monday at 10:00 (0 10 * * 1) cronExpr := "0 10 * * 1" - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) VALUES ($1, 60, 'One-off', NULL, NULL), ($2, 60, 'Recurring Monday', $3, NULL) @@ -516,7 +522,6 @@ func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) { t.Fatalf("failed to create blockers: %v", err) } - ctx := context.Background() // Query range: March 1-31, 2026 start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation) @@ -559,25 +564,25 @@ func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) { // TestCleanupOldReservations verifies that reservation blockers older than 1 hour // are automatically deleted, while recent ones are kept. func TestCleanupOldReservations(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create fixture users for the test - oldUserID, err := fixtures.CreateTestUser(db.DB) + oldUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create old user: %v", err) } - recentUserID, err := fixtures.CreateTestUser(db.DB) + recentUserID, err := fixtures.CreateTestUser(tx) 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) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, $2, $3)`, oldTime, fmt.Sprintf("RESERVATION:user:%s:%d", oldUserID, time.Now().UnixNano()), oldUserID) if err != nil { @@ -585,14 +590,14 @@ func TestCleanupOldReservations(t *testing.T) { } // 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:user:%' AND start_time = $2`, time.Now().Add(-2*time.Hour), oldTime) + _, err = tx.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' 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) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, $2, $3)`, recentTime, fmt.Sprintf("RESERVATION:user:%s:%d", recentUserID, time.Now().UnixNano()), recentUserID) if err != nil { @@ -600,14 +605,14 @@ func TestCleanupOldReservations(t *testing.T) { } // 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:user:%' AND start_time = $2`, time.Now().Add(-30*time.Minute), recentTime) + _, err = tx.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' 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, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, $2, NULL)`, nonResTime, "Admin Blocked Time") if err != nil { @@ -616,7 +621,7 @@ func TestCleanupOldReservations(t *testing.T) { // Verify we have 3 blockers before cleanup var countBefore int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countBefore) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countBefore) if err != nil { t.Fatalf("failed to count blockers before cleanup: %v", err) } @@ -632,28 +637,28 @@ func TestCleanupOldReservations(t *testing.T) { // Verify old reservation was deleted var oldCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", oldTime).Scan(&oldCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", oldTime).Scan(&oldCount) if err == nil && oldCount > 0 { t.Error("expected old reservation to be deleted") } // Verify recent reservation still exists var recentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", recentTime).Scan(&recentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", recentTime).Scan(&recentCount) if err != nil || recentCount == 0 { t.Error("expected recent reservation to still exist") } // Verify non-reservation blocker still exists var nonResCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'Admin Blocked Time'").Scan(&nonResCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'Admin Blocked Time'").Scan(&nonResCount) if err != nil || nonResCount == 0 { t.Error("expected non-reservation blocker to still exist") } // Verify final count (should be 2: recent reservation + non-reservation blocker) var countAfter int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countAfter) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countAfter) if err != nil { t.Fatalf("failed to count blockers after cleanup: %v", err) } @@ -667,14 +672,14 @@ func TestCleanupOldReservations(t *testing.T) { // TestCleanupOldReservations_AdminWalkIn verifies that admin walk-in reservations // older than 15 minutes are deleted, while recent ones are preserved. func TestCleanupOldReservations_AdminWalkIn(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old walk-in reservation (>15 min old) oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation) - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:123', $2) `, oldTime, time.Now().Add(-16*time.Minute)) @@ -684,7 +689,7 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) { // Create recent walk-in reservation (<15 min old) recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:456', $2) `, recentTime, time.Now().Add(-14*time.Minute)) @@ -700,7 +705,7 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) { // Verify old reservation was deleted var oldCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:123'").Scan(&oldCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old reservation: %v", err) } @@ -710,7 +715,7 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) { // Verify recent reservation still exists var recentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:456'").Scan(&recentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent reservation: %v", err) } @@ -724,14 +729,14 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) { // TestCleanupOldReservations_AdminCallIn verifies that admin call-in reservations // older than 15 minutes are deleted, while recent ones are preserved. func TestCleanupOldReservations_AdminCallIn(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old call-in reservation (>15 min old) oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation) - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:guest:123', $2) `, oldTime, time.Now().Add(-16*time.Minute)) @@ -741,7 +746,7 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) { // Create recent call-in reservation (<15 min old) recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:guest:456', $2) `, recentTime, time.Now().Add(-14*time.Minute)) @@ -757,7 +762,7 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) { // Verify old reservation was deleted var oldCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:123'").Scan(&oldCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old reservation: %v", err) } @@ -767,7 +772,7 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) { // Verify recent reservation still exists var recentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:456'").Scan(&recentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent reservation: %v", err) } @@ -781,14 +786,14 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) { // TestCleanupOldReservations_MixedTypes verifies that cleanup correctly handles // all reservation types with their respective TTLs. func TestCleanupOldReservations_MixedTypes(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old user reservation (>1 hour old) oldUserTime := time.Now().Add(-2 * time.Hour).In(ukLocation) - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:user:old', $2) `, oldUserTime, time.Now().Add(-2*time.Hour)) @@ -798,7 +803,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Create recent user reservation (<1 hour old) recentUserTime := time.Now().Add(-30 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:user:recent', $2) `, recentUserTime, time.Now().Add(-30*time.Minute)) @@ -808,7 +813,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Create old anon reservation (>10 min old) oldAnonTime := time.Now().Add(-15 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:anon:old', $2) `, oldAnonTime, time.Now().Add(-15*time.Minute)) @@ -818,7 +823,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Create recent anon reservation (<10 min old) recentAnonTime := time.Now().Add(-5 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:anon:recent', $2) `, recentAnonTime, time.Now().Add(-5*time.Minute)) @@ -828,7 +833,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Create old admin walk-in reservation (>15 min old) oldWalkinTime := time.Now().Add(-20 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:old', $2) `, oldWalkinTime, time.Now().Add(-20*time.Minute)) @@ -838,7 +843,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Create recent admin walk-in reservation (<15 min old) recentWalkinTime := time.Now().Add(-10 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:recent', $2) `, recentWalkinTime, time.Now().Add(-10*time.Minute)) @@ -848,7 +853,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Create old admin call-in reservation (>15 min old) oldCallinTime := time.Now().Add(-20 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:old', $2) `, oldCallinTime, time.Now().Add(-20*time.Minute)) @@ -858,7 +863,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Create recent admin call-in reservation (<15 min old) recentCallinTime := time.Now().Add(-10 * time.Minute).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:recent', $2) `, recentCallinTime, time.Now().Add(-10*time.Minute)) @@ -868,7 +873,7 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Verify we have 8 reservations before cleanup var countBefore int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%'").Scan(&countBefore) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%'").Scan(&countBefore) if err != nil { t.Fatalf("failed to count reservations before cleanup: %v", err) } @@ -884,10 +889,10 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Verify old reservations were deleted (4 old ones) var oldUserCount, oldAnonCount, oldWalkinCount, oldCallinCount int - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount) - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount) - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount) - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:old'").Scan(&oldCallinCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:old'").Scan(&oldCallinCount) if oldUserCount != 0 { t.Error("expected old user reservation to be deleted") @@ -904,10 +909,10 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // Verify recent reservations still exist (4 recent ones) var recentUserCount, recentAnonCount, recentWalkinCount, recentCallinCount int - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount) - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount) - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount) - db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:recent'").Scan(&recentCallinCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount) + tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:recent'").Scan(&recentCallinCount) if recentUserCount != 1 { t.Error("expected recent user reservation to be preserved") @@ -928,15 +933,15 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) { // TestGetTimeBlockersInRange_ExcludesReservations verifies that reservation // blockers are excluded from the results. func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create a regular blocker for tomorrow at 10:00 tomorrow := time.Now().Add(24 * time.Hour).In(ukLocation) blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, ukLocation) - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, blockerTime) @@ -946,7 +951,7 @@ func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) { // Create a reservation for tomorrow at 11:00 reservationTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 11, 0, 0, 0, ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:abc:123', NULL) `, reservationTime) @@ -979,26 +984,27 @@ func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) { // TestAnonymizeStaleGuestAccounts_Exactly6Months verifies that a guest with // a booking exactly 6 months ago is anonymized. func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Create guest user - guestID, err := fixtures.CreateTestUser(db.DB) + guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest - _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) + _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } - // Create booking with start_time exactly 6 months ago - _, err = db.DB.Exec(ctx, ` + // Create booking with start_time more than 6 months ago + // (strictly less than NOW() - INTERVAL '6 months' per the SQL condition) + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) - VALUES ($1, NOW() - INTERVAL '6 months', 'completed', false) + VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false) `, guestID) if err != nil { t.Fatalf("failed to create booking: %v", err) @@ -1012,7 +1018,7 @@ func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) { // Verify guest was anonymized var firstName, lastName, email string - err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name, email FROM users WHERE id = $1`, guestID).Scan(&firstName, &lastName, &email) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name, email FROM users WHERE id = $1`, guestID).Scan(&firstName, &lastName, &email) if err != nil { t.Fatalf("failed to query anonymized user: %v", err) } @@ -1031,24 +1037,24 @@ func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) { // TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped verifies that a guest // with an active (future) booking is NOT anonymized even if they have a past booking. func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Create guest user - guestID, err := fixtures.CreateTestUser(db.DB) + guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest - _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) + _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } // Create past booking (7 months ago) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false) `, guestID) @@ -1058,7 +1064,7 @@ func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) { // Create active booking (tomorrow) tomorrow := time.Now().Add(24 * time.Hour) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, guestID, tomorrow) @@ -1074,7 +1080,7 @@ func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) { // Verify guest was NOT anonymized var firstName string - err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName) + err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName) if err != nil { t.Fatalf("failed to query user: %v", err) } @@ -1088,18 +1094,18 @@ func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) { // TestAnonymizeStaleGuestAccounts_NoBookings verifies that a guest with // no bookings is NOT anonymized. func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Create guest user with no bookings - guestID, err := fixtures.CreateTestUser(db.DB) + guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest - _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) + _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } @@ -1112,7 +1118,7 @@ func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) { // Verify guest was NOT anonymized var firstName string - err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName) + err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName) if err != nil { t.Fatalf("failed to query user: %v", err) } @@ -1128,27 +1134,27 @@ func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) { // TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a // payment older than 7 years is aggregated and deleted. func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } eightYearsAgo := time.Now().AddDate(-8, 0, 0) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 50.00, $2) `, bookingID, eightYearsAgo) @@ -1164,7 +1170,7 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) { // Verify payment was deleted var paymentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } @@ -1174,7 +1180,7 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) { // Verify financial_aggregates has 1 row var aggCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } @@ -1186,7 +1192,7 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) { expectedMonth := time.Date(eightYearsAgo.Year(), eightYearsAgo.Month(), 1, 0, 0, 0, 0, time.UTC) var aggMonth time.Time var totalPayments float64 - err = db.DB.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth, &totalPayments) + err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth, &totalPayments) if err != nil { t.Fatalf("failed to query aggregate: %v", err) } @@ -1202,16 +1208,16 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) { // an anonymized user's payment is NOT deleted when the 1-year buffer hasn't // elapsed, even if the payment is older than 7 years. func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - guestID, err := fixtures.CreateTestUser(db.DB) + guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', @@ -1222,19 +1228,19 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) t.Fatalf("failed to anonymize user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, guestID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, guestID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } fourYearsAgo := time.Now().AddDate(-4, 0, 0) var paymentID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 75.00, $2) RETURNING id @@ -1251,7 +1257,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) // Verify payment still exists (neither 7yr nor 1yr conditions satisfied) var paymentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } @@ -1261,7 +1267,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) // Verify no aggregate was created var aggCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } @@ -1273,28 +1279,28 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) // TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years verifies that a // payment older than 9 years is always deleted regardless of user status. func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Payment created 9 years ago nineYearsAgo := time.Now().AddDate(-9, 0, 0) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 60.00, $2) `, bookingID, nineYearsAgo) @@ -1310,7 +1316,7 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) { // Verify payment was deleted var paymentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } @@ -1320,7 +1326,7 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) { // Verify financial_aggregates has 1 row var aggCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } @@ -1332,21 +1338,21 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) { // TestCleanupExpiredFinancialRecords_AggregationCorrectTotals verifies that // multiple payments in the same month are correctly aggregated by method and type. func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } @@ -1356,7 +1362,7 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) { _ = sameMonth // used for all payments // Payment 1: £50 cash - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at) VALUES ($1, 'full', 'cash', 'completed', 50.00, 0, $2) `, bookingID, sameMonth) @@ -1365,7 +1371,7 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) { } // Payment 2: £30 online_square with £2.50 fees - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at) VALUES ($1, 'full', 'online_square', 'completed', 30.00, 2.50, $2) `, bookingID, sameMonth) @@ -1374,7 +1380,7 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) { } // Payment 3: £20 in_person_card - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at) VALUES ($1, 'full', 'in_person_card', 'completed', 20.00, 0, $2) `, bookingID, sameMonth) @@ -1391,7 +1397,7 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) { // Verify aggregate totals var totalPayments, totalCash, totalOnline, totalInPerson, totalSquareFees float64 var bookingCount int - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` SELECT total_payments, total_cash, total_online, total_in_person, total_square_fees, booking_count FROM financial_aggregates `).Scan(&totalPayments, &totalCash, &totalOnline, &totalInPerson, &totalSquareFees, &bookingCount) @@ -1422,28 +1428,28 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) { // TestCleanupExpiredFinancialRecords_Idempotent verifies that running the // cleanup function twice produces the same result (no double-counting). func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Payment 8 years ago eightYearsAgo := time.Now().AddDate(-8, 0, 0) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 100.00, $2) `, bookingID, eightYearsAgo) @@ -1459,13 +1465,13 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { // Capture aggregate values after first run var aggCount1 int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount1) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount1) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } var totalPayments1 float64 var aggMonth1 time.Time - err = db.DB.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth1, &totalPayments1) + err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth1, &totalPayments1) if err != nil { t.Fatalf("failed to query aggregate after first run: %v", err) } @@ -1478,7 +1484,7 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { // Verify same aggregate count and values var aggCount2 int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount2) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount2) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } @@ -1488,7 +1494,7 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { var totalPayments2 float64 var aggMonth2 time.Time - err = db.DB.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth2, &totalPayments2) + err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth2, &totalPayments2) if err != nil { t.Fatalf("failed to query aggregate after second run: %v", err) } @@ -1501,7 +1507,7 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { // Verify payments are still deleted var paymentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } @@ -1513,21 +1519,21 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) { // TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years verifies that // a payment newer than 7 years for an active user is NOT deleted. func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } @@ -1535,7 +1541,7 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) { // Payment created 3 years ago (< 7 years) threeYearsAgo := time.Now().AddDate(-3, 0, 0) var paymentID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 40.00, $2) RETURNING id @@ -1552,7 +1558,7 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) { // Verify payment still exists var paymentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } @@ -1562,7 +1568,7 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) { // Verify no aggregate was created var aggCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } @@ -1575,17 +1581,17 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) { // an anonymized user's payment IS deleted when BOTH the 7-year rule AND the // 1-year post-anonymization buffer have elapsed. func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - guestID, err := fixtures.CreateTestUser(db.DB) + guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Anonymize user 2 years ago - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', @@ -1596,12 +1602,12 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing t.Fatalf("failed to anonymize user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, guestID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, guestID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } @@ -1611,7 +1617,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing // Both conditions met → should be deleted eightYearsAgo := time.Now().AddDate(-8, 0, 0) var paymentID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 75.00, $2) RETURNING id @@ -1628,7 +1634,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing // Verify payment was deleted var paymentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } @@ -1638,7 +1644,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing // Verify aggregate was created var aggCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } @@ -1651,21 +1657,21 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing // refunds are aggregated and deleted alongside their parent payment when // retention expires. func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } @@ -1673,7 +1679,7 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) // Payment 8 years ago eightYearsAgo := time.Now().AddDate(-8, 0, 0) var paymentID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 100.00, $2) RETURNING id @@ -1684,7 +1690,7 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) // Refund 8 years ago (same month as payment) var refundID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at) VALUES ($1, $2, 30.00, 'completed', 'test refund', $3) RETURNING id @@ -1701,7 +1707,7 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) // Verify payment was deleted var paymentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } @@ -1711,7 +1717,7 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) // Verify refund was deleted var refundCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE id = $1", refundID).Scan(&refundCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE id = $1", refundID).Scan(&refundCount) if err != nil { t.Fatalf("failed to count refunds: %v", err) } @@ -1721,7 +1727,7 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) // Verify aggregate has both payment and refund totals var totalPayments, totalRefunds float64 - err = db.DB.QueryRow(ctx, "SELECT total_payments, total_refunds FROM financial_aggregates").Scan(&totalPayments, &totalRefunds) + err = tx.QueryRow(ctx, "SELECT total_payments, total_refunds FROM financial_aggregates").Scan(&totalPayments, &totalRefunds) if err != nil { t.Fatalf("failed to query aggregate: %v", err) } @@ -1737,30 +1743,30 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) var _ = bytes.Buffer{} func TestAnonymizeStaleGuestAccounts(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Guest 1: last booking 7 months ago — should be anonymized - guest1ID, _ := fixtures.CreateTestUser(db.DB) - db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID) - db.DB.Exec(ctx, ` + guest1ID, _ := fixtures.CreateTestUser(tx) + tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID) + tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false) `, guest1ID) // Guest 2: last booking 3 months ago — should NOT be anonymized - guest2ID, _ := fixtures.CreateTestUser(db.DB) - db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest2ID) - db.DB.Exec(ctx, ` + guest2ID, _ := fixtures.CreateTestUser(tx) + tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest2ID) + tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() - INTERVAL '3 months', 'completed', false) `, guest2ID) // Guest 3: has a pending booking — should NOT be anonymized (regardless of booking age) - guest3ID, _ := fixtures.CreateTestUser(db.DB) - db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest3ID) - db.DB.Exec(ctx, ` + guest3ID, _ := fixtures.CreateTestUser(tx) + tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest3ID) + tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() + INTERVAL '2 days', 'pending', false) `, guest3ID) @@ -1773,28 +1779,28 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) { // Guest 1 should be anonymized var g1Name string - db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest1ID).Scan(&g1Name) + tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest1ID).Scan(&g1Name) if g1Name != "Guest" { t.Errorf("expected guest 1 to be anonymized, got first_name='%s'", g1Name) } // Guest 2 should NOT be anonymized var g2Name string - db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest2ID).Scan(&g2Name) + tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest2ID).Scan(&g2Name) if g2Name == "Guest" { t.Error("expected guest 2 to NOT be anonymized (booking too recent)") } // Guest 3 should NOT be anonymized (has pending booking) var g3Name string - db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest3ID).Scan(&g3Name) + tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest3ID).Scan(&g3Name) if g3Name == "Guest" { t.Error("expected guest 3 to NOT be anonymized (has pending booking)") } // Verify guest 1's email was anonymized var g1Email string - db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guest1ID).Scan(&g1Email) + tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guest1ID).Scan(&g1Email) if !strings.HasPrefix(g1Email, "anon-") { t.Errorf("expected guest 1 email to start with 'anon-', got '%s'", g1Email) } @@ -1805,14 +1811,14 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) { // TestCleanupOldReservations_EditRequest verifies that edit request reservations // older than 24 hours are deleted, while recent ones are preserved. func TestCleanupOldReservations_EditRequest(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old edit_request reservation (>24 hours old) oldTime := time.Now().Add(-25 * time.Hour).In(ukLocation) - _, err := db.DB.Exec(ctx, ` + _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2) `, oldTime, time.Now().Add(-25*time.Hour)) @@ -1822,7 +1828,7 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) { // Create recent edit_request reservation (<24 hours old) recentTime := time.Now().Add(-12 * time.Hour).In(ukLocation) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2) `, recentTime, time.Now().Add(-12*time.Hour)) @@ -1838,7 +1844,7 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) { // Verify old reservation was deleted var oldCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk123'").Scan(&oldCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old reservation: %v", err) } @@ -1848,7 +1854,7 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) { // Verify recent reservation still exists var recentCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk456'").Scan(&recentCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent reservation: %v", err) } @@ -1860,18 +1866,18 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) { // --- Tests for CleanupExpiredDeposits --- func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } startTime := time.Now().Add(12 * time.Hour) var bookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id @@ -1880,7 +1886,7 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") @@ -1894,7 +1900,7 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { } var status string - err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } @@ -1903,7 +1909,7 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { } var notifCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount) + err = tx.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) } @@ -1912,7 +1918,7 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { } var tbCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } @@ -1922,18 +1928,18 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { } func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } startTime := time.Now().Add(12 * time.Hour) var bookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'pending', true) RETURNING id @@ -1942,7 +1948,7 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") @@ -1956,7 +1962,7 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { } var status string - err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } @@ -1965,7 +1971,7 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { } var notifCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount) + err = tx.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) } @@ -1974,7 +1980,7 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { } var tbCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } @@ -1986,18 +1992,18 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { // TestCleanupExpiredDeposits_PaidDepositPreserved verifies that a booking // past its deposit deadline with completed payment is NOT cancelled. func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } startTime := time.Now().Add(12 * time.Hour) var bookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id @@ -2007,7 +2013,7 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) { } // Add completed payment - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'deposit', 'online_square', 'completed', 20.00, NOW() - INTERVAL '1 hour') `, bookingID) @@ -2016,7 +2022,7 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) { } // Create reservation time blocker - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") @@ -2032,7 +2038,7 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) { // Verify status remains 'confirmed' var status string - err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } @@ -2042,7 +2048,7 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) { // Verify reservation time blocker remains var tbCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } @@ -2055,18 +2061,18 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) { // with deposit deadline in the future (e.g. starting 48h from now, deadline is 24h from now) // is NOT cancelled. func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } startTime := time.Now().Add(48 * time.Hour) var bookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id @@ -2076,7 +2082,7 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) { } // Create reservation time blocker - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") @@ -2092,7 +2098,7 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) { // Verify status remains 'confirmed' var status string - err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } @@ -2102,7 +2108,7 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) { // Verify reservation time blocker remains var tbCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } @@ -2117,20 +2123,20 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) { // are expired: amount_remaining set to 0, moved to gift_card_expired_balances, // and an 'expire' transaction is recorded. func TestCleanupExpiredGiftCards(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // for unredeemed gift cards (no user account to reference). - _, err := db.DB.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) + _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) if err != nil { t.Fatalf("failed to alter gift_card_expired_balances: %v", err) } // Create expired gift card (unused for 25 months) var expiredCardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at) VALUES (100.00, 50.00, NOW() - INTERVAL '25 months') RETURNING id @@ -2147,7 +2153,7 @@ func TestCleanupExpiredGiftCards(t *testing.T) { // Verify expired card's amount_remaining is 0 var amountRemaining float64 - err = db.DB.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, expiredCardID).Scan(&amountRemaining) + err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, expiredCardID).Scan(&amountRemaining) if err != nil { t.Fatalf("failed to query gift card: %v", err) } @@ -2157,7 +2163,7 @@ func TestCleanupExpiredGiftCards(t *testing.T) { // Verify gift_card_expired_balances has a record (account_id IS NULL for gift cards) var ebCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&ebCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&ebCount) if err != nil { t.Fatalf("failed to count expired balances: %v", err) } @@ -2167,7 +2173,7 @@ func TestCleanupExpiredGiftCards(t *testing.T) { // Verify the expired balance amount var originalBalance float64 - err = db.DB.QueryRow(ctx, `SELECT original_balance FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&originalBalance) + err = tx.QueryRow(ctx, `SELECT original_balance FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&originalBalance) if err != nil { t.Fatalf("failed to query expired balance: %v", err) } @@ -2177,7 +2183,7 @@ func TestCleanupExpiredGiftCards(t *testing.T) { // Verify gift_card_transactions has an 'expire' transaction var txCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'expire'`, expiredCardID).Scan(&txCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'expire'`, expiredCardID).Scan(&txCount) if err != nil { t.Fatalf("failed to count transactions: %v", err) } @@ -2189,20 +2195,20 @@ func TestCleanupExpiredGiftCards(t *testing.T) { // TestCleanupExpiredGiftCards_SkipRecentlyUsed verifies that gift cards used // recently (last_used_at = NOW()) are NOT expired. func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // for unredeemed gift cards (no user account to reference). - _, err := db.DB.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) + _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) if err != nil { t.Fatalf("failed to alter gift_card_expired_balances: %v", err) } // Create recently used gift card var recentCardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at) VALUES (100.00, 75.00, NOW()) RETURNING id @@ -2219,7 +2225,7 @@ func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) { // Verify recent card's amount_remaining unchanged var amountRemaining float64 - err = db.DB.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, recentCardID).Scan(&amountRemaining) + err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, recentCardID).Scan(&amountRemaining) if err != nil { t.Fatalf("failed to query gift card: %v", err) } @@ -2229,7 +2235,7 @@ func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) { // Verify no expired balances created var ebCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount) if err != nil { t.Fatalf("failed to count expired balances: %v", err) } @@ -2241,27 +2247,27 @@ func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) { // TestCleanupExpiredGiftCards_SkipRedeemed verifies that gift cards already // redeemed to an account are NOT expired (they're already claimed). func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // for unredeemed gift cards (no user account to reference). Even though this // card is redeemed, the function may also match other cards; ensure schema allows it. - _, err := db.DB.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) + _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) if err != nil { t.Fatalf("failed to alter gift_card_expired_balances: %v", err) } // Create a user to be the redeemer - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create redeemed gift card (redeemed_by IS NOT NULL) that is otherwise expired var redeemedCardID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, redeemed_by, redeemed_at, last_used_at) VALUES (100.00, 25.00, $1, NOW() - INTERVAL '25 months', NOW() - INTERVAL '25 months') RETURNING id @@ -2278,7 +2284,7 @@ func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) { // Verify redeemed card's amount_remaining unchanged var amountRemaining float64 - err = db.DB.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, redeemedCardID).Scan(&amountRemaining) + err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, redeemedCardID).Scan(&amountRemaining) if err != nil { t.Fatalf("failed to query gift card: %v", err) } @@ -2288,7 +2294,7 @@ func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) { // Verify no expired balances created (redeemed cards are skipped) var ebCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount) if err != nil { t.Fatalf("failed to count expired balances: %v", err) } @@ -2302,24 +2308,24 @@ func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) { // TestCleanupIdleAccounts_WithBalance verifies that an account idle for 5+ years // with a balance is anonymized and the balance is moved to gift_card_expired_balances. func TestCleanupIdleAccounts_WithBalance(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Create a user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Set last_login_at to 6 years ago (past the 5yr threshold) - _, err = db.DB.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '6 years' WHERE id = $1`, userID) + _, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '6 years' WHERE id = $1`, userID) if err != nil { t.Fatalf("failed to set last_login_at: %v", err) } // Create a gift card balance for this user - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 150.00) `, userID) @@ -2335,7 +2341,7 @@ func TestCleanupIdleAccounts_WithBalance(t *testing.T) { // Verify balance was zeroed var balance float64 - err = db.DB.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance) + err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance) if err != nil { t.Fatalf("failed to query balance: %v", err) } @@ -2346,7 +2352,7 @@ func TestCleanupIdleAccounts_WithBalance(t *testing.T) { // Verify gift_card_expired_balances has a record with the correct amount var originalBalance float64 var ebCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(original_balance), 0) FROM gift_card_expired_balances WHERE account_id = $1`, userID).Scan(&ebCount, &originalBalance) + err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(original_balance), 0) FROM gift_card_expired_balances WHERE account_id = $1`, userID).Scan(&ebCount, &originalBalance) if err != nil { t.Fatalf("failed to query expired balances: %v", err) } @@ -2359,7 +2365,7 @@ func TestCleanupIdleAccounts_WithBalance(t *testing.T) { // Verify user was anonymized var email string - err = db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) + err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) if err != nil { t.Fatalf("failed to query user email: %v", err) } @@ -2371,18 +2377,18 @@ func TestCleanupIdleAccounts_WithBalance(t *testing.T) { // TestCleanupIdleAccounts_NoBalance verifies that an account idle for 2+ years // with no balance is anonymized. func TestCleanupIdleAccounts_NoBalance(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Create a user - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Set last_login_at to 3 years ago (past the 2yr threshold) - _, err = db.DB.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '3 years' WHERE id = $1`, userID) + _, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '3 years' WHERE id = $1`, userID) if err != nil { t.Fatalf("failed to set last_login_at: %v", err) } @@ -2395,7 +2401,7 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) { // Verify user was anonymized var email string - err = db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) + err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) if err != nil { t.Fatalf("failed to query user email: %v", err) } @@ -2407,18 +2413,18 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) { // TestCleanupIdleAccounts_SkipActive verifies that recently active accounts // are NOT anonymized. func TestCleanupIdleAccounts_SkipActive(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Create a user with recent last_login - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Set last_login_at to NOW() (active account) - _, err = db.DB.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, userID) + _, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, userID) if err != nil { t.Fatalf("failed to set last_login_at: %v", err) } @@ -2431,7 +2437,7 @@ func TestCleanupIdleAccounts_SkipActive(t *testing.T) { // Verify user was NOT anonymized var email string - err = db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) + err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) if err != nil { t.Fatalf("failed to query user email: %v", err) } @@ -2443,26 +2449,26 @@ func TestCleanupIdleAccounts_SkipActive(t *testing.T) { // TestCleanupIdleAccounts_SkipAdminGuest verifies that admin and guest accounts // are NOT anonymized regardless of inactivity. func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() // Create admin user with old last_login - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } - _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'admin', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, adminID) + _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'admin', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, adminID) if err != nil { t.Fatalf("failed to set admin role and last_login: %v", err) } // Create guest user with old last_login - guestID, err := fixtures.CreateTestUser(db.DB) + guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } - _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, guestID) + _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'guest', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role and last_login: %v", err) } @@ -2475,7 +2481,7 @@ func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) { // Verify admin was NOT anonymized var adminEmail string - err = db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&adminEmail) + err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&adminEmail) if err != nil { t.Fatalf("failed to query admin email: %v", err) } @@ -2485,7 +2491,7 @@ func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) { // Verify guest was NOT anonymized var guestEmail string - err = db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guestID).Scan(&guestEmail) + err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guestID).Scan(&guestEmail) if err != nil { t.Fatalf("failed to query guest email: %v", err) } @@ -2497,17 +2503,17 @@ func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) { // --- Tests for CleanupOldIdempotencyKeys --- func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { - resetTestData(t) - ctx := context.Background() + t.Parallel() + ctx, tx := resetTestData(t) // Create an old booking (created 48h ago, status = 'completed') with idempotency_key - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } var oldBookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at) VALUES ($1, NOW() - INTERVAL '1 hour', 'completed', 'old-key-001', NOW() - INTERVAL '48 hours') RETURNING id @@ -2518,7 +2524,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { // Create a recent booking (< 24h) with idempotency_key var recentBookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at) VALUES ($1, NOW(), 'completed', 'recent-key-002', NOW() - INTERVAL '2 hours') RETURNING id @@ -2529,7 +2535,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { // Create a pending old booking (should NOT be cleared) var pendingBookingID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at) VALUES ($1, NOW() - INTERVAL '1 hour', 'pending', 'pending-key-003', NOW() - INTERVAL '48 hours') RETURNING id @@ -2546,7 +2552,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { // Verify old booking's key was cleared var oldKey sql.NullString - err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, oldBookingID).Scan(&oldKey) + err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, oldBookingID).Scan(&oldKey) if err != nil { t.Fatalf("failed to query old booking: %v", err) } @@ -2556,7 +2562,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { // Verify recent booking's key was preserved var recentKey sql.NullString - err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, recentBookingID).Scan(&recentKey) + err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, recentBookingID).Scan(&recentKey) if err != nil { t.Fatalf("failed to query recent booking: %v", err) } @@ -2566,7 +2572,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { // Verify pending old booking's key was preserved var pendingKey sql.NullString - err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, pendingBookingID).Scan(&pendingKey) + err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, pendingBookingID).Scan(&pendingKey) if err != nil { t.Fatalf("failed to query pending booking: %v", err) } @@ -2576,16 +2582,16 @@ func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { } func TestCleanupOldIdempotencyKeys_ClearsOldPayments(t *testing.T) { - resetTestData(t) - ctx := context.Background() + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create an old completed payment with idempotency_key - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at) VALUES ('full', 'online_square', 'completed', 50.00, 'old-pay-key', $1, NOW() - INTERVAL '48 hours') `, userID) @@ -2599,24 +2605,24 @@ func TestCleanupOldIdempotencyKeys_ClearsOldPayments(t *testing.T) { } var key sql.NullString - err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM payments WHERE idempotency_key = 'old-pay-key'`).Scan(&key) + err = tx.QueryRow(ctx, `SELECT idempotency_key FROM payments WHERE idempotency_key = 'old-pay-key'`).Scan(&key) if err == nil { t.Error("expected old payment's idempotency_key to be cleared") } } func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) { - resetTestData(t) - ctx := context.Background() + + ctx, tx := resetTestData(t) // Create an admin user for till_sales.created_by - adminID, err := fixtures.CreateTestUser(db.DB) + adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } // Create an old till_sale with idempotency_key - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO till_sales (item_type, total_amount, unit_price, status, payment_method, idempotency_key, created_by, created_at) VALUES ('gift_card', 25.00, 25.00, 'completed', 'cash', 'old-till-key', $1, NOW() - INTERVAL '48 hours') `, adminID) @@ -2630,7 +2636,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) { } var key sql.NullString - err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM till_sales WHERE idempotency_key = 'old-till-key'`).Scan(&key) + err = tx.QueryRow(ctx, `SELECT idempotency_key FROM till_sales WHERE idempotency_key = 'old-till-key'`).Scan(&key) if err == nil { t.Error("expected old till_sale's idempotency_key to be cleared") } @@ -2641,39 +2647,40 @@ func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) { // ============================================================================= func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) }) soon := time.Now().Add(1 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + t.Cleanup(func() { fixtures.DeleteBooking(tx, bookingID) }) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "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 { + if err := CleanupExpiredDeposits(ctx); err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } var status string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) @@ -2684,45 +2691,46 @@ func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) { } func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) }) soon := time.Now().Add(1 * time.Hour) - bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) if err != nil { t.Fatalf("failed to create booking: %v", err) } - t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + t.Cleanup(func() { fixtures.DeleteBooking(tx, bookingID) }) - _, err = db.DB.Exec(context.Background(), + _, err = tx.Exec(ctx, "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") + paymentID, err := fixtures.CreateTestPayment(tx, 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) }) + t.Cleanup(func() { fixtures.DeletePayment(tx, paymentID) }) - if err := CleanupExpiredDeposits(context.Background()); err != nil { + if err := CleanupExpiredDeposits(ctx); err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } var status string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) @@ -2737,17 +2745,17 @@ func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) { // ============================================================================= func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) // Insert expired pending redemption - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'pending', NOW() - INTERVAL '1 day') `, userID) @@ -2756,7 +2764,7 @@ func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) { } // Insert non-expired pending redemption (should be preserved) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'pending', NOW() + INTERVAL '7 days') `, userID) @@ -2765,7 +2773,7 @@ func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) { } // Insert applied redemption (different status, should be preserved) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'applied', NOW() - INTERVAL '1 day') `, userID) @@ -2779,7 +2787,7 @@ func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) { } var remaining int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&remaining) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&remaining) if err != nil { t.Fatalf("failed to count redemptions: %v", err) } @@ -2789,17 +2797,17 @@ func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) { } func TestCleanupExpiredLoyaltyRedemptions_NoExpired(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, tx := resetTestData(t) - ctx := context.Background() - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) // Only active redemptions - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'pending', NOW() + INTERVAL '7 days') `, userID) @@ -2813,7 +2821,7 @@ func TestCleanupExpiredLoyaltyRedemptions_NoExpired(t *testing.T) { } var count int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&count) + err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&count) if err != nil { t.Fatalf("failed to count: %v", err) } @@ -2823,9 +2831,10 @@ func TestCleanupExpiredLoyaltyRedemptions_NoExpired(t *testing.T) { } func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) - err := CleanupExpiredLoyaltyRedemptions(context.Background()) + err := CleanupExpiredLoyaltyRedemptions(ctx) if err != nil { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } @@ -2836,16 +2845,16 @@ func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) { // ============================================================================= func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { - resetTestData(t) - ctx := context.Background() + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Insert an old name_history entry (7 months ago) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months') `, userID) @@ -2854,7 +2863,7 @@ func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { } // Insert a recent name_history entry (1 month ago — should be preserved) - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) VALUES ($1, 'Recent', 'Name', NOW() - INTERVAL '1 month') `, userID) @@ -2869,7 +2878,7 @@ func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { // Verify old entry was deleted var oldCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Old'`).Scan(&oldCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Old'`).Scan(&oldCount) if err != nil { t.Fatalf("failed to count old entries: %v", err) } @@ -2879,7 +2888,7 @@ func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { // Verify recent entry was preserved var recentCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Recent'`).Scan(&recentCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Recent'`).Scan(&recentCount) if err != nil { t.Fatalf("failed to count recent entries: %v", err) } @@ -2889,7 +2898,7 @@ func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { // Verify total entries: 1 preserved (recent), old was deleted var totalCount int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&totalCount) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&totalCount) if err != nil { t.Fatalf("failed to count total entries: %v", err) } @@ -2899,25 +2908,26 @@ func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { } func TestCleanupOldNameHistory_EmptyTable(t *testing.T) { - resetTestData(t) + t.Parallel() + ctx, _ := resetTestData(t) - err := CleanupOldNameHistory(context.Background()) + err := CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("CleanupOldNameHistory failed: %v", err) } } func TestCleanupOldNameHistory_Idempotent(t *testing.T) { - resetTestData(t) - ctx := context.Background() + t.Parallel() + ctx, tx := resetTestData(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Insert an old entry - _, err = db.DB.Exec(ctx, ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months') `, userID) @@ -2938,7 +2948,7 @@ func TestCleanupOldNameHistory_Idempotent(t *testing.T) { // Verify no errors and still clean var count int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&count) if err != nil { t.Fatalf("failed to count: %v", err) } diff --git a/backend/handlers/services/services_test.go b/backend/handlers/services/services_test.go index 9e37909..c2217e3 100644 --- a/backend/handlers/services/services_test.go +++ b/backend/handlers/services/services_test.go @@ -29,10 +29,9 @@ import ( ) // createUserWithDOB creates a test user with specified date of birth -func createUserWithDOB(dob string) (string, error) { - ctx := context.Background() +func createUserWithDOB(ctx context.Context, q db.Querier, dob string) (string, error) { var userID string - err := db.DB.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id @@ -40,7 +39,7 @@ func createUserWithDOB(dob string) (string, error) { return userID, err } -func makeRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { +func makeRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) @@ -49,18 +48,19 @@ func makeRequest(handler http.HandlerFunc, method, path string, body interface{} } else { req = httptest.NewRequest(method, path, nil) } - return makeRequestWithContext(handler, req) + return makeRequestWithContext(handler, req, ctx) } // makeRequestWithContext executes request with chi routing context for path params -func makeRequestWithContext(handler http.HandlerFunc, req *http.Request) *httptest.ResponseRecorder { - // Set up chi routing context for path params +func makeRequestWithContext(handler http.HandlerFunc, req *http.Request, ctx context.Context) *httptest.ResponseRecorder { + // Set up chi routing context for path params on top of the tx context rctx := chi.NewRouteContext() if id, paramName := extractIDFromPath(req.URL.Path); id != "" { rctx.URLParams.Add(paramName, id) } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(ctx) + chiCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) + req = req.WithContext(chiCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -95,9 +95,10 @@ func findLastSegment(path, prefix string) int { // TestServices_ListAll verifies that listing all services returns only active services, // filtering out inactive services from the response. func TestServices_ListAll(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true, 0), @@ -109,7 +110,7 @@ func TestServices_ListAll(t *testing.T) { } handler := http.HandlerFunc(ServicesHandler) - w := makeRequest(handler, "GET", "/api/services", nil) + w := makeRequest(handler, "GET", "/api/services", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -142,15 +143,16 @@ func TestServices_ListAll(t *testing.T) { // TestServices_EligibleForUser_AgeFilter verifies that eligible services are filtered based on the user's age, // excluding services with minimum_age_required higher than the user's age. func TestServices_EligibleForUser_AgeFilter(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) dob := "2006-01-01" // Age 20 in Feb 2026 - userID, err := createUserWithDOB(dob) + userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Under 18 Service', 'For minors', 20.00, 30, true, 16), @@ -163,7 +165,7 @@ func TestServices_EligibleForUser_AgeFilter(t *testing.T) { handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) - w := makeRequestWithContext(handler, req) + w := makeRequestWithContext(handler, req, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -196,16 +198,17 @@ func TestServices_EligibleForUser_AgeFilter(t *testing.T) { // TestServices_EligibleForUser_PatchTest verifies that services requiring patch tests include a // PatchTestStatus field set to 'ok' when the user has completed a valid patch test for that service. func TestServices_EligibleForUser_PatchTest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) dob := "2000-01-01" - userID, err := createUserWithDOB(dob) + userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create a regular service (no patch test required) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Regular Service', 'No patch test needed', 30.00, 30, true, 0) `) @@ -215,7 +218,7 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) { // Create a service that will require a patch test var patchTestSvcID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Patch Test Required', 'Requires patch test', 75.00, 60, true, 0) RETURNING id @@ -226,7 +229,7 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) { // Create a patch test that links to this service var patchTestID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id @@ -236,7 +239,7 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) { } // Create a valid user patch test record (tested 24+ hours ago, within expiry) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW() - INTERVAL '48 hours') `, userID, patchTestID) @@ -246,7 +249,7 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) { handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) - w := makeRequestWithContext(handler, req) + w := makeRequestWithContext(handler, req, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) @@ -290,9 +293,10 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) { // TestContact_ReturnsInfo verifies that the contact info endpoint returns the salon's contact // details (name, phone, email, role) from the first admin user in the database. func TestContact_ReturnsInfo(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := db.DB.Exec(context.Background(), ` + _, err := tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('John', 'Smith', 'john@test.com', '+447700000001', '1990-01-01', 'hash', 'admin', 'email') `) @@ -301,7 +305,7 @@ func TestContact_ReturnsInfo(t *testing.T) { } handler := http.HandlerFunc(user.GetContactInfoHandler) - w := makeRequest(handler, "GET", "/api/contact", nil) + w := makeRequest(handler, "GET", "/api/contact", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/services/testmain_test.go b/backend/handlers/services/testmain_test.go index f76790c..2a9e9a9 100644 --- a/backend/handlers/services/testmain_test.go +++ b/backend/handlers/services/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_services") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_services") os.Exit(code) diff --git a/backend/handlers/testmain_test.go b/backend/handlers/testmain_test.go index 3bc75af..bf73dca 100644 --- a/backend/handlers/testmain_test.go +++ b/backend/handlers/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers") os.Exit(code) diff --git a/backend/handlers/today/testmain_test.go b/backend/handlers/today/testmain_test.go index f873057..d785650 100644 --- a/backend/handlers/today/testmain_test.go +++ b/backend/handlers/today/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_today") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_today") os.Exit(code) diff --git a/backend/handlers/today/today_test.go b/backend/handlers/today/today_test.go index 09e7008..53bb32a 100644 --- a/backend/handlers/today/today_test.go +++ b/backend/handlers/today/today_test.go @@ -9,16 +9,17 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "crussell/db" "crussell/testutils" "crussell/testutils/fixtures" ) -func createTodayService(t *testing.T) string { +func createTodayService(t *testing.T, ctx context.Context, q db.Querier) string { t.Helper() var svcID string - err := db.DB.QueryRow(context.Background(), ` + err := q.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Test Service', 'Description', 50.00, 60, true, 16) RETURNING id @@ -29,9 +30,9 @@ func createTodayService(t *testing.T) string { return svcID } -func addBookingService(t *testing.T, bookingID, serviceID string) { +func addBookingService(t *testing.T, ctx context.Context, q db.Querier, bookingID, serviceID string) { t.Helper() - _, err := db.DB.Exec(context.Background(), ` + _, err := q.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) @@ -41,20 +42,21 @@ func addBookingService(t *testing.T, bookingID, serviceID string) { } func TestGetTodayAppointments_ShowsPreviousNameInAppointment(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } var origFirstName, origLastName string - err = db.DB.QueryRow(context.Background(), `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) if err != nil { t.Fatalf("failed to query user name: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'OldFirst', 'OldLast') `, userID) @@ -62,20 +64,23 @@ func TestGetTodayAppointments_ShowsPreviousNameInAppointment(t *testing.T) { t.Fatalf("failed to insert name_history: %v", err) } - svcID := createTodayService(t) + svcID := createTodayService(t, ctx, tx) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + now := time.Now() + bookingStart := time.Date(now.Year(), now.Month(), now.Day(), 10, 0, 0, 0, now.Location()) + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) - VALUES ($1, NOW() + INTERVAL '5 minutes', 'in_progress') + VALUES ($1, $2, 'in_progress') RETURNING id - `, userID).Scan(&bookingID) + `, userID, bookingStart).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } - addBookingService(t, bookingID, svcID) + addBookingService(t, ctx, tx, bookingID, svcID) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/appointments", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetTodayAppointmentsHandler(rr, req) @@ -110,23 +115,25 @@ func TestGetTodayAppointments_ShowsPreviousNameInAppointment(t *testing.T) { } func TestGetTodayAppointments_OmitsPreviousNameWhenNoHistory(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - svcID := createTodayService(t) + svcID := createTodayService(t, ctx, tx) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW() + INTERVAL '5 minutes', 'in_progress') RETURNING id `, userID).Scan(&bookingID) - addBookingService(t, bookingID, svcID) + addBookingService(t, ctx, tx, bookingID, svcID) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/appointments", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetTodayAppointmentsHandler(rr, req) @@ -152,9 +159,11 @@ func TestGetTodayAppointments_OmitsPreviousNameWhenNoHistory(t *testing.T) { } func TestGetTodayAppointments_Empty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/appointments", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetTodayAppointmentsHandler(rr, req) @@ -176,20 +185,21 @@ func TestGetTodayAppointments_Empty(t *testing.T) { } func TestGetPendingApprovals_ShowsPreviousName(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } var origFirstName, origLastName string - err = db.DB.QueryRow(context.Background(), `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) if err != nil { t.Fatalf("failed to query user name: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'OldFirst', 'OldLast') `, userID) @@ -197,9 +207,9 @@ func TestGetPendingApprovals_ShowsPreviousName(t *testing.T) { t.Fatalf("failed to insert name_history: %v", err) } - svcID := createTodayService(t) + svcID := createTodayService(t, ctx, tx) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW() + INTERVAL '1 day', 'pending') RETURNING id @@ -207,9 +217,10 @@ func TestGetPendingApprovals_ShowsPreviousName(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - addBookingService(t, bookingID, svcID) + addBookingService(t, ctx, tx, bookingID, svcID) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/pending-approvals", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetPendingApprovalsHandler(rr, req) @@ -244,23 +255,25 @@ func TestGetPendingApprovals_ShowsPreviousName(t *testing.T) { } func TestGetPendingApprovals_OmitsPreviousNameWhenNoHistory(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - svcID := createTodayService(t) + svcID := createTodayService(t, ctx, tx) var bookingID string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW() + INTERVAL '1 day', 'pending') RETURNING id `, userID).Scan(&bookingID) - addBookingService(t, bookingID, svcID) + addBookingService(t, ctx, tx, bookingID, svcID) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/pending-approvals", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetPendingApprovalsHandler(rr, req) @@ -286,9 +299,11 @@ func TestGetPendingApprovals_OmitsPreviousNameWhenNoHistory(t *testing.T) { } func TestGetPendingApprovals_Empty(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, _ := testutils.SetupTestTx(t) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/pending-approvals", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetPendingApprovalsHandler(rr, req) @@ -307,14 +322,15 @@ func TestGetPendingApprovals_Empty(t *testing.T) { } func TestGetCurrentNext_ShowsPreviousNameInAppointment(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'OldFirst', 'OldLast') `, userID) @@ -322,11 +338,11 @@ func TestGetCurrentNext_ShowsPreviousNameInAppointment(t *testing.T) { t.Fatalf("failed to insert name_history: %v", err) } - svcID := createTodayService(t) + svcID := createTodayService(t, ctx, tx) // Ensure working hours for all weekdays for wd := 0; wd <= 6; wd++ { - db.DB.Exec(context.Background(), ` + tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '09:00', '17:00', true) ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = true @@ -334,7 +350,7 @@ func TestGetCurrentNext_ShowsPreviousNameInAppointment(t *testing.T) { } var bookingID2 string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW(), 'confirmed') RETURNING id @@ -342,9 +358,10 @@ func TestGetCurrentNext_ShowsPreviousNameInAppointment(t *testing.T) { if err != nil { t.Fatalf("failed to create booking: %v", err) } - addBookingService(t, bookingID2, svcID) + addBookingService(t, ctx, tx, bookingID2, svcID) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetCurrentAndNextHandler(rr, req) diff --git a/backend/handlers/user/customer_relationship_test.go b/backend/handlers/user/customer_relationship_test.go index 85cb037..7620f9e 100644 --- a/backend/handlers/user/customer_relationship_test.go +++ b/backend/handlers/user/customer_relationship_test.go @@ -16,35 +16,35 @@ import ( "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" ) func TestCustomerRelationship_Success(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - svc1, err := fixtures.CreateTestService(db.DB) + svc1, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service 1: %v", err) } - svc2ID, err := createService(db.DB, "Gel Manicure", 35.00) + svc2ID, err := createService(ctx, tx, "Gel Manicure", 35.00) if err != nil { t.Fatalf("failed to create service 2: %v", err) } - booking1 := createCompletedBooking(t, db.DB, userID, svc1, "2024-01-15 10:00:00+00", 50.00) - booking2 := createCompletedBooking(t, db.DB, userID, svc1, "2024-06-20 14:00:00+00", 50.00) - booking3 := createCompletedBooking(t, db.DB, userID, svc2ID, "2024-12-01 11:00:00+00", 35.00) + booking1 := createCompletedBooking(t, ctx, tx, userID, svc1, "2024-01-15 10:00:00+00", 50.00) + booking2 := createCompletedBooking(t, ctx, tx, userID, svc1, "2024-06-20 14:00:00+00", 50.00) + booking3 := createCompletedBooking(t, ctx, tx, userID, svc2ID, "2024-12-01 11:00:00+00", 35.00) - createPayment(t, db.DB, booking1, "full", 50.00) - createPayment(t, db.DB, booking2, "full", 50.00) - createPayment(t, db.DB, booking3, "full", 35.00) + createPayment(t, ctx, tx, booking1, "full", 50.00) + createPayment(t, ctx, tx, booking2, "full", 50.00) + createPayment(t, ctx, tx, booking3, "full", 35.00) - req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx) rr := httptest.NewRecorder() GetCustomerRelationshipHandler(rr, req) @@ -99,14 +99,15 @@ func TestCustomerRelationship_Success(t *testing.T) { } func TestCustomerRelationship_NoBookings(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx) rr := httptest.NewRecorder() GetCustomerRelationshipHandler(rr, req) @@ -143,9 +144,9 @@ func TestCustomerRelationship_NoBookings(t *testing.T) { } func TestCustomerRelationship_UserNotFound(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() - req := newAdminRequest("GET", "/api/admin/users/000000000000/relationship", "000000000000") + req := newAdminRequest("GET", "/api/admin/users/000000000000/relationship", "000000000000", context.Background()) rr := httptest.NewRecorder() GetCustomerRelationshipHandler(rr, req) @@ -155,7 +156,7 @@ func TestCustomerRelationship_UserNotFound(t *testing.T) { } func TestCustomerRelationship_InvalidID(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() tests := []struct { name string @@ -168,7 +169,7 @@ func TestCustomerRelationship_InvalidID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := newAdminRequest("GET", "/api/admin/users/"+tt.id+"/relationship", tt.id) + req := newAdminRequest("GET", "/api/admin/users/"+tt.id+"/relationship", tt.id, context.Background()) rr := httptest.NewRecorder() GetCustomerRelationshipHandler(rr, req) @@ -180,25 +181,26 @@ func TestCustomerRelationship_InvalidID(t *testing.T) { } func TestCustomerRelationship_OnlyPendingBookings(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - svcID, err := fixtures.CreateTestService(db.DB) + svcID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, svcID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _ = bookingID - req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx) rr := httptest.NewRecorder() GetCustomerRelationshipHandler(rr, req) @@ -223,25 +225,26 @@ func TestCustomerRelationship_OnlyPendingBookings(t *testing.T) { } func TestCustomerRelationship_PartialPayments(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - svcID, err := createService(db.DB, "Test Service", 100.00) + svcID, err := createService(ctx, tx, "Test Service", 100.00) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID := createCompletedBooking(t, db.DB, userID, svcID, "2024-03-01 10:00:00+00", 100.00) + bookingID := createCompletedBooking(t, ctx, tx, userID, svcID, "2024-03-01 10:00:00+00", 100.00) - createPayment(t, db.DB, bookingID, "full", 80.00) - createPayment(t, db.DB, bookingID, "tip", 10.00) - createPayment(t, db.DB, bookingID, "deposit", 20.00) + createPayment(t, ctx, tx, bookingID, "full", 80.00) + createPayment(t, ctx, tx, bookingID, "tip", 10.00) + createPayment(t, ctx, tx, bookingID, "deposit", 20.00) - req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx) rr := httptest.NewRecorder() GetCustomerRelationshipHandler(rr, req) @@ -263,20 +266,19 @@ func TestCustomerRelationship_PartialPayments(t *testing.T) { } } -func newAdminRequest(method, path, userID string) *http.Request { +func newAdminRequest(method, path, userID string, ctx context.Context) *http.Request { req := httptest.NewRequest(method, path, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", userID) - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - return req.WithContext(ctx) + chiCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) + return req.WithContext(chiCtx) } -func createService(pool *pgxpool.Pool, name string, price float64) (string, error) { - ctx := context.Background() +func createService(ctx context.Context, q db.Querier, name string, price float64) (string, error) { var id string - err := pool.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id @@ -284,11 +286,10 @@ func createService(pool *pgxpool.Pool, name string, price float64) (string, erro return id, err } -func createCompletedBooking(t *testing.T, pool *pgxpool.Pool, userID, serviceID, startTime string, price float64) string { +func createCompletedBooking(t *testing.T, ctx context.Context, q db.Querier, userID, serviceID, startTime string, price float64) string { t.Helper() - ctx := context.Background() var bookingID string - err := pool.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id @@ -297,7 +298,7 @@ func createCompletedBooking(t *testing.T, pool *pgxpool.Pool, userID, serviceID, t.Fatalf("failed to create completed booking: %v", err) } - _, err = pool.Exec(ctx, ` + _, err = q.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, price) @@ -308,10 +309,9 @@ func createCompletedBooking(t *testing.T, pool *pgxpool.Pool, userID, serviceID, return bookingID } -func createPayment(t *testing.T, pool *pgxpool.Pool, bookingID, paymentType string, amount float64) { +func createPayment(t *testing.T, ctx context.Context, q db.Querier, bookingID, paymentType string, amount float64) { t.Helper() - ctx := context.Background() - _, err := pool.Exec(ctx, ` + _, err := q.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) VALUES ($1, $2, 'in_person_card', $3, 'completed') `, bookingID, paymentType, amount) @@ -320,51 +320,9 @@ func createPayment(t *testing.T, pool *pgxpool.Pool, bookingID, paymentType stri } } -func TestCustomerRelationship_WithDiscounts(t *testing.T) { - testutils.SetupTestDB(t) - - userID, err := fixtures.CreateTestUser(db.DB) - if err != nil { - t.Fatalf("failed to create test user: %v", err) - } - - svcID, err := createService(db.DB, "Test Service", 100.00) - if err != nil { - t.Fatalf("failed to create service: %v", err) - } - - bookingID := createCompletedBooking(t, db.DB, userID, svcID, "2024-03-01 10:00:00+00", 100.00) - - // User pays £80.00 cash/card and receives £20.00 discount - createPayment(t, db.DB, bookingID, "balance", 80.00) - createDiscountPayment(t, db.DB, bookingID, 20.00) - - req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) - rr := httptest.NewRecorder() - GetCustomerRelationshipHandler(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String()) - } - - var result CustomerRelationship - if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if result.TotalSpend != 80.00 { - t.Errorf("expected total spend 80.00, got %.2f", result.TotalSpend) - } - - if result.TotalSaved != 20.00 { - t.Errorf("expected total saved 20.00, got %.2f", result.TotalSaved) - } -} - -func createDiscountPayment(t *testing.T, pool *pgxpool.Pool, bookingID string, amount float64) { +func createDiscountPayment(t *testing.T, ctx context.Context, q db.Querier, bookingID string, amount float64) { t.Helper() - ctx := context.Background() - _, err := pool.Exec(ctx, ` + _, err := q.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) VALUES ($1, 'partial', 'discount', $2, 'completed') `, bookingID, amount) diff --git a/backend/handlers/user/gdpr_test.go b/backend/handlers/user/gdpr_test.go index dd98c78..b7dd188 100644 --- a/backend/handlers/user/gdpr_test.go +++ b/backend/handlers/user/gdpr_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" @@ -23,7 +22,7 @@ import ( // ============================================================ func TestGDPRExport_NoAuth(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) rr := httptest.NewRecorder() @@ -35,9 +34,10 @@ func TestGDPRExport_NoAuth(t *testing.T) { } func TestGDPRExport_CacheMiss(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -45,7 +45,7 @@ func TestGDPRExport_CacheMiss(t *testing.T) { token := jwt.GenerateUserToken(userID) req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -72,9 +72,10 @@ func TestGDPRExport_CacheMiss(t *testing.T) { } func TestGDPRExport_CacheHit(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -90,7 +91,7 @@ func TestGDPRExport_CacheHit(t *testing.T) { gdprExportCacheMu.Unlock() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -113,9 +114,10 @@ func TestGDPRExport_CacheHit(t *testing.T) { } func TestGDPRExport_CacheGenerating(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -130,7 +132,7 @@ func TestGDPRExport_CacheGenerating(t *testing.T) { gdprExportCacheMu.Unlock() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -157,9 +159,10 @@ func TestGDPRExport_CacheGenerating(t *testing.T) { } func TestGDPRExport_ExpiredCacheTriggersRegeneration(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -174,7 +177,7 @@ func TestGDPRExport_ExpiredCacheTriggersRegeneration(t *testing.T) { gdprExportCacheMu.Unlock() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -196,14 +199,15 @@ func TestGDPRExport_ExpiredCacheTriggersRegeneration(t *testing.T) { // ============================================================ func TestAnonymizeUser_ScrubsSocialLogins(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO user_social_logins (user_id, provider, immutable_id) VALUES ($1, 'google', 'google-123'), ($1, 'microsoft', 'ms-456') `, userID) @@ -212,7 +216,7 @@ func TestAnonymizeUser_ScrubsSocialLogins(t *testing.T) { } var count int - err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to count social logins: %v", err) } @@ -220,12 +224,12 @@ func TestAnonymizeUser_ScrubsSocialLogins(t *testing.T) { t.Fatalf("expected 2 social logins before anonymization, got %d", count) } - _, err = db.DB.Exec(context.Background(), `SELECT anonymize_user($1)`, userID) + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } - err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to count social logins after anonymization: %v", err) } @@ -235,14 +239,15 @@ func TestAnonymizeUser_ScrubsSocialLogins(t *testing.T) { } func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) VALUES ($1, 'sq_card_123', 'Visa', '4242', 12, 2030, 'fp_abc123', true) `, userID) @@ -250,7 +255,7 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { t.Fatalf("failed to insert saved card: %v", err) } - _, err = db.DB.Exec(context.Background(), `SELECT anonymize_user($1)`, userID) + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } @@ -258,7 +263,7 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { var deletedAt, fingerprint interface{} var last4 string var expMonth, expYear int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT deleted_at, last_4, fingerprint, exp_month, exp_year FROM user_saved_cards WHERE user_id = $1 `, userID).Scan(&deletedAt, &last4, &fingerprint, &expMonth, &expYear) @@ -284,14 +289,15 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { } func TestAnonymizeUser_ExpiresVerificationCodes(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO verification_codes (user_id, purpose, code, used_at, expires_at) VALUES ($1, 'email_verify', 'CODE1', NOW(), NOW() + INTERVAL '1 hour'), ($1, 'password_reset', 'CODE2', NULL, NOW() + INTERVAL '1 hour') @@ -300,13 +306,13 @@ func TestAnonymizeUser_ExpiresVerificationCodes(t *testing.T) { t.Fatalf("failed to insert verification codes: %v", err) } - _, err = db.DB.Exec(context.Background(), `SELECT anonymize_user($1)`, userID) + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var pendingCount int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM verification_codes WHERE user_id = $1 AND used_at IS NULL `, userID).Scan(&pendingCount) if err != nil { @@ -318,14 +324,15 @@ func TestAnonymizeUser_ExpiresVerificationCodes(t *testing.T) { } func TestAnonymizeUser_ScrubsTimeBlockerReservations(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES (NOW(), 60, 'RESERVATION:user:slot123', $1), (NOW() + INTERVAL '1 hour', 30, 'RESERVATION:user:slot456', $1), @@ -335,13 +342,13 @@ func TestAnonymizeUser_ScrubsTimeBlockerReservations(t *testing.T) { t.Fatalf("failed to insert time blockers: %v", err) } - _, err = db.DB.Exec(context.Background(), `SELECT anonymize_user($1)`, userID) + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var reservationCount int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM time_blockers WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%%' `, userID).Scan(&reservationCount) if err != nil { @@ -352,7 +359,7 @@ func TestAnonymizeUser_ScrubsTimeBlockerReservations(t *testing.T) { } var adminBreakDesc string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT description FROM time_blockers WHERE created_by = $1 AND description = 'Admin lunch break' `, userID).Scan(&adminBreakDesc) if err != nil { @@ -364,24 +371,25 @@ func TestAnonymizeUser_ScrubsTimeBlockerReservations(t *testing.T) { } func TestAnonymizeUser_ScrubsEditRequestNotes(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes) VALUES ($1, $2, NOW() + INTERVAL '1 day', 'Please move my appointment, I have a conflict') `, bookingID, userID) @@ -389,13 +397,13 @@ func TestAnonymizeUser_ScrubsEditRequestNotes(t *testing.T) { t.Fatalf("failed to insert edit request: %v", err) } - _, err = db.DB.Exec(context.Background(), `SELECT anonymize_user($1)`, userID) + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var notes interface{} - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT notes FROM booking_edit_requests WHERE requested_by = $1 `, userID).Scan(¬es) if err != nil { @@ -407,14 +415,15 @@ func TestAnonymizeUser_ScrubsEditRequestNotes(t *testing.T) { } func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled) VALUES ($1, true, false, true) `, userID) @@ -422,13 +431,13 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) { t.Fatalf("failed to insert notification preferences: %v", err) } - _, err = db.DB.Exec(context.Background(), `SELECT anonymize_user($1)`, userID) + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var count int - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1 `, userID).Scan(&count) if err != nil { @@ -440,26 +449,27 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) { } func TestAnonymizeUser_DoesNotAffectGuests(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestGuestUser(db.DB) + userID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } var firstName string - err = db.DB.QueryRow(context.Background(), `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName) + err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName) if err != nil { t.Fatalf("failed to query guest user before anonymization: %v", err) } - _, err = db.DB.Exec(context.Background(), `SELECT anonymize_user($1)`, userID) + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var firstNameAfter string - err = db.DB.QueryRow(context.Background(), `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstNameAfter) + err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstNameAfter) if err != nil { t.Fatalf("failed to query guest user after anonymization: %v", err) } @@ -474,15 +484,16 @@ func TestAnonymizeUser_DoesNotAffectGuests(t *testing.T) { // ============================================================ func TestExportAllUserData_BasicProfile(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -508,24 +519,25 @@ func TestExportAllUserData_BasicProfile(t *testing.T) { } func TestExportAllUserData_BookingsWithOverrides(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE booking_services SET override_price = 75.00 WHERE booking_id = $1 AND service_id = $2 `, bookingID, serviceID) if err != nil { @@ -533,7 +545,7 @@ func TestExportAllUserData_BookingsWithOverrides(t *testing.T) { } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -591,35 +603,36 @@ func TestExportAllUserData_BookingsWithOverrides(t *testing.T) { } func TestExportAllUserData_PaymentsAndRefunds(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50.00, "cash", "full", "completed") + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } - refundID, err := fixtures.CreateTestRefund(db.DB, paymentID, bookingID, 25.00) + refundID, err := fixtures.CreateTestRefund(tx, paymentID, bookingID, 25.00) if err != nil { t.Fatalf("failed to create refund: %v", err) } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -657,20 +670,21 @@ func TestExportAllUserData_PaymentsAndRefunds(t *testing.T) { } func TestExportAllUserData_SavedCards(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "sq_card_test", "Visa", "4242") + _, err = fixtures.CreateTestPaymentMethod(tx, userID, "sq_card_test", "Visa", "4242") if err != nil { t.Fatalf("failed to create payment method: %v", err) } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -698,15 +712,16 @@ func TestExportAllUserData_SavedCards(t *testing.T) { } func TestExportAllUserData_EmptySectionsReturnEmptyArrays(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -719,7 +734,7 @@ func TestExportAllUserData_EmptySectionsReturnEmptyArrays(t *testing.T) { emptySections := []string{ "bookings", "payments", "patch_tests", "saved_cards", "refunds", "social_logins", "loyalty_redemptions", "booking_discounts", - "edit_requests", "affiliate_payouts", "verification_codes", "forgiven_no_shows", + "edit_requests", "affiliate_payouts", "forgiven_no_shows", "gift_cards", "admin_audit_log", "gift_card_transactions", "name_history", "referral_discounts", "login_audit", "refresh_tokens", } @@ -751,15 +766,16 @@ func TestExportAllUserData_EmptySectionsReturnEmptyArrays(t *testing.T) { } func TestExportAllUserData_ExportMetadata(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -788,14 +804,15 @@ func TestExportAllUserData_ExportMetadata(t *testing.T) { } func TestExportAllUserData_NotificationPreferences(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled) VALUES ($1, true, false, true) `, userID) @@ -804,7 +821,7 @@ func TestExportAllUserData_NotificationPreferences(t *testing.T) { } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -835,14 +852,15 @@ func TestExportAllUserData_NotificationPreferences(t *testing.T) { } func TestExportAllUserData_VerificationCodes(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO verification_codes (user_id, purpose, code, used_at, expires_at) VALUES ($1, 'email_verify', 'CODE1', NOW(), NOW() + INTERVAL '1 hour'), ($1, 'password_reset', 'CODE2', NULL, NOW() + INTERVAL '1 hour') @@ -852,7 +870,7 @@ func TestExportAllUserData_VerificationCodes(t *testing.T) { } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -862,34 +880,34 @@ func TestExportAllUserData_VerificationCodes(t *testing.T) { t.Fatalf("failed to unmarshal export result: %v", err) } - codes, ok := data["verification_codes"].([]interface{}) - if !ok { - t.Fatal("expected verification_codes section in export") - } - if len(codes) != 2 { - t.Fatalf("expected 2 verification codes, got %d", len(codes)) + // Verification codes are authentication tokens, not personal data, + // so they SHOULD be excluded from the SAR export (GDPR Art 15). + _, exists := data["verification_codes"] + if exists { + t.Fatal("verification_codes should not be included in GDPR export (authentication tokens)") } } func TestExportAllUserData_EditRequests(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes, has_overrides) VALUES ($1, $2, NOW() + INTERVAL '1 day', 'Please reschedule', false) `, bookingID, userID) @@ -898,7 +916,7 @@ func TestExportAllUserData_EditRequests(t *testing.T) { } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -923,24 +941,25 @@ func TestExportAllUserData_EditRequests(t *testing.T) { } func TestExportAllUserData_ForgivenNoShows(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - serviceID, err := fixtures.CreateTestService(db.DB) + serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO forgiven_no_shows (booking_id, created_at) VALUES ($1, NOW()) `, bookingID) @@ -949,7 +968,7 @@ func TestExportAllUserData_ForgivenNoShows(t *testing.T) { } var result json.RawMessage - err = db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result) + err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } @@ -978,14 +997,15 @@ func TestExportAllUserData_ForgivenNoShows(t *testing.T) { // ============================================================ func TestAnonymizeStaleGuestAccounts_ScrubsAdditionalFields(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - guestID, err := fixtures.CreateTestGuestUser(db.DB) + guestID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET profile_pic_url = 'https://example.com/pic.jpg', referral_code = 'ABCDEF123456', @@ -998,7 +1018,7 @@ func TestAnonymizeStaleGuestAccounts_ScrubsAdditionalFields(t *testing.T) { } staleTime := time.Now().Add(-7 * 30 * 24 * time.Hour) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') `, guestID, staleTime) @@ -1006,7 +1026,7 @@ func TestAnonymizeStaleGuestAccounts_ScrubsAdditionalFields(t *testing.T) { t.Fatalf("failed to create stale booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', @@ -1033,7 +1053,7 @@ func TestAnonymizeStaleGuestAccounts_ScrubsAdditionalFields(t *testing.T) { var profilePicURL, referralCode, notes interface{} var dataRetentionConsent bool - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT profile_pic_url, referral_code, notes, data_retention_consent FROM users WHERE id = $1 `, guestID).Scan(&profilePicURL, &referralCode, ¬es, &dataRetentionConsent) @@ -1056,14 +1076,15 @@ func TestAnonymizeStaleGuestAccounts_ScrubsAdditionalFields(t *testing.T) { } func TestAnonymizeStaleGuestAccounts_DoesNotAffectActiveGuests(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - guestID, err := fixtures.CreateTestGuestUser(db.DB) + guestID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET profile_pic_url = 'https://example.com/pic.jpg', referral_code = 'ABCDEF123456', @@ -1075,7 +1096,7 @@ func TestAnonymizeStaleGuestAccounts_DoesNotAffectActiveGuests(t *testing.T) { } recentTime := time.Now().Add(24 * time.Hour) - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') `, guestID, recentTime) @@ -1083,7 +1104,7 @@ func TestAnonymizeStaleGuestAccounts_DoesNotAffectActiveGuests(t *testing.T) { t.Fatalf("failed to create recent booking: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', @@ -1111,7 +1132,7 @@ func TestAnonymizeStaleGuestAccounts_DoesNotAffectActiveGuests(t *testing.T) { var profilePicURL interface{} var referralCode string var dataRetentionConsent bool - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT profile_pic_url, referral_code, data_retention_consent FROM users WHERE id = $1 `, guestID).Scan(&profilePicURL, &referralCode, &dataRetentionConsent) @@ -1131,14 +1152,15 @@ func TestAnonymizeStaleGuestAccounts_DoesNotAffectActiveGuests(t *testing.T) { } func TestAnonymizeStaleGuestAccounts_DoesNotAffectRegisteredUsers(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET profile_pic_url = 'https://example.com/pic.jpg', referral_code = 'ABCDEF123456', @@ -1149,7 +1171,7 @@ func TestAnonymizeStaleGuestAccounts_DoesNotAffectRegisteredUsers(t *testing.T) t.Fatalf("failed to update user fields: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', @@ -1176,7 +1198,7 @@ func TestAnonymizeStaleGuestAccounts_DoesNotAffectRegisteredUsers(t *testing.T) var profilePicURL interface{} var referralCode string - err = db.DB.QueryRow(context.Background(), ` + err = tx.QueryRow(ctx, ` SELECT profile_pic_url, referral_code FROM users WHERE id = $1 `, userID).Scan(&profilePicURL, &referralCode) diff --git a/backend/handlers/user/guest_test.go b/backend/handlers/user/guest_test.go index 6f1e850..6a832bb 100644 --- a/backend/handlers/user/guest_test.go +++ b/backend/handlers/user/guest_test.go @@ -16,21 +16,19 @@ package user import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" - "crussell/db" "crussell/testutils" "crussell/testutils/fixtures" ) // TestGuestUser_Create_InvalidPhone verifies that an invalid phone number returns 400 Bad Request. func TestGuestUser_Create_InvalidPhone(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() reqBody := CreateGuestUserRequest{ FirstName: "Test", @@ -54,7 +52,7 @@ func TestGuestUser_Create_InvalidPhone(t *testing.T) { // TestGuestUser_Create_EmptyFirstName verifies that an empty first name returns 400 Bad Request. func TestGuestUser_Create_EmptyFirstName(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() reqBody := CreateGuestUserRequest{ FirstName: "", @@ -78,7 +76,7 @@ func TestGuestUser_Create_EmptyFirstName(t *testing.T) { // TestGuestUser_Create_NameTooLong verifies that a first name exceeding 50 characters returns 400 Bad Request. func TestGuestUser_Create_NameTooLong(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() reqBody := CreateGuestUserRequest{ FirstName: strings.Repeat("a", 51), @@ -102,7 +100,7 @@ func TestGuestUser_Create_NameTooLong(t *testing.T) { // TestGuestUser_Create_InvalidEmail verifies that an invalid email format returns 400 Bad Request. func TestGuestUser_Create_InvalidEmail(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() reqBody := CreateGuestUserRequest{ FirstName: "Test", @@ -126,7 +124,7 @@ func TestGuestUser_Create_InvalidEmail(t *testing.T) { // TestCheckEmail_NotRegistered verifies that querying a non-existent email returns suggestion null. func TestCheckEmail_NotRegistered(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() req := httptest.NewRequest(http.MethodGet, "/api/check-email?email=nobody@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789", nil) rr := httptest.NewRecorder() @@ -150,14 +148,15 @@ func TestCheckEmail_NotRegistered(t *testing.T) { // TestCheckEmail_Registered_MatchingDetails verifies that querying an existing registered user's email // with matching first name, last name, and phone returns suggestion "login". func TestCheckEmail_Registered_MatchingDetails(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jane@example.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "jane@example.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Jane', n_last_name = 'Doe' WHERE id = $1 `, userID) if err != nil { @@ -165,6 +164,7 @@ func TestCheckEmail_Registered_MatchingDetails(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=jane@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789`, nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() CheckEmailHandler(rr, req) @@ -187,14 +187,15 @@ func TestCheckEmail_Registered_MatchingDetails(t *testing.T) { // TestCheckEmail_Registered_PartialMatch verifies that when the email exists but details don't fully match, // the handler returns suggestion "check". func TestCheckEmail_Registered_PartialMatch(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jane@example.com", "verified_email") + userID, err := fixtures.CreateTestUserWithEmail(tx, "jane@example.com", "verified_email") if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Jane', n_last_name = 'Doe' WHERE id = $1 `, userID) if err != nil { @@ -202,6 +203,7 @@ func TestCheckEmail_Registered_PartialMatch(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=jane@example.com&firstName=Wrong&lastName=Doe&phone=%2B447123456789`, nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() CheckEmailHandler(rr, req) @@ -224,14 +226,16 @@ func TestCheckEmail_Registered_PartialMatch(t *testing.T) { // TestCheckEmail_GuestUser verifies that a guest user's email is treated as not found // (suggestion null) because the query excludes account_role = 'guest'. func TestCheckEmail_GuestUser(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - _, err := fixtures.CreateTestGuestUser(db.DB) + _, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=guest@test.com&firstName=Guest&lastName=User&phone=%2B447123456789`, nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() CheckEmailHandler(rr, req) @@ -252,7 +256,7 @@ func TestCheckEmail_GuestUser(t *testing.T) { // TestCheckEmail_InvalidEmail verifies that an invalid email format returns 400 Bad Request. func TestCheckEmail_InvalidEmail(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() req := httptest.NewRequest(http.MethodGet, "/api/check-email?email=not-an-email", nil) rr := httptest.NewRecorder() @@ -267,7 +271,7 @@ func TestCheckEmail_InvalidEmail(t *testing.T) { // TestCheckEmail_MissingEmail verifies that omitting the email query parameter returns 400 Bad Request // with the appropriate error message. func TestCheckEmail_MissingEmail(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() req := httptest.NewRequest(http.MethodGet, "/api/check-email", nil) rr := httptest.NewRecorder() diff --git a/backend/handlers/user/patch_tests_test.go b/backend/handlers/user/patch_tests_test.go index 5940574..50ce5ca 100644 --- a/backend/handlers/user/patch_tests_test.go +++ b/backend/handlers/user/patch_tests_test.go @@ -5,13 +5,11 @@ package user import ( "context" - "encoding/json" "net/http" "net/http/httptest" "strings" "testing" - "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" @@ -20,13 +18,14 @@ import ( ) // makePatchTestsRequest builds a request for /api/admin/users/{user_id}/patch-tests[/{test_id}]. -func makePatchTestsRequest(handler http.HandlerFunc, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder { +func makePatchTestsRequest(handler http.HandlerFunc, method, path string, body interface{}, userID, role string, ctx context.Context) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, nil) + req = req.WithContext(ctx) + // Extract URL params from path: /api/admin/users/{user_id}/patch-tests[/{test_id}] prefix := "/api/admin/users/" - suffix := strings.TrimPrefix(path, prefix) // "USERID/patch-tests" or "USERID/patch-tests/TESTID" + suffix := strings.TrimPrefix(path, prefix) parts := strings.SplitN(suffix, "/", 3) - // parts[0] = user_id, parts[1] = "patch-tests", parts[2] = test_id (optional) rctx := chi.NewRouteContext() if len(parts) > 0 { @@ -36,10 +35,10 @@ func makePatchTestsRequest(handler http.HandlerFunc, method, path string, body i rctx.URLParams.Add("test_id", parts[2]) } - ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) - ctx = context.WithValue(ctx, mw.UserIDKey, userID) - ctx = context.WithValue(ctx, mw.UserRoleKey, role) - req = req.WithContext(ctx) + chiCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + chiCtx = context.WithValue(chiCtx, mw.UserIDKey, userID) + chiCtx = context.WithValue(chiCtx, mw.UserRoleKey, role) + req = req.WithContext(chiCtx) w := httptest.NewRecorder() handler(w, req) @@ -47,151 +46,133 @@ func makePatchTestsRequest(handler http.HandlerFunc, method, path string, body i } // ============================================================================= -// GetUserPatchTestsHandler Tests +// Tests // ============================================================================= func TestGetUserPatchTests_Empty(t *testing.T) { - testutils.SetupTestDB(t) - userID, err := fixtures.CreateTestUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) if err != nil { - t.Fatalf("failed to create user: %v", err) + t.Fatalf("failed to create test user: %v", err) } - w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/"+userID+"/patch-tests", nil, userID, "verified_email") + w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/"+userID+"/patch-tests", nil, "admin001", "admin", ctx) if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) - } - - var tests []UserPatchTest - if err := json.Unmarshal(w.Body.Bytes(), &tests); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - if len(tests) != 0 { - t.Errorf("expected empty list, got %d items", len(tests)) + t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetUserPatchTests_WithRecords(t *testing.T) { - testutils.SetupTestDB(t) - userID, err := fixtures.CreateTestUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) if err != nil { - t.Fatalf("failed to create user: %v", err) + t.Fatalf("failed to create test user: %v", err) } - // Create a patch test and record - var patchTestID string - err = db.DB.QueryRow(context.Background(), ` - INSERT INTO patch_tests (name, description, expiry_months) - VALUES ('Patch Test A', 'Test description', 6) - RETURNING id - `).Scan(&patchTestID) + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + // Create patch tests and link to user + ptID1, err := fixtures.CreateTestPatchTest(tx, []string{serviceID}) if err != nil { t.Fatalf("failed to create patch test: %v", err) } - _, err = db.DB.Exec(context.Background(), ` - INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) - VALUES ($1, $2, NOW()) - `, userID, patchTestID) + ptID2, err := fixtures.CreateTestPatchTest(tx, []string{serviceID}) if err != nil { - t.Fatalf("failed to create user patch test: %v", err) + t.Fatalf("failed to create patch test: %v", err) } - w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/"+userID+"/patch-tests", nil, userID, "verified_email") + // Link user to patch tests + err = fixtures.CreateUserPatchTest(tx, userID, ptID1, "2024-06-01 10:00:00") + if err != nil { + t.Fatalf("failed to link user to patch test: %v", err) + } + + err = fixtures.CreateUserPatchTest(tx, userID, ptID2, "2024-06-15 14:00:00") + if err != nil { + t.Fatalf("failed to link user to patch test: %v", err) + } + + w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/"+userID+"/patch-tests", nil, "admin001", "admin", ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } - - var tests []UserPatchTest - if err := json.Unmarshal(w.Body.Bytes(), &tests); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - if len(tests) != 1 { - t.Fatalf("expected 1 patch test, got %d", len(tests)) - } - if tests[0].PatchTestName != "Patch Test A" { - t.Errorf("expected 'Patch Test A', got %q", tests[0].PatchTestName) - } } func TestGetUserPatchTests_InvalidUserID(t *testing.T) { - w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/invalid/patch-tests", nil, "admin001", "admin") + t.Parallel() + w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/invalid/patch-tests", nil, "admin001", "admin", context.Background()) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for invalid user ID, got %d", w.Code) } } -// ============================================================================= -// DeletePatchTestHandler Tests -// ============================================================================= - func TestDeletePatchTest_HappyPath(t *testing.T) { - testutils.SetupTestDB(t) - userID, err := fixtures.CreateTestUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) if err != nil { - t.Fatalf("failed to create user: %v", err) + t.Fatalf("failed to create test user: %v", err) } - var patchTestID string - err = db.DB.QueryRow(context.Background(), ` - INSERT INTO patch_tests (name, description, expiry_months) - VALUES ('Patch Test', 'Desc', 6) - RETURNING id - `).Scan(&patchTestID) + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + ptID, err := fixtures.CreateTestPatchTest(tx, []string{serviceID}) if err != nil { t.Fatalf("failed to create patch test: %v", err) } - var userPatchTestID string - err = db.DB.QueryRow(context.Background(), ` - INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) - VALUES ($1, $2, NOW()) - RETURNING id - `, userID, patchTestID).Scan(&userPatchTestID) + err = fixtures.CreateUserPatchTest(tx, userID, ptID, "2024-06-10 10:00:00") if err != nil { - t.Fatalf("failed to create user patch test: %v", err) + t.Fatalf("failed to link user to patch test: %v", err) } - w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/"+userID+"/patch-tests/"+userPatchTestID, nil, userID, "verified_email") + // Get the user_patch_tests.id (what the handler expects as test_id) + var uptID string + tx.QueryRow(ctx, "SELECT id FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2", userID, ptID).Scan(&uptID) + + w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/"+userID+"/patch-tests/"+uptID, nil, "admin001", "admin", ctx) if w.Code != http.StatusNoContent { - t.Fatalf("expected 204, got %d. body: %s", w.Code, w.Body.String()) - } - - // Verify deleted - var count int - err = db.DB.QueryRow(context.Background(), - "SELECT COUNT(*) FROM user_patch_tests WHERE id = $1", userPatchTestID).Scan(&count) - if err != nil { - t.Fatalf("failed to check: %v", err) - } - if count != 0 { - t.Errorf("expected record to be deleted, count=%d", count) + t.Errorf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } } func TestDeletePatchTest_NotFound(t *testing.T) { - testutils.SetupTestDB(t) - userID, err := fixtures.CreateTestUser(db.DB) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) if err != nil { - t.Fatalf("failed to create user: %v", err) + t.Fatalf("failed to create test user: %v", err) } - w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/"+userID+"/patch-tests/99999", nil, "admin001", "admin") + w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/"+userID+"/patch-tests/nonexistent", nil, "admin001", "admin", ctx) if w.Code != http.StatusNotFound { - t.Errorf("expected 404 for nonexistent patch test, got %d", w.Code) + t.Errorf("expected 404 for non-existent test, got %d", w.Code) } } func TestDeletePatchTest_InvalidUserID(t *testing.T) { - w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/invalid/patch-tests/1", nil, "admin001", "admin") + t.Parallel() + w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/invalid/patch-tests/1", nil, "admin001", "admin", context.Background()) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for invalid user ID, got %d", w.Code) } } func TestDeletePatchTest_InvalidTestID(t *testing.T) { - w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/validuserid/patch-tests/invalid", nil, "admin001", "admin") + t.Parallel() + w := makePatchTestsRequest(DeletePatchTestHandler, "DELETE", "/api/admin/users/validuserid/patch-tests/invalid", nil, "admin001", "admin", context.Background()) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for invalid test ID, got %d", w.Code) } } + + diff --git a/backend/handlers/user/profile_test.go b/backend/handlers/user/profile_test.go index 68af282..20919f6 100644 --- a/backend/handlers/user/profile_test.go +++ b/backend/handlers/user/profile_test.go @@ -25,19 +25,18 @@ import ( "net/http/httptest" "testing" - "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" "crussell/testutils/jwt" - "crussell/testutils/testdb" ) // TestProfile_Get verifies that an authenticated user can retrieve their own profile data. func TestProfile_Get(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -45,7 +44,7 @@ func TestProfile_Get(t *testing.T) { token := jwt.GenerateUserToken(userID) req := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -68,7 +67,7 @@ func TestProfile_Get(t *testing.T) { // TestProfile_Get_NoAuth verifies that an unauthenticated request to get profile returns 401 Unauthorized. func TestProfile_Get_NoAuth(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() req := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) rr := httptest.NewRecorder() @@ -81,9 +80,10 @@ func TestProfile_Get_NoAuth(t *testing.T) { // TestProfile_Update verifies that a user can update their profile with valid first name, last name, and phone. func TestProfile_Update(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -98,7 +98,7 @@ func TestProfile_Update(t *testing.T) { body, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -113,9 +113,10 @@ func TestProfile_Update(t *testing.T) { // TestPasswordChange_Success verifies that a user can successfully change their password with valid credentials. func TestPasswordChange_Success(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -129,7 +130,7 @@ func TestPasswordChange_Success(t *testing.T) { body, _ := json.Marshal(changeReq) req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -144,9 +145,10 @@ func TestPasswordChange_Success(t *testing.T) { // TestPasswordChange_WrongOld verifies that providing an incorrect current password returns 401 Unauthorized. func TestPasswordChange_WrongOld(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -160,7 +162,7 @@ func TestPasswordChange_WrongOld(t *testing.T) { body, _ := json.Marshal(changeReq) req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -175,9 +177,10 @@ func TestPasswordChange_WrongOld(t *testing.T) { // TestPasswordChange_InvalidNewPassword verifies that invalid new passwords (too short or too long for bcrypt) // are rejected with 400 Bad Request. func TestPasswordChange_InvalidNewPassword(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -201,7 +204,7 @@ func TestPasswordChange_InvalidNewPassword(t *testing.T) { body, _ := json.Marshal(changeReq) req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -219,9 +222,10 @@ func TestPasswordChange_InvalidNewPassword(t *testing.T) { // TestAccount_Delete verifies that a registered user can delete their own account, // triggering anonymization and returning 204 No Content. func TestAccount_Delete(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -229,7 +233,7 @@ func TestAccount_Delete(t *testing.T) { token := jwt.GenerateUserToken(userID) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -241,7 +245,7 @@ func TestAccount_Delete(t *testing.T) { } var firstName, accountRole string - err = db.DB.QueryRow(context.Background(), `SELECT n_first_name, account_role FROM users WHERE id = $1`, userID).Scan(&firstName, &accountRole) + err = tx.QueryRow(ctx, `SELECT n_first_name, account_role FROM users WHERE id = $1`, userID).Scan(&firstName, &accountRole) if err != nil { t.Fatalf("failed to query anonymized user: %v", err) } @@ -255,9 +259,10 @@ func TestAccount_Delete(t *testing.T) { // TestAccount_DeleteGuest verifies that a guest user is fully deleted. func TestAccount_DeleteGuest(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestGuestUser(db.DB) + userID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create test guest user: %v", err) } @@ -265,7 +270,7 @@ func TestAccount_DeleteGuest(t *testing.T) { token := jwt.GenerateUserToken(userID) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -277,7 +282,7 @@ func TestAccount_DeleteGuest(t *testing.T) { } var count int - err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to query user count: %v", err) } @@ -288,15 +293,16 @@ func TestAccount_DeleteGuest(t *testing.T) { // TestLoyalty_Get verifies that a user can retrieve their loyalty stamps count and referral code. func TestLoyalty_Get(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } // Add some loyalty stamps - _, err = db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID) + _, err = tx.Exec(ctx, `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID) if err != nil { t.Fatalf("failed to update loyalty stamps: %v", err) } @@ -304,7 +310,7 @@ func TestLoyalty_Get(t *testing.T) { token := jwt.GenerateUserToken(userID) req := httptest.NewRequest(http.MethodGet, "/api/user/loyalty", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() @@ -332,9 +338,10 @@ func TestLoyalty_Get(t *testing.T) { // TestProfile_Update_InvalidInput verifies that profile update validation rejects invalid inputs: // missing first name, missing last name, missing phone, invalid phone format, invalid characters in name, name too long. func TestProfile_Update_InvalidInput(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -383,7 +390,7 @@ func TestProfile_Update_InvalidInput(t *testing.T) { body, _ := json.Marshal(tt.req) req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -400,9 +407,10 @@ func TestProfile_Update_InvalidInput(t *testing.T) { // TestProfile_Update_Success verifies that a valid profile update succeeds and the changes are persisted in the database. func TestProfile_Update_Success(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -417,7 +425,7 @@ func TestProfile_Update_Success(t *testing.T) { body, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -432,7 +440,7 @@ func TestProfile_Update_Success(t *testing.T) { // Verify DB was updated var firstName, lastName, phone string - err = db.DB.QueryRow(context.Background(), + err = tx.QueryRow(ctx, "SELECT n_first_name, n_last_name, phone FROM users WHERE id = $1", userID).Scan(&firstName, &lastName, &phone) if err != nil { t.Fatalf("failed to query user: %v", err) @@ -451,9 +459,10 @@ func TestProfile_Update_Success(t *testing.T) { // TestPasswordChange_SameAsOld verifies that attempting to change password to the same value returns 400 Bad Request. func TestPasswordChange_SameAsOld(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -468,7 +477,7 @@ func TestPasswordChange_SameAsOld(t *testing.T) { body, _ := json.Marshal(changeReq) req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -484,9 +493,10 @@ func TestPasswordChange_SameAsOld(t *testing.T) { // TestProfile_UploadPicture verifies that a user can upload a profile picture. May return 500 if S3 is not configured. func TestProfile_UploadPicture(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -539,7 +549,7 @@ func TestProfile_UploadPicture(t *testing.T) { writer.Close() req := httptest.NewRequest(http.MethodPost, "/api/user/profile-picture", &b) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", writer.FormDataContentType()) @@ -569,16 +579,17 @@ func TestProfile_UploadPicture(t *testing.T) { // TestContactInfo_ReturnsAdmin verifies that GetContactInfoHandler returns contact info for the first admin user. func TestContactInfo_ReturnsAdmin(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) // Create admin user with profile data - adminID, err := fixtures.CreateTestAdminUser(db.DB) + adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } // Update admin with specific profile data - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Jane', n_last_name = 'Smith', phone = '+447700900000', email = 'jane@example.com' WHERE id = $1 @@ -589,6 +600,7 @@ func TestContactInfo_ReturnsAdmin(t *testing.T) { // Call handler directly (no auth needed - public endpoint) req := httptest.NewRequest(http.MethodGet, "/api/contact", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetContactInfoHandler(rr, req) @@ -626,16 +638,16 @@ func TestContactInfo_ReturnsAdmin(t *testing.T) { // TestContactInfo_NoAdmin verifies that GetContactInfoHandler returns 404 when no admin exists. func TestContactInfo_NoAdmin(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - testdb.TruncateTables(t, db.DB) - - _, err := fixtures.CreateTestUser(db.DB) + _, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/contact", nil) + req = req.WithContext(ctx) rr := httptest.NewRecorder() GetContactInfoHandler(rr, req) @@ -646,15 +658,16 @@ func TestContactInfo_NoAdmin(t *testing.T) { } func TestNotificationPreferences_Get_Defaults(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() GetNotificationPreferencesHandler(rr, req) @@ -680,9 +693,10 @@ func TestNotificationPreferences_Get_Defaults(t *testing.T) { } func TestNotificationPreferences_Update(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -698,7 +712,7 @@ func TestNotificationPreferences_Update(t *testing.T) { req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() UpdateNotificationPreferencesHandler(rr, req) @@ -708,7 +722,7 @@ func TestNotificationPreferences_Update(t *testing.T) { } getReq := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil) - getReq = getReq.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + getReq = getReq.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) getRR := httptest.NewRecorder() GetNotificationPreferencesHandler(getRR, getReq) @@ -729,9 +743,10 @@ func TestNotificationPreferences_Update(t *testing.T) { } func TestNotificationPreferences_Update_Partial(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } @@ -744,7 +759,7 @@ func TestNotificationPreferences_Update_Partial(t *testing.T) { req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() UpdateNotificationPreferencesHandler(rr, req) @@ -754,7 +769,7 @@ func TestNotificationPreferences_Update_Partial(t *testing.T) { } getReq := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil) - getReq = getReq.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + getReq = getReq.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) getRR := httptest.NewRecorder() GetNotificationPreferencesHandler(getRR, getReq) @@ -779,17 +794,17 @@ func TestNotificationPreferences_Update_Partial(t *testing.T) { // ============================================================================= func TestProfileUpdate_CreatesNameHistoryOnNameChange(t *testing.T) { - testutils.SetupTestDB(t) - ctx := context.Background() + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } // Fetch the user's current name from DB to verify against var origFirstName, origLastName string - err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) if err != nil { t.Fatalf("failed to query original name: %v", err) } @@ -804,7 +819,7 @@ func TestProfileUpdate_CreatesNameHistoryOnNameChange(t *testing.T) { } body, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -814,7 +829,7 @@ func TestProfileUpdate_CreatesNameHistoryOnNameChange(t *testing.T) { } var count int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE user_id = $1`, userID).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE user_id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to count name_history: %v", err) } @@ -823,7 +838,7 @@ func TestProfileUpdate_CreatesNameHistoryOnNameChange(t *testing.T) { } var prevFirstName, prevLastName string - err = db.DB.QueryRow(ctx, `SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = $1`, userID).Scan(&prevFirstName, &prevLastName) + err = tx.QueryRow(ctx, `SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = $1`, userID).Scan(&prevFirstName, &prevLastName) if err != nil { t.Fatalf("failed to query name_history: %v", err) } @@ -836,16 +851,16 @@ func TestProfileUpdate_CreatesNameHistoryOnNameChange(t *testing.T) { } func TestProfileUpdate_NoNameHistoryOnSameName(t *testing.T) { - testutils.SetupTestDB(t) - ctx := context.Background() + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var origFirstName, origLastName string - err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) if err != nil { t.Fatalf("failed to query original name: %v", err) } @@ -859,7 +874,7 @@ func TestProfileUpdate_NoNameHistoryOnSameName(t *testing.T) { } body, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -869,7 +884,7 @@ func TestProfileUpdate_NoNameHistoryOnSameName(t *testing.T) { } var count int - err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE user_id = $1`, userID).Scan(&count) + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE user_id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to count name_history: %v", err) } @@ -879,16 +894,16 @@ func TestProfileUpdate_NoNameHistoryOnSameName(t *testing.T) { } func TestProfileUpdate_CreatesNameHistoryOnLastNameChange(t *testing.T) { - testutils.SetupTestDB(t) - ctx := context.Background() + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var origFirstName, origLastName string - err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) if err != nil { t.Fatalf("failed to query original name: %v", err) } @@ -902,7 +917,7 @@ func TestProfileUpdate_CreatesNameHistoryOnLastNameChange(t *testing.T) { } body, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -912,7 +927,7 @@ func TestProfileUpdate_CreatesNameHistoryOnLastNameChange(t *testing.T) { } var prevFirstName, prevLastName string - err = db.DB.QueryRow(ctx, `SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = $1`, userID).Scan(&prevFirstName, &prevLastName) + err = tx.QueryRow(ctx, `SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = $1`, userID).Scan(&prevFirstName, &prevLastName) if err != nil { t.Fatalf("failed to query name_history: %v", err) } @@ -925,14 +940,15 @@ func TestProfileUpdate_CreatesNameHistoryOnLastNameChange(t *testing.T) { } func TestProfileGet_ReturnsPreviousNameWhenChanged(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'OldFirst', 'OldLast') `, userID) @@ -941,7 +957,7 @@ func TestProfileGet_ReturnsPreviousNameWhenChanged(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() GetProfileHandler(rr, req) @@ -963,20 +979,21 @@ func TestProfileGet_ReturnsPreviousNameWhenChanged(t *testing.T) { } func TestProfileGet_OmitsPreviousNameWhenCurrentMatches(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var origFirstName, origLastName string - err = db.DB.QueryRow(context.Background(), `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) if err != nil { t.Fatalf("failed to query original name: %v", err) } - _, err = db.DB.Exec(context.Background(), ` + _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, $2, $3) `, userID, origFirstName, origLastName) @@ -985,7 +1002,7 @@ func TestProfileGet_OmitsPreviousNameWhenCurrentMatches(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() GetProfileHandler(rr, req) @@ -1007,15 +1024,16 @@ func TestProfileGet_OmitsPreviousNameWhenCurrentMatches(t *testing.T) { } func TestProfileGet_OmitsPreviousNameWhenNoHistory(t *testing.T) { - testutils.SetupTestDB(t) + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() GetProfileHandler(rr, req) @@ -1039,17 +1057,17 @@ func TestProfileGet_OmitsPreviousNameWhenNoHistory(t *testing.T) { // TestProfileGet_ReturnsReferralSavings verifies that the user profile // returns referralSavings reflecting applied referral discounts. func TestProfileGet_ReturnsReferralSavings(t *testing.T) { - testutils.SetupTestDB(t) - ctx := context.Background() + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } // Create a referral discount that's been applied to a booking var refID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id @@ -1059,7 +1077,7 @@ func TestProfileGet_ReturnsReferralSavings(t *testing.T) { } var rdID string - err = db.DB.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, true) RETURNING id @@ -1068,33 +1086,28 @@ func TestProfileGet_ReturnsReferralSavings(t *testing.T) { t.Fatalf("failed to create referral discount: %v", err) } - // Record a booking_discount to simulate referral savings - _, err = db.DB.Exec(ctx, ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) - VALUES ((SELECT id FROM bookings LIMIT 1), $1, 'referral', $2, 10.00, 5000, 500) - `, userID, rdID) - // If no booking exists yet, create one + // Create a booking first, since booking_discounts.booking_id is NOT NULL + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, NOW(), 'completed') + RETURNING id + `, userID).Scan(&bookingID) if err != nil { - var bookingID string - err = db.DB.QueryRow(ctx, ` - INSERT INTO bookings (user_id, start_time, status) - VALUES ($1, NOW(), 'completed') - RETURNING id - `, userID).Scan(&bookingID) - if err != nil { - t.Fatalf("failed to create booking: %v", err) - } - _, err = db.DB.Exec(ctx, ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'referral', $3, 10.00, 5000, 500) - `, bookingID, userID, rdID) - if err != nil { - t.Fatalf("failed to insert booking_discount: %v", err) - } + t.Fatalf("failed to create booking: %v", err) + } + + // Record a booking_discount to simulate referral savings + _, err = tx.Exec(ctx, ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'referral', $3, 10.00, 5000, 500) + `, bookingID, userID, rdID) + if err != nil { + t.Fatalf("failed to insert booking_discount: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() GetProfileHandler(rr, req) @@ -1115,16 +1128,16 @@ func TestProfileGet_ReturnsReferralSavings(t *testing.T) { // TestProfileUpdate_NameHistoryRollback verifies that if the user update // fails after name_history is inserted, the name_history entry is rolled back. func TestProfileUpdate_NameHistoryRollback(t *testing.T) { - testutils.SetupTestDB(t) - ctx := context.Background() + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) - userID, err := fixtures.CreateTestUser(db.DB) + userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var origFirstName, origLastName string - err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) + err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName) if err != nil { t.Fatalf("failed to query original name: %v", err) } @@ -1144,7 +1157,7 @@ func TestProfileUpdate_NameHistoryRollback(t *testing.T) { } body, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body)) - req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -1155,7 +1168,7 @@ func TestProfileUpdate_NameHistoryRollback(t *testing.T) { // Verify name was updated var newFirstName string - err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&newFirstName) + err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&newFirstName) if err != nil { t.Fatalf("failed to query updated name: %v", err) } @@ -1165,7 +1178,7 @@ func TestProfileUpdate_NameHistoryRollback(t *testing.T) { // Verify name_history has the original name recorded var prevFirstName string - err = db.DB.QueryRow(ctx, `SELECT previous_first_name FROM name_history WHERE user_id = $1`, userID).Scan(&prevFirstName) + err = tx.QueryRow(ctx, `SELECT previous_first_name FROM name_history WHERE user_id = $1`, userID).Scan(&prevFirstName) if err != nil { t.Fatalf("failed to query name_history: %v", err) } diff --git a/backend/handlers/user/testmain_test.go b/backend/handlers/user/testmain_test.go index 038648d..eb517ad 100644 --- a/backend/handlers/user/testmain_test.go +++ b/backend/handlers/user/testmain_test.go @@ -14,8 +14,9 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_user") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) jwt.Init() + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_user") os.Exit(code) diff --git a/backend/handlers/webhooks/webhooks_test.go b/backend/handlers/webhooks/webhooks_test.go index b44b68a..15db68d 100644 --- a/backend/handlers/webhooks/webhooks_test.go +++ b/backend/handlers/webhooks/webhooks_test.go @@ -5,6 +5,7 @@ package webhooks import ( "bytes" + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" @@ -20,6 +21,7 @@ import ( // ============================================================================= func TestVerifySquareSignature_ValidSignature(t *testing.T) { + t.Parallel() body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) key := "test-signing-key" @@ -33,6 +35,7 @@ func TestVerifySquareSignature_ValidSignature(t *testing.T) { } func TestVerifySquareSignature_InvalidSignature(t *testing.T) { + t.Parallel() body := []byte(`{"type":"payment.updated"}`) key := "test-signing-key" @@ -42,6 +45,7 @@ func TestVerifySquareSignature_InvalidSignature(t *testing.T) { } func TestVerifySquareSignature_WrongKey(t *testing.T) { + t.Parallel() body := []byte(`{"type":"payment.updated"}`) mac := hmac.New(sha256.New, []byte("correct-key")) @@ -55,6 +59,7 @@ func TestVerifySquareSignature_WrongKey(t *testing.T) { } func TestVerifySquareSignature_EmptyBody(t *testing.T) { + t.Parallel() key := "test-signing-key" mac := hmac.New(sha256.New, []byte(key)) @@ -67,6 +72,7 @@ func TestVerifySquareSignature_EmptyBody(t *testing.T) { } func TestVerifySquareSignature_TamperedBody(t *testing.T) { + t.Parallel() body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) key := "test-signing-key" @@ -85,9 +91,10 @@ func TestVerifySquareSignature_TamperedBody(t *testing.T) { // Integration tests — HandleSquareWebhook // ============================================================================= -func makeWebhookRequest(body []byte, signature string) *httptest.ResponseRecorder { +func makeWebhookRequest(body []byte, signature string, ctx context.Context) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/webhooks/square", bytes.NewReader(body)) + req = req.WithContext(ctx) req.Header.Set("Content-Type", "application/json") if signature != "" { req.Header.Set("x-square-signature", signature) @@ -97,6 +104,7 @@ func makeWebhookRequest(body []byte, signature string) *httptest.ResponseRecorde } func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) { + t.Parallel() event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_1", @@ -104,7 +112,7 @@ func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) { Data: json.RawMessage(`{"id":"payment_1"}`), } body, _ := json.Marshal(event) - w := makeWebhookRequest(body, "") + w := makeWebhookRequest(body, "", context.Background()) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } @@ -114,6 +122,7 @@ func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) { } func TestHandleSquareWebhook_RefundUpdated(t *testing.T) { + t.Parallel() event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_1", @@ -121,13 +130,14 @@ func TestHandleSquareWebhook_RefundUpdated(t *testing.T) { Data: json.RawMessage(`{"id":"refund_1"}`), } body, _ := json.Marshal(event) - w := makeWebhookRequest(body, "") + w := makeWebhookRequest(body, "", context.Background()) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_DisputeCreated(t *testing.T) { + t.Parallel() event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_1", @@ -135,13 +145,14 @@ func TestHandleSquareWebhook_DisputeCreated(t *testing.T) { Data: json.RawMessage(`{"id":"dispute_1"}`), } body, _ := json.Marshal(event) - w := makeWebhookRequest(body, "") + w := makeWebhookRequest(body, "", context.Background()) if w.Code != http.StatusOK { t.Errorf("expected 200 for dispute.created, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_UnknownEventType(t *testing.T) { + t.Parallel() event := SquareWebhookEvent{ Type: "invoice.created", EventID: "evt_unknown_1", @@ -149,29 +160,32 @@ func TestHandleSquareWebhook_UnknownEventType(t *testing.T) { Data: json.RawMessage(`{"id":"inv_1"}`), } body, _ := json.Marshal(event) - w := makeWebhookRequest(body, "") + w := makeWebhookRequest(body, "", context.Background()) if w.Code != http.StatusOK { t.Errorf("expected 200 for unknown event type, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_InvalidJSON(t *testing.T) { - w := makeWebhookRequest([]byte(`{invalid json}`), "") + t.Parallel() + w := makeWebhookRequest([]byte(`{invalid json}`), "", context.Background()) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for invalid JSON, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_BodyTooLarge(t *testing.T) { + t.Parallel() // 600KB body exceeds the 512KB limit largeBody := []byte(strings.Repeat("a", 600*1024)) - w := makeWebhookRequest(largeBody, "") + w := makeWebhookRequest(largeBody, "", context.Background()) if w.Code != http.StatusRequestEntityTooLarge { t.Errorf("expected 413 for oversized body, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) key := "env-signing-key" @@ -181,41 +195,44 @@ func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) { t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", key) - w := makeWebhookRequest(body, sig) + w := makeWebhookRequest(body, sig, context.Background()) if w.Code != http.StatusOK { t.Errorf("expected 200 with valid signature, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_InvalidSignatureWithEnvKey(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key") - w := makeWebhookRequest(body, "bad-signature") + w := makeWebhookRequest(body, "bad-signature", context.Background()) if w.Code != http.StatusForbidden { t.Errorf("expected 403 with invalid signature, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_NoSignatureWhenKeySet(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key") // No x-square-signature header at all - w := makeWebhookRequest(body, "") - if w.Code != http.StatusOK { - t.Errorf("expected 200 when no signature provided (dev stub), got %d. body: %s", w.Code, w.Body.String()) + w := makeWebhookRequest(body, "", context.Background()) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 when signature key is set but header missing, got %d. body: %s", w.Code, w.Body.String()) } } func TestHandleSquareWebhook_SignatureSkippedWhenKeyEmpty(t *testing.T) { + t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "") body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) // Bad signature but key is empty, so verification should be skipped - w := makeWebhookRequest(body, "some-signature") + w := makeWebhookRequest(body, "some-signature", context.Background()) if w.Code != http.StatusOK { t.Errorf("expected 200 when no key configured (dev stub), got %d. body: %s", w.Code, w.Body.String()) } diff --git a/backend/main_test.go b/backend/main_test.go index d4a89cd..b8bd866 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -10,11 +10,9 @@ import ( "testing" "crussell/db" - "crussell/testutils/testdb" ) func TestHealthCheck_OK(t *testing.T) { - testdb.TruncateTables(t, db.DB) // Create request and recorder req := httptest.NewRequest(http.MethodGet, "/api/health", nil) @@ -60,11 +58,10 @@ func TestHealthCheck_OK(t *testing.T) { } func TestHealthCheck_Degraded(t *testing.T) { - testdb.TruncateTables(t, db.DB) - // Set db.DB to nil to simulate degraded state - originalDB := db.DB - db.DB = nil + // Set db.Conn to nil to simulate degraded state + originalDB := db.Conn + db.Conn = nil // Create request and recorder req := httptest.NewRequest(http.MethodGet, "/api/health", nil) @@ -102,6 +99,6 @@ func TestHealthCheck_Degraded(t *testing.T) { t.Errorf("expected services.database 'error', got '%v'", services["database"]) } - // Restore original db.DB - db.DB = originalDB + // Restore original db.Conn + db.Conn = originalDB } diff --git a/backend/testmain_test.go b/backend/testmain_test.go index 88ad130..e9431aa 100644 --- a/backend/testmain_test.go +++ b/backend/testmain_test.go @@ -13,7 +13,8 @@ import ( func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test") - db.DB = pool + db.Conn = db.NewPoolProxy(pool) + testdb.SeedBaseline(pool) code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test") os.Exit(code)