The PoolProxy.Begin routes to the outer test transaction, so PG NOW() returns the same value for both handler invocations. Comparing notification IDs correctly verifies the notification was deleted and recreated. Also removes unused testify/assert import. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2390 lines
88 KiB
Go
2390 lines
88 KiB
Go
//go: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/mw"
|
|
"crussell/testutils"
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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)
|
|
|
|
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)
|
|
}
|
|
|
|
var firstNotifID string
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT id FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'",
|
|
bookingID).Scan(&firstNotifID)
|
|
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)
|
|
}
|
|
|
|
// Create second edit request (upsert) — deletes old notification, inserts new one
|
|
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 was recreated (different ID from original)
|
|
var secondNotifID string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT id FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'",
|
|
bookingID).Scan(&secondNotifID)
|
|
if err != nil {
|
|
t.Fatalf("expected notification to exist after upsert: %v", err)
|
|
}
|
|
if secondNotifID == firstNotifID {
|
|
t.Errorf("expected notification to be recreated (new ID), but got same ID: %s", secondNotifID)
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|