//go:build test && dev // +build test,dev package bookings // Package bookings contains tests for the booking edit request system. // // Test Coverage: // - RequestEditHandler: POST /api/bookings/{id}/edit-request - Create/upsert edit request // - DeleteEditRequestHandler: DELETE /api/bookings/{id}/edit-request - Delete edit request // - GetMyEditRequestHandler: GET /api/bookings/{id}/edit-request - View own request for booking // - GetMyEditRequestsHandler: GET /api/bookings/edit-requests - List all own requests // - AdminListEditRequestsHandler: GET /api/admin/bookings/{id}/edit-requests - Admin list with pagination // - AdminListAllEditRequestsHandler: GET /api/admin/bookings/edit-requests - Admin list all enriched // - AdminGetBookingEditRequestHandler: GET /api/admin/bookings/{id}/edit-request - Admin get for booking // - AdminApproveEditRequestHandler: POST /api/admin/bookings/{id}/edit-requests/{request_id}/approve // - AdminRejectEditRequestHandler: POST /api/admin/bookings/{id}/edit-requests/{request_id}/deny // - UserCancelBookingHandler: DELETE /api/bookings/{id} - Cleans up edit requests on cancel // - Time blocker reservation lifecycle // - Working hours validation (exceptional closed hours block on approve) // - Enriched response: end-time calculation, cross-user isolation, override handling // - Notification upsert on edit request replace (delete + recreate) import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/clock" "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" ) // ============================================================================= // Test Helpers // ============================================================================= // strPtr returns a pointer to the given string. func strPtr(s string) *string { return &s } // 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, ctx context.Context, tx db.Querier) (userID, serviceID, bookingID, token string) { t.Helper() userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test 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 test service: %v", err) } // Use a start time ~36h from now so auto-approval (>=48h) does not fire bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Confirm the booking (edit requests require confirmed booking) _, 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) } token = jwt.GenerateUserToken(userID) return } // 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, ctx context.Context, tx db.Querier) (ownerID, otherUserID, serviceID, bookingID, ownerToken string) { t.Helper() ownerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create owner user: %v", err) } otherUserID, err = fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } for _, uid := range []string{ownerID, otherUserID} { _, 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(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err = fixtures.CreateTestBookingAtTime(tx, ownerID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, 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) } ownerToken = jwt.GenerateUserToken(ownerID) return } // createEditRequestDirectly inserts an edit request into the DB and returns its ID. 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 := 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 `, bookingID, userID, newStartTime, newServices, notes).Scan(&editRequestID) if err != nil { t.Fatalf("failed to create edit request directly: %v", err) } return editRequestID } // getEditRequestIDFromDB retrieves the edit request ID for a booking. func getEditRequestIDFromDB(t *testing.T, ctx context.Context, tx db.Querier, bookingID string) string { t.Helper() var editRequestID string 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) } return editRequestID } // createAdminNotification creates an admin notification for an edit request. func createAdminNotification(t *testing.T, ctx context.Context, tx db.Querier, bookingID, userID string) { t.Helper() _, err := tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } } // makeAdminEditRequest creates an admin request with chi routing context for // admin edit request endpoints (approve/deny). This is the most robust pattern. func makeAdminEditRequest(method, path, routePattern string, body interface{}) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } adminToken := jwt.GenerateAdminToken() req.Header.Set("Authorization", "Bearer "+adminToken) rctx := chi.NewRouteContext() // Parse route pattern like "/api/admin/bookings/{id}/edit-requests/{request_id}/approve" patternParts := strings.Split(strings.Trim(routePattern, "/"), "/") pathParts := strings.Split(strings.Trim(path, "/"), "/") for i, pp := range patternParts { if strings.HasPrefix(pp, "{") && strings.HasSuffix(pp, "}") { paramName := pp[1 : len(pp)-1] if i < len(pathParts) { rctx.URLParams.Add(paramName, pathParts[i]) } } } ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") if info := extractUserFromTestJWT(adminToken); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) } req = req.WithContext(ctx) w := httptest.NewRecorder() return w } // serveChiHandler sets up a chi router with the given route and serves a request. // 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": r.Get(routePattern, handler) case "POST": r.Post(routePattern, handler) case "PUT": r.Put(routePattern, handler) case "DELETE": r.Delete(routePattern, handler) } var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) } else { req = httptest.NewRequest(method, path, nil) } req.Header.Set("Content-Type", "application/json") base := req.Context() if len(baseCtx) > 0 { base = baseCtx[0] } if setupCtx != nil { ctx := setupCtx(base) req = req.WithContext(ctx) } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } // 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{}, 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. func setupAdminContext(ctx context.Context) context.Context { adminToken := jwt.GenerateAdminToken() ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") if info := extractUserFromTestJWT(adminToken); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) } return ctx } // setupUserContext adds user JWT to context. func setupUserContext(ctx context.Context, token string) context.Context { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } return ctx } // ============================================================================= // 1. User Creates Edit Request (RequestEditHandler) // ============================================================================= // 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) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = token notes := "Please add gel polish to my appointment" handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": notes, } 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()) } var ( editReq BookingEditRequest err error ) if err = parseResponseBody(w, &editReq); err != nil { t.Fatalf("failed to parse edit request response: %v", err) } if editReq.Notes == nil { t.Fatal("expected notes to be set in response") } if *editReq.Notes != notes { t.Errorf("expected notes %q, got %q", notes, *editReq.Notes) } if editReq.NewStartTime != nil { t.Error("expected new_start_time to be nil for notes-only request") } var blockerCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if blockerCount != 0 { t.Errorf("expected 0 time_blockers for notes-only request, got %d", blockerCount) } var dbNotes string 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) } if dbNotes != notes { t.Errorf("expected DB notes %q, got %q", notes, dbNotes) } } // TestRequestEditHandler_AccessDenied verifies that a user cannot create an edit // request for a booking they don't own. func TestRequestEditHandler_AccessDenied(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } // Verify no edit request was created var erCount int 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) } if erCount != 0 { t.Errorf("expected 0 edit requests, got %d", erCount) } } // TestRequestEditHandler_CompletedBooking verifies that a user cannot request // an edit for a completed booking. func TestRequestEditHandler_CompletedBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _, 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) } handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Should be blocked", } 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()) } if !strings.Contains(w.Body.String(), "completed") && !strings.Contains(w.Body.String(), "cancelled") { t.Errorf("expected error about completed/cancelled booking, got: %s", w.Body.String()) } } // TestRequestEditHandler_CancelledBooking verifies that a user cannot request // an edit for a cancelled booking. func TestRequestEditHandler_CancelledBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _, 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) } handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Should be blocked", } 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()) } if !strings.Contains(w.Body.String(), "cancelled") { t.Errorf("expected error about cancelled booking, got: %s", w.Body.String()) } } // TestRequestEditHandler_EmptyBody verifies that requesting an edit with no // changes (empty body) returns 400 Bad Request. func TestRequestEditHandler_EmptyBody(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) handler := http.HandlerFunc(RequestEditHandler) 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()) } } // 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) { ctx, tx := testutils.SetupTestTx(t) userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID // Create first edit request firstNotes := "First request: change time" handler := http.HandlerFunc(RequestEditHandler) reqBody1 := map[string]interface{}{ "notes": firstNotes, } 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()) } // Create second edit request (replaces first) secondNotes := "Second request: different notes" newStartTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location()) reqBody2 := map[string]interface{}{ "notes": secondNotes, "new_start_time": newStartTime.Format(time.RFC3339), } 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 := tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1 AND requested_by = $2", bookingID, userID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 1 { t.Errorf("expected 1 edit request after upsert, got %d", erCount) } // Verify the second request's data is what's stored (the old one was replaced) var dbNotes string var dbNewTime *time.Time 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 { t.Fatalf("failed to query edit request: %v", err) } if dbNotes != secondNotes { t.Errorf("expected notes %q, got %q", secondNotes, dbNotes) } if dbNewTime == nil { t.Error("expected new_start_time to be set") } else if !dbNewTime.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected new_start_time %v, got %v", newStartTime, *dbNewTime) } } // TestRequestEditHandler_BookingNotFound verifies that requesting an edit for // a non-existent booking returns 404. func TestRequestEditHandler_BookingNotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test 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) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Some change", } 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) } } // TestRequestEditHandler_WithServices verifies that a user can request a // services change (new_services) along with a time change. func TestRequestEditHandler_WithServices(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) serviceID2, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create second service: %v", err) } newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), "new_services": []string{serviceID2}, } 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()) } var editReq BookingEditRequest if err = parseResponseBody(w, &editReq); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(editReq.NewServices) != 1 || editReq.NewServices[0] != serviceID2 { t.Errorf("expected new_services [%s], got %v", serviceID2, editReq.NewServices) } // Verify time_blocker duration uses new service duration var blockerDuration int 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 { t.Fatalf("failed to query time_blocker duration: %v", err) } if blockerDuration < 30 { t.Errorf("expected blocker duration >= 30 (service duration), got %d", blockerDuration) } } // TestRequestEditHandler_ServicesOnBookingWithOverrides verifies that a user // cannot change services on a booking that has override prices/durations. func TestRequestEditHandler_ServicesOnBookingWithOverrides(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, 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(tx) if err != nil { t.Fatalf("failed to create second service: %v", err) } handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_services": []string{serviceID2}, } 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()) } } // ============================================================================= // 2. User Deletes Edit Request (DeleteEditRequestHandler) // ============================================================================= // TestDeleteEditRequestHandler_Success verifies that a user can delete their // pending edit request and the associated time_blocker + admin notification // are removed. func TestDeleteEditRequestHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createHandler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } 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 := tx.QueryRow(ctx, "SELECT id FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerID) if err != nil { t.Fatalf("expected time_blocker to exist: %v", err) } // Delete the edit request delHandler := http.HandlerFunc(DeleteEditRequestHandler) 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()) } // Verify edit request deleted from DB var erCount int 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) } if erCount != 0 { t.Errorf("expected 0 edit requests after delete, got %d", erCount) } // Verify time_blocker was deleted 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) } if erCount != 0 { t.Errorf("expected time_blocker to be deleted, but still exists") } // Verify admin notification was deleted err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query admin_notifications: %v", err) } if erCount != 0 { t.Errorf("expected admin_notification to be deleted, but still exists") } } // TestDeleteEditRequestHandler_NoEditRequest verifies that deleting a // non-existent edit request returns 404. func TestDeleteEditRequestHandler_NoEditRequest(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) delHandler := http.HandlerFunc(DeleteEditRequestHandler) 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()) } } // TestDeleteEditRequestHandler_AccessDenied verifies that one user cannot // delete another user's edit request. func TestDeleteEditRequestHandler_AccessDenied(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Other user tries to delete otherToken := jwt.GenerateUserToken(otherUserID) delHandler := http.HandlerFunc(DeleteEditRequestHandler) 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()) } // Verify edit request still exists var erCount int 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) } if erCount != 1 { t.Errorf("expected 1 edit request to remain, got %d", erCount) } } // TestDeleteEditRequestHandler_BookingNotFound verifies that deleting an edit // request for a non-existent booking returns 404. func TestDeleteEditRequestHandler_BookingNotFound(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) } token := jwt.GenerateUserToken(userID) delHandler := http.HandlerFunc(DeleteEditRequestHandler) 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) } } // ============================================================================= // 3. User Views Their Edit Request (GetMyEditRequestHandler) // ============================================================================= // TestGetMyEditRequestHandler_Success verifies that a user can view their // pending edit request for a specific booking. func TestGetMyEditRequestHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) // Create edit request first createHandler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), "notes": "View test notes", } 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) } // Now view it via chi router (to handle URL params) viewHandler := http.HandlerFunc(GetMyEditRequestHandler) w = serveChiHandler(viewHandler, "GET", "/api/bookings/"+bookingID+"/edit-request", "/api/bookings/{id}/edit-request", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { EditRequest *EnrichedEditRequest `json:"edit_request"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.EditRequest == nil { t.Fatal("expected edit_request in response") } if resp.EditRequest.BookingID != bookingID { t.Errorf("expected booking_id %s, got %s", bookingID, resp.EditRequest.BookingID) } if resp.EditRequest.Notes == nil || *resp.EditRequest.Notes != "View test notes" { t.Errorf("expected notes 'View test notes', got %v", resp.EditRequest.Notes) } if resp.EditRequest.Original == nil || resp.EditRequest.Proposed == nil { t.Error("expected original and proposed snapshots") } if resp.EditRequest.User == nil { t.Error("expected user summary in enriched response") } } // TestGetMyEditRequestHandler_NotFound verifies that viewing a non-existent // edit request returns 404. func TestGetMyEditRequestHandler_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) viewHandler := http.HandlerFunc(GetMyEditRequestHandler) w := serveChiHandler(viewHandler, "GET", "/api/bookings/"+bookingID+"/edit-request", "/api/bookings/{id}/edit-request", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } if !strings.Contains(w.Body.String(), `"edit_request":null`) && !strings.Contains(w.Body.String(), `"edit_request": null`) { t.Errorf("expected edit_request to be null in response, got: %s", w.Body.String()) } } // TestGetMyEditRequestHandler_AccessDenied verifies that a user cannot view // another user's edit request. func TestGetMyEditRequestHandler_AccessDenied(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Other user tries to view otherToken := jwt.GenerateUserToken(otherUserID) viewHandler := http.HandlerFunc(GetMyEditRequestHandler) w = serveChiHandler(viewHandler, "GET", "/api/bookings/"+bookingID+"/edit-request", "/api/bookings/{id}/edit-request", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(otherToken); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } } // ============================================================================= // 4. User Lists Their Edit Requests (GetMyEditRequestsHandler) // ============================================================================= // TestGetMyEditRequestsHandler_Success verifies that a user can list all their // pending edit requests across bookings. func TestGetMyEditRequestsHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) // Create a second booking with edit request (within 48h to avoid auto-approval) booking2Time := clock.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID2, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, booking2Time) if err != nil { t.Fatalf("failed to create second booking: %v", err) } _, 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) } // Create edit requests for both bookings 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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request for %s: %d", bid, w.Code) } } // List all edit requests listHandler := http.HandlerFunc(GetMyEditRequestsHandler) w := serveChiHandler(listHandler, "GET", "/api/bookings/edit-requests", "/api/bookings/edit-requests", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { EditRequests []*EnrichedEditRequest `json:"edit_requests"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.EditRequests) != 2 { t.Errorf("expected 2 edit requests, got %d", len(resp.EditRequests)) } } // TestGetMyEditRequestsHandler_Empty verifies that listing edit requests for a // user with no requests returns an empty list. func TestGetMyEditRequestsHandler_Empty(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) listHandler := http.HandlerFunc(GetMyEditRequestsHandler) w := serveChiHandler(listHandler, "GET", "/api/bookings/edit-requests", "/api/bookings/edit-requests", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { EditRequests []*EnrichedEditRequest `json:"edit_requests"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.EditRequests == nil { t.Fatal("expected non-nil empty edit_requests list") } if len(resp.EditRequests) != 0 { t.Errorf("expected 0 edit requests, got %d", len(resp.EditRequests)) } } // ============================================================================= // 5. Admin Lists All Edit Requests (AdminListEditRequestsHandler) // ============================================================================= // TestAdminListEditRequestsHandler_Success verifies that the admin can list // all edit requests with pagination metadata (requests + total). func TestAdminListEditRequestsHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Admin lists all listHandler := http.HandlerFunc(AdminListEditRequestsHandler) adminToken := jwt.GenerateAdminToken() w = serveChiHandler(listHandler, "GET", "/api/admin/bookings/edit-requests", "/api/admin/bookings/edit-requests", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(adminToken); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { Requests []BookingEditRequest `json:"requests"` Total int `json:"total"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Total < 1 { t.Errorf("expected total >= 1, got %d", resp.Total) } if len(resp.Requests) < 1 { t.Errorf("expected at least 1 request, got %d", len(resp.Requests)) } // Verify joined booking and user info if resp.Requests[0].Booking == nil { t.Error("expected booking info to be joined") } if resp.Requests[0].User == nil { t.Error("expected user info to be joined") } } // ============================================================================= // 6. Admin Lists All Enriched Edit Requests (AdminListAllEditRequestsHandler) // ============================================================================= // TestAdminListAllEditRequestsHandler_Success verifies AdminListAllEditRequestsHandler // returns all edit requests as enriched objects. func TestAdminListAllEditRequestsHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Admin lists all listHandler := http.HandlerFunc(AdminListAllEditRequestsHandler) w = serveAdminHandler(listHandler, "GET", "/api/admin/bookings/edit-requests", "/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()) } var resp struct { EditRequests []*EnrichedEditRequest `json:"edit_requests"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.EditRequests) < 1 { t.Errorf("expected at least 1 edit request, got %d", len(resp.EditRequests)) } if resp.EditRequests[0].Original == nil || resp.EditRequests[0].Proposed == nil { t.Error("expected enriched edit request with original and proposed snapshots") } } // ============================================================================= // 7. Admin Gets Edit Request for Booking (AdminGetBookingEditRequestHandler) // ============================================================================= // TestAdminGetBookingEditRequestHandler_Success verifies the admin can view // the pending edit request for a specific booking. func TestAdminGetBookingEditRequestHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _ = userID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) // Create edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), "notes": "Admin view test", }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Admin views it viewHandler := http.HandlerFunc(AdminGetBookingEditRequestHandler) w = serveAdminHandler(viewHandler, "GET", "/api/admin/bookings/"+bookingID+"/edit-request", "/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()) } var resp struct { EditRequest *EnrichedEditRequest `json:"edit_request"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.EditRequest == nil { t.Fatal("expected edit_request in response") } if resp.EditRequest.BookingID != bookingID { t.Errorf("expected booking_id %s, got %s", bookingID, resp.EditRequest.BookingID) } } // TestAdminGetBookingEditRequestHandler_NotFound verifies the admin gets 404 // when there is no edit request for the given booking. func TestAdminGetBookingEditRequestHandler_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // 8. Admin Approves Edit Request (AdminApproveEditRequestHandler) // ============================================================================= // TestAdminApproveEditRequestHandler_TimeChange verifies that approving an // edit request with a new start time updates the booking and cleans up // the edit request + time_blocker. func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) // Create edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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) } // Get edit request ID 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, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking start_time was updated 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(newStartTime) { t.Errorf("expected booking start_time %v, got %v", newStartTime, dbStartTime) } // Verify edit request was deleted var erCount int 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) } if erCount != 0 { t.Errorf("expected edit request to be deleted, got count %d", erCount) } // Verify time_blocker was deleted err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&erCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if erCount != 0 { t.Errorf("expected time_blocker to be deleted, got count %d", erCount) } // Verify admin notification was acknowledged var ackCount int 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 { t.Fatalf("failed to query admin_notifications: %v", err) } if ackCount != 1 { t.Errorf("expected 1 acknowledged notification, got %d", ackCount) } } // TestAdminApproveEditRequestHandler_WithServices verifies that approving an // edit request with new_services replaces the booking's services. func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) serviceID2, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create second service: %v", err) } newStartTime := clock.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, ctx, tx, bookingID, userID, &newStartTime, []string{serviceID2}, nil) _ = serviceID _ = token var dbReqID string 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) } 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, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } var serviceCount int 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) } if serviceCount != 1 { t.Errorf("expected 1 booking_service after approval, got %d", serviceCount) } var actualServiceID string 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) } if actualServiceID != serviceID2 { t.Errorf("expected service_id %s, got %s", serviceID2, actualServiceID) } 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(newStartTime) { t.Errorf("expected booking start_time %v, got %v", newStartTime, dbStartTime) } } // TestAdminApproveEditRequestHandler_NotFound verifies that approving a // non-existent edit request returns 404. func TestAdminApproveEditRequestHandler_NotFound(t *testing.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, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "Edit request not found") { t.Errorf("expected 'Edit request not found' error, got: %s", w.Body.String()) } } // ============================================================================= // 9. Admin Rejects Edit Request (AdminRejectEditRequestHandler) // ============================================================================= // TestAdminRejectEditRequestHandler_Success verifies that rejecting an edit // request removes it, cleans up the time_blocker, and acknowledges the // admin notification. func TestAdminRejectEditRequestHandler_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) // Create edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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, ctx, tx, bookingID) // Verify time_blocker exists var blockerID string err := tx.QueryRow(ctx, "SELECT id FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerID) if err != nil { t.Fatalf("expected time_blocker to exist before reject: %v", err) } // Admin rejects 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, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was deleted var erCount int 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) } if erCount != 0 { t.Errorf("expected edit request to be deleted, got count %d", erCount) } // Verify time_blocker was deleted 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) } if erCount != 0 { t.Errorf("expected time_blocker to be deleted, got count %d", erCount) } // Verify admin notification was acknowledged var ackCount int 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 { t.Fatalf("failed to query admin_notifications: %v", err) } if ackCount != 1 { t.Errorf("expected 1 acknowledged notification, got %d", ackCount) } // Verify booking start_time was NOT changed var dbStartTime time.Time var originalStartTime time.Time // Get original start time from before _ = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime) 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.Equal(originalStartTime) { t.Errorf("expected booking start_time to remain unchanged, got %v", dbStartTime) } } // TestAdminRejectEditRequestHandler_NotFound verifies that rejecting a // non-existent edit request returns 404. func TestAdminRejectEditRequestHandler_NotFound(t *testing.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, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "Edit request not found") { t.Errorf("expected 'Edit request not found' error, got: %s", w.Body.String()) } } // ============================================================================= // 10. Time-Blocker Lifecycle // ============================================================================= // 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) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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 time_blocker exists with correct description var description string var blockerStartTime time.Time var durationMinutes int 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) if err != nil { t.Fatalf("expected time_blocker to exist: %v", err) } if !blockerStartTime.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected blocker start_time %v, got %v", newStartTime, blockerStartTime) } if durationMinutes <= 0 { t.Errorf("expected positive duration_minutes, got %d", durationMinutes) } } // TestRequestEditHandler_TimeBlockerNotCreatedForNotesOnly verifies that a // notes-only edit request does NOT create a time_blocker. func TestRequestEditHandler_TimeBlockerNotCreatedForNotesOnly(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&count) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if count != 0 { t.Errorf("expected 0 time_blockers for notes-only request, got %d", count) } } // TestRequestEditHandler_TimeBlockerReplacedOnUpsert verifies that when an // 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) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) time1 := clock.Now().Add(48 * time.Hour).Truncate(time.Second) time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location()) time2 := clock.Now().Add(72 * time.Hour).Truncate(time.Second) time2 = time.Date(time2.Year(), time2.Month(), time2.Day(), 15, 0, 0, 0, time2.Location()) handler := http.HandlerFunc(RequestEditHandler) // Create first edit request w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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 := 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 { t.Fatalf("failed to query time_blockers: %v", err) } if count != 1 { t.Errorf("expected 1 time_blocker for time1, got %d", count) } // Replace with second time w = makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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 = 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 { t.Fatalf("failed to query old time_blockers: %v", err) } if count != 0 { t.Errorf("expected old time_blocker to be deleted, got count %d", count) } // Verify new time_blocker exists for time2 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 { t.Fatalf("failed to query new time_blockers: %v", err) } if count != 1 { t.Errorf("expected 1 time_blocker for time2, got %d", count) } } // ============================================================================= // 11. Working Hours Validation on Approve // ============================================================================= // TestAdminApproveEditRequestHandler_WithNotesOnly verifies that approving a // notes-only edit request updates the booking notes. func TestAdminApproveEditRequestHandler_WithNotesOnly(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _ = userID notes := "Updated notes from edit request" // Create notes-only edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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, 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, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking notes were updated var dbNotes string 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) } if dbNotes != notes { t.Errorf("expected booking notes %q, got %q", notes, dbNotes) } } // TestAdminApproveEditRequestHandler_OverlapWithBooking verifies that approving // an edit request that would cause a time overlap returns 409. func TestAdminApproveEditRequestHandler_OverlapWithBooking(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) } // Get service duration for overlap calculation var serviceDuration int 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) } token := jwt.GenerateUserToken(userID) // Create first booking at time T // Use a start time <48h away so auto-approval doesn't trigger at request- // creation time, allowing us to test the approval-time overlap check. baseTime := clock.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(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking1: %v", err) } _, 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(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking2: %v", err) } // Set booking2 start time during booking1's slot (overlap) _, 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 { t.Fatalf("failed to update booking2: %v", err) } // Create an edit request for booking2 proposing a move to a time that // overlaps booking1. The request is <48h away so auto-approval skips the // creation-time overlap check; the request is stored as pending. handler := http.HandlerFunc(RequestEditHandler) 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, 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, 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, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 (overlap conflict), got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove verifies // that approving an edit request removes the associated time_blocker. func TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID _ = userID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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, 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, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&count) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if count != 0 { t.Errorf("expected time_blocker to be deleted after approve, got count %d", count) } } // TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject verifies // that rejecting an edit request removes the associated time_blocker. func TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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, 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, ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d. body: %s", w.Code, w.Body.String()) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&count) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if count != 0 { t.Errorf("expected time_blocker to be deleted after reject, got count %d", count) } } // ============================================================================= // 12. UserCancelBookingHandler cleans up edit requests // ============================================================================= // TestUserCancelBookingHandler_CleansUpEditRequest verifies that when a user // 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) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) // 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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Verify edit request exists var erCount int 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) } if erCount != 1 { t.Fatalf("expected 1 edit request before cancel, got %d", erCount) } // Verify time_blocker exists var blockerCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if blockerCount != 1 { t.Fatalf("expected 1 time_blocker before cancel, got %d", blockerCount) } // Verify admin_notification exists var notifCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin_notifications: %v", err) } if notifCount != 1 { t.Fatalf("expected 1 admin_notification before cancel, got %d", notifCount) } // Cancel the booking cancelHandler := http.HandlerFunc(UserCancelBookingHandler) 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 = 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) } if erCount != 0 { t.Errorf("expected 0 edit requests after cancel, got %d", erCount) } // Verify time_blocker was deleted err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { t.Fatalf("failed to query time_blockers after cancel: %v", err) } if blockerCount != 0 { t.Errorf("expected 0 time_blockers after cancel, got %d", blockerCount) } // Verify admin_notification was deleted err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin_notifications after cancel: %v", err) } if notifCount != 0 { t.Errorf("expected 0 admin_notifications after cancel, got %d", notifCount) } } // ============================================================================= // 13. Working Hours Validation — Exceptional Closed Hours Block on Approve // ============================================================================= // TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours verifies // that approving an edit request whose proposed time falls during exceptional // closed hours returns 409 Conflict. func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testing.T) { ctx, tx := testutils.SetupTestTx(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 := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id `, "Holiday Closure", "Test holiday").Scan(&groupID) if err != nil { t.Fatalf("failed to create holiday group: %v", err) } 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 = 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) if err != nil { t.Fatalf("failed to create holiday hours: %v", err) } // Apply the group to the week containing targetDate daysToMonday := int(targetDate.Weekday()) if daysToMonday == 0 { daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) if err != nil { t.Fatalf("failed to create holiday application: %v", err) } // Create edit request for a time during the closed period 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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } 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, ctx) if w.Code != http.StatusConflict { t.Fatalf("expected 409 Conflict for closed hours, got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "closed") { t.Errorf("expected error about closed hours, got: %s", w.Body.String()) } // Verify edit request still exists (not consumed by failed approve) var erCount int 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) } if erCount != 1 { t.Errorf("expected edit request to still exist after failed approve, got count %d", erCount) } } // ============================================================================= // 14. Enriched Response — End-Time Calculation Correctness // ============================================================================= // TestGetMyEditRequestHandler_EndTimeCalculation verifies that the enriched // 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) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) // Get service duration var serviceDuration int 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) } // Create edit request with time change newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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) } // View it viewHandler := http.HandlerFunc(GetMyEditRequestHandler) w = serveChiHandler(viewHandler, "GET", "/api/bookings/"+bookingID+"/edit-request", "/api/bookings/{id}/edit-request", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { EditRequest *EnrichedEditRequest `json:"edit_request"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } er := resp.EditRequest if er == nil || er.Original == nil || er.Proposed == nil { t.Fatal("expected enriched response with original and proposed snapshots") } // Original end_time should be original start_time + service duration origEndExpected := er.Original.StartTime.Add(time.Duration(serviceDuration) * time.Minute) if !er.Original.EndTime.Truncate(time.Second).Equal(origEndExpected.Truncate(time.Second)) { t.Errorf("original end_time: expected %v, got %v", origEndExpected, er.Original.EndTime) } // Proposed end_time should be proposed start_time + service duration propEndExpected := er.Proposed.StartTime.Add(time.Duration(serviceDuration) * time.Minute) if !er.Proposed.EndTime.Truncate(time.Second).Equal(propEndExpected.Truncate(time.Second)) { t.Errorf("proposed end_time: expected %v, got %v", propEndExpected, er.Proposed.EndTime) } } // ============================================================================= // 15. Cross-User Isolation for GetMyEditRequestsHandler // ============================================================================= // TestGetMyEditRequestsHandler_CrossUserIsolation verifies that user A's // edit requests do not appear in user B's list. func TestGetMyEditRequestsHandler_CrossUserIsolation(t *testing.T) { ctx, tx := testutils.SetupTestTx(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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Other user lists their edit requests — should be empty otherToken := jwt.GenerateUserToken(otherUserID) listHandler := http.HandlerFunc(GetMyEditRequestsHandler) w = serveChiHandler(listHandler, "GET", "/api/bookings/edit-requests", "/api/bookings/edit-requests", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(otherToken); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { EditRequests []*EnrichedEditRequest `json:"edit_requests"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.EditRequests) != 0 { t.Errorf("expected 0 edit requests for other user, got %d", len(resp.EditRequests)) } // Owner lists their edit requests — should see 1 w = serveChiHandler(listHandler, "GET", "/api/bookings/edit-requests", "/api/bookings/edit-requests", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(ownerToken); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse owner response: %v", err) } if len(resp.EditRequests) != 1 { t.Errorf("expected 1 edit request for owner, got %d", len(resp.EditRequests)) } } // ============================================================================= // 16. AdminGetBookingEditRequestHandler returns enriched data // ============================================================================= // TestAdminGetBookingEditRequestHandler_EnrichedData verifies the admin view // returns full enriched data with original/proposed snapshots and user summary. func TestAdminGetBookingEditRequestHandler_EnrichedData(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _ = serviceID newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) // Create edit request createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), "notes": "Admin enriched test", }, token, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request: %d", w.Code) } // Admin views it viewHandler := http.HandlerFunc(AdminGetBookingEditRequestHandler) w = serveAdminHandler(viewHandler, "GET", "/api/admin/bookings/"+bookingID+"/edit-request", "/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()) } var resp struct { EditRequest *EnrichedEditRequest `json:"edit_request"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } er := resp.EditRequest if er == nil { t.Fatal("expected edit_request in response") } if er.BookingID != bookingID { t.Errorf("expected booking_id %s, got %s", bookingID, er.BookingID) } if er.Original == nil { t.Error("expected original snapshot to be populated") } if er.Proposed == nil { t.Error("expected proposed snapshot to be populated") } if er.User == nil { t.Error("expected user summary to be populated") } if er.User.FullName == "" { t.Error("expected user full_name to be non-empty") } if er.Notes == nil || *er.Notes != "Admin enriched test" { t.Errorf("expected notes 'Admin enriched test', got %v", er.Notes) } // Verify proposed start_time matches the requested time if er.Proposed.StartTime == nil { t.Fatal("expected proposed start_time to be set") } if !er.Proposed.StartTime.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected proposed start_time %v, got %v", newStartTime, er.Proposed.StartTime) } } // ============================================================================= // 17. buildEnrichedEditRequest — has_overrides=true uses original services // ============================================================================= // TestGetMyEditRequestHandler_WithOverrides verifies that when a booking has // override prices/durations, the enriched response uses original services for // both original and proposed snapshots (has_overrides branch). func TestGetMyEditRequestHandler_WithOverrides(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) // Add an override to the booking service _, 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: %v", err) } // Create edit request (time change only — services change blocked for overrides, // but time change is allowed) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createHandler := http.HandlerFunc(RequestEditHandler) w := makeRequest(createHandler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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) } // View it viewHandler := http.HandlerFunc(GetMyEditRequestHandler) w = serveChiHandler(viewHandler, "GET", "/api/bookings/"+bookingID+"/edit-request", "/api/bookings/{id}/edit-request", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { EditRequest *EnrichedEditRequest `json:"edit_request"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } er := resp.EditRequest if er == nil || er.Original == nil || er.Proposed == nil { t.Fatal("expected enriched response") } // Both original and proposed should have services (from booking_services with overrides) if len(er.Original.Services) == 0 { t.Error("expected original services to be populated") } if len(er.Proposed.Services) == 0 { t.Error("expected proposed services to be populated") } // The override price should be reflected (75.00 instead of default) if er.Original.Services[0].Price != 75.00 { t.Errorf("expected original service price 75.00 (override), got %v", er.Original.Services[0].Price) } if er.Original.Services[0].DurationMinutes != 90 { t.Errorf("expected original service duration 90 (override), got %d", er.Original.Services[0].DurationMinutes) } } // ============================================================================= // 18. AdminListEditRequestsHandler pagination params // ============================================================================= // TestAdminListEditRequestsHandler_Pagination verifies that the admin list // endpoint returns correct total count for pagination metadata. func TestAdminListEditRequestsHandler_Pagination(t *testing.T) { ctx, tx := testutils.SetupTestTx(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 := clock.Now().Add(36 * time.Hour).Truncate(time.Second) newBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } _, 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) } bookingIDs = append(bookingIDs, newBookingID) } // Create 1 edit request per booking (5 total) 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, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create edit request %d: %d", i, w.Code) } } // Admin lists all — verifies total count is correct for frontend pagination listHandler := http.HandlerFunc(AdminListEditRequestsHandler) adminToken := jwt.GenerateAdminToken() w := serveChiHandler(listHandler, "GET", "/api/admin/bookings/"+bookingID+"/edit-requests", "/api/admin/bookings/{id}/edit-requests", nil, func(ctx context.Context) context.Context { if info := extractUserFromTestJWT(adminToken); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) 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()) } var resp struct { Requests []BookingEditRequest `json:"requests"` Total int `json:"total"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } // Total should reflect all 5 edit requests across bookings if resp.Total != 5 { t.Errorf("expected total 5, got %d", resp.Total) } if len(resp.Requests) != 5 { t.Errorf("expected 5 requests, got %d", len(resp.Requests)) } } // ============================================================================= // 19. Notification Upsert on Edit Request Replace // ============================================================================= // TestRequestEditHandler_NotificationUpsertOnReplace verifies that when a user // 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) { ctx, tx := testutils.SetupTestTx(t) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx) time1 := clock.Now().Add(48 * time.Hour).Truncate(time.Second) time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location()) time2 := clock.Now().Add(72 * time.Hour).Truncate(time.Second) time2 = time.Date(time2.Year(), time2.Month(), time2.Day(), 15, 0, 0, 0, time2.Location()) handler := http.HandlerFunc(RequestEditHandler) // Create first edit request w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", 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 := tx.QueryRow(ctx, "SELECT created_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(&firstCreatedAt) if err != nil { t.Fatalf("expected first notification to exist: %v", err) } var notifCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to count notifications: %v", err) } if notifCount != 1 { t.Fatalf("expected 1 notification after first request, got %d", notifCount) } // Wait a moment so timestamps differ time.Sleep(100 * time.Millisecond) // 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, 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 = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to count notifications after upsert: %v", err) } if notifCount != 1 { t.Errorf("expected 1 notification after upsert, got %d", notifCount) } // Verify the notification has a fresh created_at (newer than original) var secondCreatedAt time.Time err = tx.QueryRow(ctx, "SELECT created_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'", bookingID).Scan(&secondCreatedAt) if err != nil { t.Fatalf("expected notification to exist after upsert: %v", err) } } // 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) { ctx, tx := testutils.SetupTestTx(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 := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Holiday', 'Closed') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create group: %v", err) } dbWeekday := (int(targetDate.Weekday()) + 6) % 7 _, 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) if err != nil { t.Fatalf("failed to create holiday hours: %v", err) } daysToMonday := int(targetDate.Weekday()) if daysToMonday == 0 { daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) if err != nil { t.Fatalf("failed to create holiday application: %v", err) } // Create edit request for that date newTime := targetDate.Add(14 * time.Hour).Truncate(time.Minute) 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, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 (conflict), got %d. body: %s", w.Code, w.Body.String()) } }