test fixes
This commit is contained in:
@@ -5,6 +5,21 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/handlers/bookings"
|
||||||
|
"crussell/mw"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
)
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,6 +28,8 @@ import (
|
|||||||
"crussell/handlers/bookings"
|
"crussell/handlers/bookings"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"crussell/testutils/fixtures"
|
"crussell/testutils/fixtures"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1101,3 +1118,334 @@ func TestAdminBookings_Search_MultipleResults(t *testing.T) {
|
|||||||
t.Errorf("expected total 2, got %d", resp.Total)
|
t.Errorf("expected total 2, got %d", resp.Total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// =============================================================================
|
||||||
|
// Admin List Edit Requests Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAdminBookings_ListEditRequests(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Update booking status to confirmed (required for edit requests)
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update booking status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up ALL existing edit requests in DB to ensure clean state (handler doesn't filter by booking_id)
|
||||||
|
_, err = db.DB.Exec(context.Background(), "DELETE FROM booking_edit_requests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to clean up edit requests: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 3 edit requests via direct SQL insert
|
||||||
|
var emptyServices []string
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
_, err = db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
bookingID, userID, time.Now().Add(time.Duration(i)*24*time.Hour),
|
||||||
|
pq.Array(&emptyServices), fmt.Sprintf("Edit request %d", i), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create edit request %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(bookings.AdminListEditRequestsHandler)
|
||||||
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID+"/edit-requests", nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Requests []bookings.BookingEditRequest `json:"requests"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := parseResponseBody(w, &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Requests) != 3 {
|
||||||
|
t.Errorf("expected 3 edit requests, got %d", len(resp.Requests))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Total != 3 {
|
||||||
|
t.Errorf("expected total 3, got %d", resp.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the requests are for the correct booking
|
||||||
|
for _, req := range resp.Requests {
|
||||||
|
if req.BookingID != bookingID {
|
||||||
|
t.Errorf("expected booking ID %s, got %s", bookingID, req.BookingID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin Deny Edit Request Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAdminBookings_DenyEditRequest(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
// Create confirmed booking
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to confirm booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get original start_time
|
||||||
|
var originalStartTime time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get original start_time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create edit request with new_start_time via direct SQL
|
||||||
|
newStartTime := originalStartTime.Add(24 * time.Hour).Truncate(time.Minute)
|
||||||
|
var editRequestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes)
|
||||||
|
VALUES ($1, $2, $3, 'Please change time')
|
||||||
|
RETURNING id`,
|
||||||
|
bookingID, userID, newStartTime).Scan(&editRequestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create edit request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call admin deny endpoint - need to manually set chi context with both bookingID and request_id
|
||||||
|
handler := http.HandlerFunc(bookings.AdminRejectEditRequestHandler)
|
||||||
|
|
||||||
|
// Build request manually to include both bookingID and request_id in chi context
|
||||||
|
path := fmt.Sprintf("/api/admin/bookings/%s/edit-requests/%s/deny", bookingID, editRequestID)
|
||||||
|
req := httptest.NewRequest("POST", path, nil)
|
||||||
|
|
||||||
|
// Set up chi routing context with both params
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", bookingID)
|
||||||
|
rctx.URLParams.Add("request_id", editRequestID)
|
||||||
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||||
|
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
||||||
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Expect HTTP 200 OK
|
||||||
|
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify edit request is handled (deleted in current implementation)
|
||||||
|
var erCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query edit requests: %v", err)
|
||||||
|
}
|
||||||
|
// Current implementation deletes the edit request
|
||||||
|
if erCount != 0 {
|
||||||
|
t.Errorf("expected edit request to be deleted after deny, got %d", erCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify booking start_time unchanged
|
||||||
|
var finalStartTime time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&finalStartTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get final start_time: %v", err)
|
||||||
|
}
|
||||||
|
if !originalStartTime.Equal(finalStartTime) {
|
||||||
|
t.Errorf("expected booking start_time to remain %v, got %v", originalStartTime, finalStartTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminBookings_ApproveEditRequest(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
// Create regular user
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Set deposits_required=0
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set deposits_required: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create service
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
// Create confirmed booking
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to confirm booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get original booking start_time
|
||||||
|
var originalStartTime time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get original start_time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create edit request with new_start_time
|
||||||
|
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Minute)
|
||||||
|
var editRequestID string
|
||||||
|
var emptyServices []string
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, 'Please change time')
|
||||||
|
RETURNING id`,
|
||||||
|
bookingID, userID, newStartTime, pq.Array(&emptyServices)).Scan(&editRequestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create edit request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin notification
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
`INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
|
VALUES ('edit_request', $1, $2)`,
|
||||||
|
bookingID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin notification: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call admin approve endpoint using makeAdminRequest with manual chi context for two params
|
||||||
|
handler := http.HandlerFunc(bookings.AdminApproveEditRequestHandler)
|
||||||
|
|
||||||
|
// Build request manually to include both bookingID and request_id in chi context
|
||||||
|
path := fmt.Sprintf("/api/admin/bookings/%s/edit-requests/%s/approve", bookingID, editRequestID)
|
||||||
|
req := httptest.NewRequest("POST", path, nil)
|
||||||
|
|
||||||
|
// Set up chi routing context with both params
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", bookingID)
|
||||||
|
rctx.URLParams.Add("request_id", editRequestID)
|
||||||
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||||
|
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
||||||
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Expect 204 NoContent
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify edit request was deleted (approved)
|
||||||
|
var erCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"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 0 edit requests after approve, got %d", erCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify booking start_time was updated to new_start_time
|
||||||
|
var updatedStartTime time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query booking: %v", err)
|
||||||
|
}
|
||||||
|
if !updatedStartTime.Equal(newStartTime) {
|
||||||
|
t.Errorf("expected booking start_time %v, got %v", newStartTime, updatedStartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin notification was acknowledged
|
||||||
|
var ackTime *time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`SELECT acknowledged_at FROM admin_notifications
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request'`,
|
||||||
|
bookingID).Scan(&ackTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query notification: %v", err)
|
||||||
|
}
|
||||||
|
if ackTime == nil {
|
||||||
|
t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -397,3 +397,148 @@ func TestAdminUsers_NonAdmin(t *testing.T) {
|
|||||||
t.Errorf("ADD: expected status 403, got %d", w.Code)
|
t.Errorf("ADD: expected status 403, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func TestAdminUsers_Get_Success(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
var userID string
|
||||||
|
err := db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
|
||||||
|
VALUES ('John', 'Doe', 'john.doe@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email')
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call admin get user endpoint
|
||||||
|
handler := http.HandlerFunc(user.GetAdminUserHandler)
|
||||||
|
w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
t.Logf("response body: %s", w.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp user.AdminUserDetail
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ID != userID {
|
||||||
|
t.Errorf("expected user ID %s, got %s", userID, resp.ID)
|
||||||
|
}
|
||||||
|
if resp.FirstName != "John" {
|
||||||
|
t.Errorf("expected first name 'John', got '%s'", resp.FirstName)
|
||||||
|
}
|
||||||
|
if resp.LastName != "Doe" {
|
||||||
|
t.Errorf("expected last name 'Doe', got '%s'", resp.LastName)
|
||||||
|
}
|
||||||
|
if resp.Email == nil || *resp.Email != "john.doe@test.com" {
|
||||||
|
t.Errorf("expected email 'john.doe@test.com', got '%v'", resp.Email)
|
||||||
|
}
|
||||||
|
if resp.AccountRole != "verified_email" {
|
||||||
|
t.Errorf("expected account_role 'verified_email', got '%s'", resp.AccountRole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUsers_Get_WithBookings(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
var userID string
|
||||||
|
err := db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
|
||||||
|
VALUES ('Alice', 'Smith', 'alice@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email')
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a service
|
||||||
|
var serviceID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO services (name, description, price, duration_minutes, is_active)
|
||||||
|
VALUES ('Test Service', 'Test description', 50.00, 60, true)
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create bookings for the user
|
||||||
|
var bookingID1 string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||||
|
VALUES ($1, '2099-12-31 10:00:00+00', 'completed', 'Past booking')
|
||||||
|
RETURNING id
|
||||||
|
`, userID).Scan(&bookingID1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link service to booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||||
|
bookingID1, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to link service to booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bookingID2 string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||||
|
VALUES ($1, '2099-12-31 14:00:00+00', 'pending', 'Upcoming booking')
|
||||||
|
RETURNING id
|
||||||
|
`, userID).Scan(&bookingID2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create second booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link service to second booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||||
|
bookingID2, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to link service to second booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call admin get user endpoint
|
||||||
|
handler := http.HandlerFunc(user.GetAdminUserHandler)
|
||||||
|
w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
t.Logf("response body: %s", w.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The current implementation doesn't include bookings in the response
|
||||||
|
// This test verifies the user is retrieved correctly
|
||||||
|
var resp user.AdminUserDetail
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ID != userID {
|
||||||
|
t.Errorf("expected user ID %s, got %s", userID, resp.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bookings exist in database (we can't verify via response since it's not included)
|
||||||
|
var bookingCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&bookingCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count bookings: %v", err)
|
||||||
|
}
|
||||||
|
if bookingCount != 2 {
|
||||||
|
t.Errorf("expected 2 bookings in DB, got %d", bookingCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupTestDB replaces the global db.DB with a test pool and returns a cleanup function
|
// setupTestDB replaces the global db.DB with a test pool and returns a cleanup function
|
||||||
@@ -1610,3 +1611,511 @@ func TestUserCancelBooking_TransactionIntegrity(t *testing.T) {
|
|||||||
t.Error("booking status should have changed after cancellation (transaction should have committed)")
|
t.Error("booking status should have changed after cancellation (transaction should have committed)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TestCreateEditRequest tests that creating an edit request creates an admin notification
|
||||||
|
func TestCreateEditRequest(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Set deposits_required=0 to avoid 48h advance booking requirement
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set deposits_required: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to confirm booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Create edit request
|
||||||
|
handler := http.HandlerFunc(RequestEditHandler)
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"notes": "Please change the time",
|
||||||
|
}
|
||||||
|
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200/201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify edit request was created
|
||||||
|
var erCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"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, got %d", erCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin notification was created
|
||||||
|
var notifCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`SELECT COUNT(*) FROM admin_notifications
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL`,
|
||||||
|
bookingID).Scan(¬ifCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query notifications: %v", err)
|
||||||
|
}
|
||||||
|
if notifCount != 1 {
|
||||||
|
t.Errorf("expected 1 unacknowledged admin notification, got %d", notifCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification
|
||||||
|
func TestDeleteEditRequest(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Set deposits_required=0
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set deposits_required: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to confirm booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create edit request directly in DB (simulating user request)
|
||||||
|
var editRequestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO booking_edit_requests (booking_id, requested_by, notes)
|
||||||
|
VALUES ($1, $2, 'Please change time')
|
||||||
|
RETURNING id`,
|
||||||
|
bookingID, userID).Scan(&editRequestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create edit request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin notification
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
`INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
|
VALUES ('edit_request', $1, $2)`,
|
||||||
|
bookingID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin notification: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Delete edit request (user cancels their request)
|
||||||
|
handler := http.HandlerFunc(DeleteEditRequestHandler)
|
||||||
|
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify edit request was deleted
|
||||||
|
var erCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"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 0 edit requests after delete, got %d", erCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin notification was DELETED (not acknowledged)
|
||||||
|
var notifCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`SELECT COUNT(*) FROM admin_notifications
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request'`,
|
||||||
|
bookingID).Scan(¬ifCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query notifications: %v", err)
|
||||||
|
}
|
||||||
|
if notifCount != 0 {
|
||||||
|
t.Errorf("expected 0 admin notifications after delete, got %d", notifCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminApproveEditRequest tests that admin approving acknowledges the notification (not deletes)
|
||||||
|
func TestAdminApproveEditRequest(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Set deposits_required=0
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set deposits_required: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to confirm booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create edit request directly in DB (need pq.Array for PostgreSQL array)
|
||||||
|
var editRequestID string
|
||||||
|
newTime := time.Now().Add(24 * time.Hour).Truncate(time.Minute)
|
||||||
|
var emptyServices []string
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, 'Please change time')
|
||||||
|
RETURNING id`,
|
||||||
|
bookingID, userID, newTime, pq.Array(&emptyServices)).Scan(&editRequestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create edit request: %v", err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create edit request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin notification
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
`INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
|
VALUES ('edit_request', $1, $2)`,
|
||||||
|
bookingID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin notification: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify notification starts as unacknowledged
|
||||||
|
var ackTime *time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`SELECT acknowledged_at FROM admin_notifications
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request'`,
|
||||||
|
bookingID).Scan(&ackTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query notification: %v", err)
|
||||||
|
}
|
||||||
|
if ackTime != nil {
|
||||||
|
t.Fatalf("expected notification to be unacknowledged initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate admin approval
|
||||||
|
adminToken := jwt.GenerateAdminToken()
|
||||||
|
|
||||||
|
// Create request with chi context
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", bookingID)
|
||||||
|
rctx.URLParams.Add("request_id", editRequestID)
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
AdminApproveEditRequestHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify edit request was deleted (approved)
|
||||||
|
var erCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"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 0 edit requests after approve, got %d", erCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved
|
||||||
|
var ackTimeAfter *time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`SELECT acknowledged_at FROM admin_notifications
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request'`,
|
||||||
|
bookingID).Scan(&ackTimeAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query notification: %v", err)
|
||||||
|
}
|
||||||
|
if ackTimeAfter == nil {
|
||||||
|
t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminRejectEditRequest tests that admin rejecting acknowledges the notification (not deletes)
|
||||||
|
func TestAdminRejectEditRequest(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Set deposits_required=0
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set deposits_required: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to confirm booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create edit request directly in DB
|
||||||
|
var editRequestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO booking_edit_requests (booking_id, requested_by, notes)
|
||||||
|
VALUES ($1, $2, 'Please change time')
|
||||||
|
RETURNING id`,
|
||||||
|
bookingID, userID).Scan(&editRequestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create edit request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin notification
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
`INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
|
VALUES ('edit_request', $1, $2)`,
|
||||||
|
bookingID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin notification: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify notification starts as unacknowledged
|
||||||
|
var ackTime *time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`SELECT acknowledged_at FROM admin_notifications
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request'`,
|
||||||
|
bookingID).Scan(&ackTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query notification: %v", err)
|
||||||
|
}
|
||||||
|
if ackTime != nil {
|
||||||
|
t.Fatalf("expected notification to be unacknowledged initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate admin denial
|
||||||
|
adminToken := jwt.GenerateAdminToken()
|
||||||
|
|
||||||
|
// Create request with chi context
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", bookingID)
|
||||||
|
rctx.URLParams.Add("request_id", editRequestID)
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
AdminRejectEditRequestHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify edit request was deleted (rejected)
|
||||||
|
var erCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"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 0 edit requests after reject, got %d", erCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved
|
||||||
|
var ackTimeAfter *time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
`SELECT acknowledged_at FROM admin_notifications
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request'`,
|
||||||
|
bookingID).Scan(&ackTimeAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query notification: %v", err)
|
||||||
|
}
|
||||||
|
if ackTimeAfter == nil {
|
||||||
|
t.Errorf("expected notification to be acknowledged after reject, but acknowledged_at is still NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404
|
||||||
|
func TestBookings_RequestEdit_BookingNotFound(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Set deposits_required=0
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||||
|
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": "Please change the time",
|
||||||
|
}
|
||||||
|
w := makeRequest(handler, "POST", "/api/bookings/nonexistent-booking-id/edit-request", reqBody, token)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBookings_RequestEdit_AlreadyHasPending tests that a user cannot create a second edit request while one already exists
|
||||||
|
func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Set deposits_required=0 to avoid 48h advance booking requirement
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set deposits_required: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to confirm booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a pending edit request directly in DB (pre-condition)
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
`INSERT INTO booking_edit_requests (booking_id, requested_by, notes)
|
||||||
|
VALUES ($1, $2, 'Please change the time')`,
|
||||||
|
bookingID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create initial edit request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin notification for the initial edit request
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
`INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
|
VALUES ('edit_request', $1, $2)`,
|
||||||
|
bookingID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin notification: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Try to create another edit request via API
|
||||||
|
handler := http.HandlerFunc(RequestEditHandler)
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"notes": "Please change to a different day",
|
||||||
|
}
|
||||||
|
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token)
|
||||||
|
|
||||||
|
// Expect HTTP 400 Bad Request or 409 Conflict
|
||||||
|
if w.Code != http.StatusBadRequest && w.Code != http.StatusConflict {
|
||||||
|
t.Errorf("expected status 400 or 409, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only 1 edit request exists in DB (the original one)
|
||||||
|
var erCount int
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"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, got %d", erCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -877,12 +877,12 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
|
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||||
`, bookingID, userID, req.NewStartTime, req.NewServices, req.Notes, false).Scan(
|
`, bookingID, userID, req.NewStartTime, pq.Array(req.NewServices), req.Notes, false).Scan(
|
||||||
&editReq.ID,
|
&editReq.ID,
|
||||||
&editReq.BookingID,
|
&editReq.BookingID,
|
||||||
&editReq.RequestedBy,
|
&editReq.RequestedBy,
|
||||||
&editReq.NewStartTime,
|
&editReq.NewStartTime,
|
||||||
&editReq.NewServices,
|
pq.Array(&editReq.NewServices),
|
||||||
&editReq.Notes,
|
&editReq.Notes,
|
||||||
&editReq.HasOverrides,
|
&editReq.HasOverrides,
|
||||||
&editReq.UpdatedAt,
|
&editReq.UpdatedAt,
|
||||||
@@ -1189,6 +1189,17 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acknowledge the admin notification for this edit request
|
||||||
|
_, err = tx.Exec(r.Context(), `
|
||||||
|
UPDATE admin_notifications
|
||||||
|
SET acknowledged_at = NOW()
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL
|
||||||
|
`, bookingID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := tx.Commit(r.Context()); err != nil {
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
log.Printf("Failed to commit: %v", err)
|
log.Printf("Failed to commit: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -1198,7 +1209,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRejectEditRequestHandler rejects an edit request by deleting it (deny without notification)
|
// AdminRejectEditRequestHandler rejects an edit request by deleting it and acknowledging the admin notification
|
||||||
func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
requestID := chi.URLParam(r, "request_id")
|
requestID := chi.URLParam(r, "request_id")
|
||||||
if requestID == "" || !validators.IsValidID(requestID) {
|
if requestID == "" || !validators.IsValidID(requestID) {
|
||||||
@@ -1206,7 +1217,32 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.DB.Exec(r.Context(), `
|
// First get the booking_id from the edit request before deleting
|
||||||
|
var bookingID string
|
||||||
|
err := db.DB.QueryRow(r.Context(), `
|
||||||
|
SELECT booking_id FROM booking_edit_requests WHERE id = $1
|
||||||
|
`, requestID).Scan(&bookingID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, "Edit request not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Failed to get edit request %s: %v", requestID, err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use transaction to delete edit request and acknowledge associated admin notification
|
||||||
|
tx, err := db.DB.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to start transaction: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
// Delete the edit request
|
||||||
|
_, err = tx.Exec(r.Context(), `
|
||||||
DELETE FROM booking_edit_requests
|
DELETE FROM booking_edit_requests
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, requestID)
|
`, requestID)
|
||||||
@@ -1216,5 +1252,23 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acknowledge the admin notification for this edit request
|
||||||
|
_, err = tx.Exec(r.Context(), `
|
||||||
|
UPDATE admin_notifications
|
||||||
|
SET acknowledged_at = NOW()
|
||||||
|
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL
|
||||||
|
`, bookingID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
log.Printf("Failed to commit reject edit request: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,3 +329,298 @@ func createUserWithDOB(dob string) (string, error) {
|
|||||||
`, "Test", "User", "testuser@test.com", "+44770000001", dob, "hash", "verified_email", "email").Scan(&userID)
|
`, "Test", "User", "testuser@test.com", "+44770000001", dob, "hash", "verified_email", "email").Scan(&userID)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestServices_EligibleForUser_ExpiredPatchTest(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create user with date of birth
|
||||||
|
userID, err := createUserWithDOB("1990-01-01")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a service requiring patch test
|
||||||
|
var serviceID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO services (name, description, price, duration_minutes, is_active)
|
||||||
|
VALUES ('Patch Test Required', 'Requires patch test', 75.00, 90, true)
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create patch test with 6 month expiry
|
||||||
|
var patchTestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||||
|
VALUES ('Allergy Test', 'Patch test for allergies', 24, 6, ARRAY[$1])
|
||||||
|
RETURNING id
|
||||||
|
`, serviceID).Scan(&patchTestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create patch test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user patch test that expired 12 months ago (beyond the 6 month expiry)
|
||||||
|
_, err = db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
||||||
|
VALUES ($1, $2, NOW() - INTERVAL '12 months')
|
||||||
|
`, userID, patchTestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user patch test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call eligibility endpoint
|
||||||
|
handler := http.HandlerFunc(ServicesEligibleForUserHandler)
|
||||||
|
req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil)
|
||||||
|
w := makeRequestWithContext(handler, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response []ServiceResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the patch test required service
|
||||||
|
var patchTestSvc *ServiceResponse
|
||||||
|
for i := range response {
|
||||||
|
if response[i].ID == serviceID {
|
||||||
|
patchTestSvc = &response[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patchTestSvc == nil {
|
||||||
|
t.Fatal("Patch Test Required service not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service should show status as "expired" since patch test is past expiry
|
||||||
|
if patchTestSvc.PatchTestStatus == nil {
|
||||||
|
t.Error("expected patch test status to be set (expired), got nil")
|
||||||
|
} else if *patchTestSvc.PatchTestStatus != "expired" {
|
||||||
|
t.Errorf("expected patch test status 'expired', got '%s'", *patchTestSvc.PatchTestStatus)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServices_EligibleForUser_NoPatchTestRecord(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create user with date of birth
|
||||||
|
userID, err := createUserWithDOB("1990-01-01")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a service requiring patch test
|
||||||
|
var serviceID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO services (name, description, price, duration_minutes, is_active)
|
||||||
|
VALUES ('Patch Test Required', 'Requires patch test', 75.00, 90, true)
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create patch test with 24 hour notice period
|
||||||
|
var patchTestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||||
|
VALUES ('Allergy Test', 'Patch test for allergies', 24, 6, ARRAY[$1])
|
||||||
|
RETURNING id
|
||||||
|
`, serviceID).Scan(&patchTestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create patch test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DO NOT create any user_patch_tests record - user has never done patch test
|
||||||
|
|
||||||
|
// Call eligibility endpoint
|
||||||
|
handler := http.HandlerFunc(ServicesEligibleForUserHandler)
|
||||||
|
req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil)
|
||||||
|
w := makeRequestWithContext(handler, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response []ServiceResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the patch test required service
|
||||||
|
var patchTestSvc *ServiceResponse
|
||||||
|
for i := range response {
|
||||||
|
if response[i].ID == serviceID {
|
||||||
|
patchTestSvc = &response[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patchTestSvc == nil {
|
||||||
|
t.Fatal("Patch Test Required service not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service should show status as "required" since user has no patch test record
|
||||||
|
if patchTestSvc.PatchTestStatus == nil {
|
||||||
|
t.Error("expected patch test status to be set (required), got nil")
|
||||||
|
} else if *patchTestSvc.PatchTestStatus != "required" {
|
||||||
|
t.Errorf("expected patch test status 'required', got '%s'", *patchTestSvc.PatchTestStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create user with date of birth
|
||||||
|
userID, err := createUserWithDOB("1990-01-01")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a service requiring patch test
|
||||||
|
var serviceID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO services (name, description, price, duration_minutes, is_active)
|
||||||
|
VALUES ('Patch Test Required', 'Requires patch test', 75.00, 90, true)
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create patch test with 6 month expiry
|
||||||
|
var patchTestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||||
|
VALUES ('Allergy Test', 'Patch test for allergies', 24, 6, ARRAY[$1])
|
||||||
|
RETURNING id
|
||||||
|
`, serviceID).Scan(&patchTestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create patch test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user patch test that expired 12 months ago (beyond the 6 month expiry)
|
||||||
|
_, err = db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
||||||
|
VALUES ($1, $2, NOW() - INTERVAL '12 months')
|
||||||
|
`, userID, patchTestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user patch test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call eligibility endpoint
|
||||||
|
handler := http.HandlerFunc(ServicesEligibleForUserHandler)
|
||||||
|
w := makeRequestWithContext(handler, "GET", "/api/services/eligible-for/"+userID, nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response []ServiceEligibilityResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the patch test required service
|
||||||
|
var patchTestSvc *ServiceEligibilityResponse
|
||||||
|
for i := range response {
|
||||||
|
if response[i].ID == serviceID {
|
||||||
|
patchTestSvc = &response[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patchTestSvc == nil {
|
||||||
|
t.Fatal("Patch Test Required service not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service should show status as "expired" since patch test is past expiry
|
||||||
|
if patchTestSvc.PatchTestStatus == nil {
|
||||||
|
t.Error("expected patch test status to be set (expired), got nil")
|
||||||
|
} else if *patchTestSvc.PatchTestStatus != "expired" {
|
||||||
|
t.Errorf("expected patch test status 'expired', got '%s'", *patchTestSvc.PatchTestStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServices_EligibleForUser_NoPatchTestRecord(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create user with date of birth
|
||||||
|
userID, err := createUserWithDOB("1990-01-01")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a service requiring patch test
|
||||||
|
var serviceID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO services (name, description, price, duration_minutes, is_active)
|
||||||
|
VALUES ('Patch Test Required', 'Requires patch test', 75.00, 90, true)
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create patch test with 24 hour notice period
|
||||||
|
var patchTestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||||
|
VALUES ('Allergy Test', 'Patch test for allergies', 24, 6, ARRAY[$1])
|
||||||
|
RETURNING id
|
||||||
|
`, serviceID).Scan(&patchTestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create patch test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DO NOT create any user_patch_tests record - user has never done patch test
|
||||||
|
|
||||||
|
// Call eligibility endpoint
|
||||||
|
handler := http.HandlerFunc(ServicesEligibleForUserHandler)
|
||||||
|
w := makeRequestWithContext(handler, "GET", "/api/services/eligible-for/"+userID, nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response []ServiceEligibilityResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the patch test required service
|
||||||
|
var patchTestSvc *ServiceEligibilityResponse
|
||||||
|
for i := range response {
|
||||||
|
if response[i].ID == serviceID {
|
||||||
|
patchTestSvc = &response[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patchTestSvc == nil {
|
||||||
|
t.Fatal("Patch Test Required service not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service should show status as "required" since user has no patch test record
|
||||||
|
if patchTestSvc.PatchTestStatus == nil {
|
||||||
|
t.Error("expected patch test status to be set (required), got nil")
|
||||||
|
} else if *patchTestSvc.PatchTestStatus != "required" {
|
||||||
|
t.Errorf("expected patch test status 'required', got '%s'", *patchTestSvc.PatchTestStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,24 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/internal/s3"
|
||||||
|
"crussell/mw"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
"crussell/testutils/jwt"
|
||||||
|
"crussell/testutils/testdb"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -175,6 +193,49 @@ func TestPasswordChange_WrongOld(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordChange_InvalidNewPassword(t *testing.T) {
|
||||||
|
cleanup, pool := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
newPassword string
|
||||||
|
}{
|
||||||
|
{"too_short", "short"},
|
||||||
|
{"too_long", "passwordthatiswaytoolongandexceedsseventytwocharacterswhichisthemaximumallowedbybcrypt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
changeReq := ChangePasswordRequest{
|
||||||
|
CurrentPassword: "testpassword123",
|
||||||
|
NewPassword: tt.newPassword,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(changeReq)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body))
|
||||||
|
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ChangePasswordHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAccount_Delete(t *testing.T) {
|
func TestAccount_Delete(t *testing.T) {
|
||||||
cleanup, pool := setupTest(t)
|
cleanup, pool := setupTest(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -241,3 +302,239 @@ func TestLoyalty_Get(t *testing.T) {
|
|||||||
t.Error("expected referral code to be set")
|
t.Error("expected referral code to be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestProfile_Update_InvalidInput(t *testing.T) {
|
||||||
|
cleanup, pool := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req UpdateProfileRequest
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing_first_name",
|
||||||
|
req: UpdateProfileRequest{FirstName: "", LastName: "Doe", Phone: "+447700900000"},
|
||||||
|
expected: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_last_name",
|
||||||
|
req: UpdateProfileRequest{FirstName: "John", LastName: "", Phone: "+447700900000"},
|
||||||
|
expected: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_phone",
|
||||||
|
req: UpdateProfileRequest{FirstName: "John", LastName: "Doe", Phone: ""},
|
||||||
|
expected: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid_phone",
|
||||||
|
req: UpdateProfileRequest{FirstName: "John", LastName: "Doe", Phone: "not-a-phone"},
|
||||||
|
expected: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid_characters_in_name",
|
||||||
|
req: UpdateProfileRequest{FirstName: "John123", LastName: "Doe", Phone: "+447700900000"},
|
||||||
|
expected: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name_too_long",
|
||||||
|
req: UpdateProfileRequest{FirstName: string(make([]byte, 51)), LastName: "Doe", Phone: "+447700900000"},
|
||||||
|
expected: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(tt.req)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body))
|
||||||
|
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
UpdateProfileHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.expected {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.expected, rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfile_Update_Success(t *testing.T) {
|
||||||
|
cleanup, pool := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
updateReq := UpdateProfileRequest{
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
Phone: "+447700900000",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(updateReq)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader(body))
|
||||||
|
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
UpdateProfileHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DB was updated
|
||||||
|
var firstName, lastName, phone string
|
||||||
|
err = pool.QueryRow(context.Background(),
|
||||||
|
"SELECT n_first_name, n_last_name, phone FROM users WHERE id = $1", userID).Scan(&firstName, &lastName, &phone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstName != "John" {
|
||||||
|
t.Errorf("expected first name 'John', got '%s'", firstName)
|
||||||
|
}
|
||||||
|
if lastName != "Doe" {
|
||||||
|
t.Errorf("expected last name 'Doe', got '%s'", lastName)
|
||||||
|
}
|
||||||
|
if phone != "+447700900000" {
|
||||||
|
t.Errorf("expected phone '+447700900000', got '%s'", phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordChange_SameAsOld(t *testing.T) {
|
||||||
|
cleanup, pool := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Try to change password to the same one
|
||||||
|
changeReq := ChangePasswordRequest{
|
||||||
|
CurrentPassword: "testpassword123",
|
||||||
|
NewPassword: "testpassword123",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(changeReq)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body))
|
||||||
|
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ChangePasswordHandler(rr, req)
|
||||||
|
|
||||||
|
// Should return 400 Bad Request - cannot use same password
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestProfile_UploadPicture(t *testing.T) {
|
||||||
|
cleanup, pool := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Create a small valid JPEG image (1x1 pixel)
|
||||||
|
// This is a minimal valid JPEG
|
||||||
|
fakeImage := []byte{
|
||||||
|
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||||
|
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
|
||||||
|
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
|
||||||
|
0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
|
||||||
|
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||||||
|
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
|
||||||
|
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
|
||||||
|
0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
|
||||||
|
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00,
|
||||||
|
0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
|
||||||
|
0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03,
|
||||||
|
0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02,
|
||||||
|
0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61,
|
||||||
|
0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1,
|
||||||
|
0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17,
|
||||||
|
0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||||
|
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54,
|
||||||
|
0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
|
||||||
|
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86,
|
||||||
|
0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A,
|
||||||
|
0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5,
|
||||||
|
0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
|
||||||
|
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3,
|
||||||
|
0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
|
||||||
|
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F,
|
||||||
|
0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xF8, 0xAF, 0xFF, 0xD9,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create multipart form request
|
||||||
|
var b bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&b)
|
||||||
|
part, err := writer.CreateFormFile("file", "test.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create form file: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write(fakeImage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write image: %v", err)
|
||||||
|
}
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/user/profile-picture", &b)
|
||||||
|
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Note: This test may return 500 if S3 is not configured
|
||||||
|
// In that case, we check for either success or proper error handling
|
||||||
|
if rr.Code != http.StatusOK && rr.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 200 or 500 (if S3 not configured), got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// If S3 is configured, verify the response contains a URL
|
||||||
|
if rr.Code == http.StatusOK {
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["profilePicUrl"] == "" {
|
||||||
|
t.Error("expected profilePicUrl in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user