//go:build test && dev package bookings // Package bookings contains targeted tests for under-covered booking handlers. // // Targets: // - UserCancelBookingHandler: DELETE /api/bookings/{id} // - AdminCancelBookingHandler: POST /api/admin/bookings/{id}/cancel // - AdminCancelReservationHandler: DELETE /api/admin/bookings/reserve // - ConfirmBookingHandler: POST /api/bookings/{id}/confirm // - AdminCreateBookingForUserHandler: POST /api/admin/bookings // - AdminRescheduleBookingHandler: PUT /api/admin/bookings/{id}/reschedule // - UpdateBookingServicesHandler: PUT /api/admin/bookings/{id}/services // - EditBookingHandler: additional edge cases (cancelled/completed booking) import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/db" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" ) // ============================================================================= // UserCancelBookingHandler — DELETE /api/bookings/{id} // ============================================================================= // TestUserCancelBookingHandler_Success verifies that a user can cancel their // own booking (status → client_cancelled). func TestUserCancelBookingHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a confirmed booking with future start time futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(UserCancelBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking is now client_cancelled var status string 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) } if status != "client_cancelled" { t.Errorf("expected status 'client_cancelled', got %q", status) } } // TestUserCancelBookingHandler_NotFound verifies that cancelling a non-existent // booking returns 404. func TestUserCancelBookingHandler_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(UserCancelBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/nonexistent-id", nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestUserCancelBookingHandler_InvalidID verifies that an invalid booking ID // returns 404. func TestUserCancelBookingHandler_InvalidID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(UserCancelBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/", nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestUserCancelBookingHandler_Unauthorized verifies that cancelling without // auth returns 401. func TestUserCancelBookingHandler_Unauthorized(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UserCancelBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/aaaaaaaaaaaa", nil, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String()) } } // TestUserCancelBookingHandler_NotOwnBooking verifies that a user cannot cancel // another user's booking. func TestUserCancelBookingHandler_NotOwnBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) ownerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create owner: %v", err) } otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", otherUserID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, ownerID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } token := jwt.GenerateUserToken(otherUserID) handler := http.HandlerFunc(UserCancelBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) // Should return 404 because the booking doesn't belong to otherUserID if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestUserCancelBookingHandler_AlreadyCancelled verifies that cancelling an // already-cancelled booking returns 404. func TestUserCancelBookingHandler_AlreadyCancelled(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Set status to already cancelled _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to cancel booking: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(UserCancelBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for already-cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // AdminCancelBookingHandler — POST /api/admin/bookings/{id}/cancel // ============================================================================= // TestAdminCancelBookingHandler_Success verifies that an admin can cancel a // confirmed booking (no payments → simple cancel). func TestAdminCancelBookingHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, 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 := serveChiHandler(AdminCancelBookingHandler, "POST", "/"+bookingID+"/cancel", "/{id}/cancel", nil, 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)) }) // No payments → should get 204 No Content if w.Code != http.StatusNoContent { t.Errorf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } var status string 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) } if status != "we_cancelled" { t.Errorf("expected 'we_cancelled', got %q", status) } } // TestAdminCancelBookingHandler_NotFound verifies that admin cancelling a // non-existent booking returns 404. func TestAdminCancelBookingHandler_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } w := serveChiHandler(AdminCancelBookingHandler, "POST", "/nonexistent-id/cancel", "/{id}/cancel", nil, 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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCancelBookingHandler_EmptyBody verifies that admin cancel works // with an empty body (no decode error) for bookings without payments. func TestAdminCancelBookingHandler_EmptyBody(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // POST with empty body — AdminCancelBookingHandler handles nil body gracefully w := serveChiHandler(AdminCancelBookingHandler, "POST", "/"+bookingID+"/cancel", "/{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.StatusNoContent { t.Errorf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCancelBookingHandler_InvalidID verifies that invalid booking ID // returns 404. func TestAdminCancelBookingHandler_InvalidID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } w := serveChiHandler(AdminCancelBookingHandler, "POST", "//cancel", "/{id}/cancel", nil, 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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCancelBookingHandler_NotCancellable verifies that admin cannot // cancel a booking that's already in a final state. func TestAdminCancelBookingHandler_NotCancellable(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Set to completed (final state) _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set completed: %v", err) } // Fetch payment info first (no payments) — this succeeds but returns zero values // Then the UPDATE fails because status='completed' is not IN ('pending','confirmed','in_progress') w := serveChiHandler(AdminCancelBookingHandler, "POST", "/"+bookingID+"/cancel", "/{id}/cancel", nil, 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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // AdminCancelReservationHandler — DELETE /api/admin/bookings/reserve // ============================================================================= // TestAdminCancelReservationHandler_Success verifies that an admin can cancel // their active reservation. func TestAdminCancelReservationHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } // Create an admin reservation startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:%d', $2) `, clock.Now().UnixNano()), startTime, adminID) if err != nil { t.Fatalf("failed to create admin reservation: %v", err) } // Verify reservation exists before var countBefore int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, adminID, ).Scan(&countBefore) if err != nil { t.Fatalf("failed to count before: %v", err) } if countBefore != 1 { t.Fatalf("expected 1 reservation before, got %d", countBefore) } // Call AdminCancelReservationHandler req := httptest.NewRequest("DELETE", "/api/admin/bookings/reserve", nil) rctx := chi.NewRouteContext() 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() AdminCancelReservationHandler(w, req) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify reservation was deleted var countAfter int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, adminID, ).Scan(&countAfter) if err != nil { t.Fatalf("failed to count after: %v", err) } if countAfter != 0 { t.Errorf("expected 0 reservations after cancel, got %d", countAfter) } // Verify response var resp map[string]string if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp["status"] != "reservation cancelled" { t.Errorf("expected status 'reservation cancelled', got %q", resp["status"]) } } // TestAdminCancelReservationHandler_Unauthorized verifies that calling // AdminCancelReservationHandler without auth returns 401. func TestAdminCancelReservationHandler_Unauthorized(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) req := httptest.NewRequest("DELETE", "/api/admin/bookings/reserve", nil) rctx := chi.NewRouteContext() reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() AdminCancelReservationHandler(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCancelReservationHandler_NoReservation verifies that cancelling with // no active reservation is idempotent (returns 200). func TestAdminCancelReservationHandler_NoReservation(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } // No reservation created req := httptest.NewRequest("DELETE", "/api/admin/bookings/reserve", nil) rctx := chi.NewRouteContext() 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() AdminCancelReservationHandler(w, req) // Should still return 200 (idempotent — DELETE affects 0 rows, no error) if w.Code != http.StatusOK { t.Errorf("expected 200 (idempotent), got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // ConfirmBookingHandler — POST /api/bookings/{id}/confirm // ============================================================================= // TestConfirmBookingHandler_Success verifies that an admin can confirm a pending // booking successfully. func TestConfirmBookingHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a pending booking with enough gap so it doesn't overlap anything futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Ensure booking is pending _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set pending: %v", err) } w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+bookingID+"/confirm", "/{id}/confirm", 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 { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var status string 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) } if status != "confirmed" { t.Errorf("expected 'confirmed', got %q", status) } } // TestConfirmBookingHandler_NotFound verifies that confirming a non-existent // booking returns 404. func TestConfirmBookingHandler_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } w := serveChiHandler(ConfirmBookingHandler, "POST", "/nonexistent-id/confirm", "/{id}/confirm", 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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestConfirmBookingHandler_InvalidID verifies that an invalid booking ID // returns 404. func TestConfirmBookingHandler_InvalidID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } w := serveChiHandler(ConfirmBookingHandler, "POST", "//confirm", "/{id}/confirm", 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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestConfirmBookingHandler_AlreadyConfirmed verifies that confirming an already // confirmed booking returns 404. func TestConfirmBookingHandler_AlreadyConfirmed(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Already confirmed _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set confirmed: %v", err) } w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+bookingID+"/confirm", "/{id}/confirm", 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)) }) // UPDATE only targets status='pending' → no rows matched → 404 if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // AdminCreateBookingForUserHandler — POST /api/admin/bookings // ============================================================================= // TestAdminCreateBookingForUserHandler_Success verifies that an admin can // successfully create a booking for a user. func TestAdminCreateBookingForUserHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } startTime := weekdayTime(time.Wednesday, 10) body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: startTime, ServiceIDs: []string{serviceID}, } w := serveChiHandler(AdminCreateBookingForUserHandler, "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 { t.Errorf("expected 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify the booking was created in the DB var count int 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) } if count != 1 { t.Errorf("expected 1 booking, got %d", count) } } // TestAdminCreateBookingForUserHandler_EmptyBody verifies that an empty body // returns 400. func TestAdminCreateBookingForUserHandler_EmptyBody(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", 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.StatusBadRequest { t.Errorf("expected 400 for empty body, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBookingForUserHandler_MissingUserID verifies that missing // user_id returns 400. func TestAdminCreateBookingForUserHandler_MissingUserID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } body := AdminCreateBookingForUserRequest{ StartTime: weekdayTime(time.Wednesday, 10), ServiceIDs: []string{serviceID}, // UserID intentionally empty } w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest { t.Errorf("expected 400 for missing user_id, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBookingForUserHandler_MissingServices verifies that missing // service IDs returns 400. func TestAdminCreateBookingForUserHandler_MissingServices(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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) } body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: weekdayTime(time.Wednesday, 10), // ServiceIDs intentionally empty } w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest { t.Errorf("expected 400 for missing services, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBookingForUserHandler_MissingStartTime verifies that missing // start_time returns 400. func TestAdminCreateBookingForUserHandler_MissingStartTime(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } body := AdminCreateBookingForUserRequest{ UserID: userID, ServiceIDs: []string{serviceID}, // StartTime intentionally zero } w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest { t.Errorf("expected 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBookingForUserHandler_InvalidOverridePrice verifies that // a negative override price returns 400. func TestAdminCreateBookingForUserHandler_InvalidOverridePrice(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } negPrice := -10.0 body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: weekdayTime(time.Wednesday, 10), ServiceIDs: []string{serviceID}, ServiceOverrides: []ServiceOverride{ {ServiceID: serviceID, OverridePrice: &negPrice}, }, } w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest { t.Errorf("expected 400 for negative override price, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBookingForUserHandler_OverrideDurationZero verifies that // override duration <= 0 returns 400. func TestAdminCreateBookingForUserHandler_OverrideDurationZero(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } zeroDur := 0 body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: weekdayTime(time.Wednesday, 10), ServiceIDs: []string{serviceID}, ServiceOverrides: []ServiceOverride{ {ServiceID: serviceID, OverrideDurationMinutes: &zeroDur}, }, } w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest { t.Errorf("expected 400 for zero override duration, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminCreateBookingForUserHandler_WithOverrideSuccess verifies that an // admin can create a booking with valid service overrides. func TestAdminCreateBookingForUserHandler_WithOverrideSuccess(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } overridePrice := 75.0 overrideDur := 45 startTime := weekdayTime(time.Wednesday, 10) body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: startTime, ServiceIDs: []string{serviceID}, ServiceOverrides: []ServiceOverride{ {ServiceID: serviceID, OverridePrice: &overridePrice, OverrideDurationMinutes: &overrideDur}, }, } w := serveChiHandler(AdminCreateBookingForUserHandler, "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 { t.Errorf("expected 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify override was applied var dbPrice *float64 var dbDur *int err = tx.QueryRow(ctx, "SELECT override_price, override_duration_minutes FROM booking_services WHERE booking_id IN (SELECT id FROM bookings WHERE user_id = $1) AND service_id = $2", userID, serviceID).Scan(&dbPrice, &dbDur) if err != nil { t.Fatalf("failed to query override: %v", err) } if dbPrice == nil || *dbPrice != overridePrice { t.Errorf("expected override_price %.2f, got %v", overridePrice, dbPrice) } if dbDur == nil || *dbDur != overrideDur { t.Errorf("expected override_duration %d, got %v", overrideDur, dbDur) } } // TestAdminCreateBookingForUserHandler_WithCustomServices verifies that an // admin can create a booking with custom services. func TestAdminCreateBookingForUserHandler_WithCustomServices(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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) } // Insert a custom service directly var customSvcID string err = tx.QueryRow(ctx, ` INSERT INTO custom_services (name, description, price, duration_minutes) VALUES ($1, $2, $3, $4) RETURNING id `, "Test Custom Service", "A custom service for testing", 60.00, 45).Scan(&customSvcID) if err != nil { t.Fatalf("failed to create custom service: %v", err) } startTime := weekdayTime(time.Wednesday, 10) body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: startTime, CustomServiceIDs: []string{customSvcID}, } w := serveChiHandler(AdminCreateBookingForUserHandler, "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 { t.Errorf("expected 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify custom service was linked var count int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_custom_services WHERE custom_service_id = $1", customSvcID).Scan(&count) if err != nil { t.Fatalf("failed to query custom services: %v", err) } if count != 1 { t.Errorf("expected 1 custom service link, got %d", count) } // Verify usage count was updated var usageCount int err = tx.QueryRow(ctx, "SELECT usage_count FROM custom_services WHERE id = $1", customSvcID).Scan(&usageCount) if err != nil { t.Fatalf("failed to query usage count: %v", err) } if usageCount != 1 { t.Errorf("expected usage_count 1, got %d", usageCount) } } // TestAdminCreateBookingForUserHandler_Unauthorized verifies that creating // a booking for a user without admin auth returns 401. func TestAdminCreateBookingForUserHandler_Unauthorized(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } body := AdminCreateBookingForUserRequest{ UserID: userID, StartTime: weekdayTime(time.Wednesday, 10), ServiceIDs: []string{serviceID}, } // No admin auth in context — handler will still check mw.UserIDKey // The handler first checks for admin auth at the top w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context { // No admin auth set return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // AdminRescheduleBookingHandler — PUT /api/admin/bookings/{id}/reschedule // ============================================================================= // TestAdminRescheduleBookingHandler_Success verifies that an admin can // reschedule a booking to a future time. func TestAdminRescheduleBookingHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Reschedule to next day newTime := baseTime.Add(24 * time.Hour) body := map[string]interface{}{ "start_time": newTime.Format(time.RFC3339), } 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 { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var dbStartTime time.Time 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) } if !dbStartTime.Truncate(time.Second).Equal(newTime.Truncate(time.Second)) { t.Errorf("expected start_time %v, got %v", newTime, dbStartTime) } } // TestAdminRescheduleBookingHandler_InvalidID verifies that an invalid booking // ID returns 404. func TestAdminRescheduleBookingHandler_InvalidID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } body := map[string]interface{}{ "start_time": clock.Now().Add(48 * time.Hour).Format(time.RFC3339), } w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "//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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminRescheduleBookingHandler_PastStart verifies that rescheduling to a // past time returns 400. func TestAdminRescheduleBookingHandler_PastStart(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } body := map[string]interface{}{ "start_time": clock.Now().Add(-1 * time.Hour).Format(time.RFC3339), } 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.StatusBadRequest { t.Errorf("expected 400 for past start time, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminRescheduleBookingHandler_EmptyBody verifies that an empty body // returns 400. func TestAdminRescheduleBookingHandler_EmptyBody(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", 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.StatusBadRequest { t.Errorf("expected 400 for empty body, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminRescheduleBookingHandler_CompletedBooking verifies that rescheduling // a completed booking returns 403. func TestAdminRescheduleBookingHandler_CompletedBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set completed: %v", err) } body := map[string]interface{}{ "start_time": weekdayTime(time.Thursday, 10).Format(time.RFC3339), } 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.StatusForbidden { t.Errorf("expected 403 for completed booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminRescheduleBookingHandler_NotFound verifies that rescheduling a // non-existent booking returns 404. func TestAdminRescheduleBookingHandler_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } body := map[string]interface{}{ "start_time": weekdayTime(time.Wednesday, 10).Format(time.RFC3339), } w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/nonexistent/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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminRescheduleBookingHandler_Unauthorized verifies that rescheduling // without admin auth returns 401. func TestAdminRescheduleBookingHandler_Unauthorized(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } body := map[string]interface{}{ "start_time": weekdayTime(time.Thursday, 10).Format(time.RFC3339), } // No admin auth w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", body, func(baseCtx context.Context) context.Context { return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // UpdateBookingServicesHandler — PUT /api/admin/bookings/{id}/services // ============================================================================= // TestUpdateBookingServicesHandler_Success verifies that an admin can update // a booking's services. func TestUpdateBookingServicesHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, 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 second service to replace with serviceID2, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create second service: %v", err) } body := UpdateBookingServicesRequest{ ServiceIDs: []string{serviceID2}, } w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", 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 { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify services were replaced var svcCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_services WHERE booking_id = $1", bookingID).Scan(&svcCount) if err != nil { t.Fatalf("failed to query services: %v", err) } if svcCount != 1 { t.Errorf("expected 1 service, got %d", svcCount) } var remainingSvcID string err = tx.QueryRow(ctx, "SELECT service_id FROM booking_services WHERE booking_id = $1", bookingID).Scan(&remainingSvcID) if err != nil { t.Fatalf("failed to query remaining service: %v", err) } if remainingSvcID != serviceID2 { t.Errorf("expected service_id %s, got %s", serviceID2, remainingSvcID) } } // TestUpdateBookingServicesHandler_InvalidID verifies that an invalid booking // ID returns 404. func TestUpdateBookingServicesHandler_InvalidID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } body := UpdateBookingServicesRequest{ ServiceIDs: []string{serviceID}, } w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "//", "/{id}", 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.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestUpdateBookingServicesHandler_Unauthorized verifies that updating services // without admin auth returns 401. func TestUpdateBookingServicesHandler_Unauthorized(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } body := UpdateBookingServicesRequest{ ServiceIDs: []string{serviceID}, } // No admin auth w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", body, func(baseCtx context.Context) context.Context { return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) }) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String()) } } // TestUpdateBookingServicesHandler_EmptyServiceIDs verifies that providing // empty service_ids returns 400. func TestUpdateBookingServicesHandler_EmptyServiceIDs(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } body := UpdateBookingServicesRequest{ ServiceIDs: []string{}, } w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", 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.StatusBadRequest { t.Errorf("expected 400 for empty service_ids, got %d. body: %s", w.Code, w.Body.String()) } } // TestUpdateBookingServicesHandler_CompletedBooking verifies that updating // services on a completed booking returns 403. func TestUpdateBookingServicesHandler_CompletedBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } baseTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set completed: %v", err) } body := UpdateBookingServicesRequest{ ServiceIDs: []string{serviceID}, } w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", 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.StatusForbidden { t.Errorf("expected 403 for completed booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // EditBookingHandler — Additional edge cases // ============================================================================= // TestEditBookingHandler_CompletedBooking verifies that editing a completed // booking returns 403. func TestEditBookingHandler_CompletedBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Set to completed _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set completed: %v", err) } token := jwt.GenerateUserToken(userID) newStartTime := futureTime.Add(48 * time.Hour) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected 403 for completed booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestEditBookingHandler_CancelledBooking verifies that editing a cancelled // booking returns 403. func TestEditBookingHandler_CancelledBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, 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(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } futureTime := weekdayTime(time.Wednesday, 10) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Set to cancelled _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set cancelled: %v", err) } token := jwt.GenerateUserToken(userID) newStartTime := futureTime.Add(48 * time.Hour) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected 403 for cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestEditBookingHandler_Unauthorized verifies that editing without auth // returns 401. func TestEditBookingHandler_Unauthorized(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) req := EditBookingRequest{ StartTime: clock.Now().Add(96 * time.Hour), } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/aaaaaaaaaaaa", req, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String()) } }