//go:build test package user // Package user contains in-package tests for admin user management handlers. // // These tests live in the `user` package (not `handlers/admin`) to ensure // Go coverage counts the handler code. The admin-package tests in // handlers/admin/users_test.go exercise the same handlers from outside the // package, which does not contribute to coverage. // // Test Coverage: // - GetAdminUserHandler: GET /api/admin/users/{id} // - ListAdminUsersHandler: GET /api/admin/users // - GetEligiblePatchTestServicesHandler: GET /api/admin/users/{id}/patch-tests/eligible // - AddPatchTestHandler: POST /api/admin/users/{id}/patch-tests // // Database State: Tests create and clean up users in the users table. // DO NOT use t.Parallel() — tests share db.Conn state. import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // makeAdminHandlerRequest creates an HTTP request with admin-level context // and chi route parameters extracted from the path. func makeAdminHandlerRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, strings.NewReader(string(bodyBytes))) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } rctx := chi.NewRouteContext() if id, ok := extractAdminUserID(path); ok { rctx.URLParams.Add("id", id) } ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-id") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // extractAdminUserID extracts the user ID segment from admin API paths. // Supported patterns: // // /api/admin/users/{id} // /api/admin/users/{id}/patch-tests/eligible // /api/admin/users/{id}/patch-tests func extractAdminUserID(path string) (string, bool) { prefix := "/api/admin/users/" if !strings.HasPrefix(path, prefix) { return "", false } rest := path[len(prefix):] // The first path segment is the ID for i := 0; i < len(rest); i++ { if rest[i] == '/' { return rest[:i], true } } return rest, true } // --------------------------------------------------------------------------- // GET /api/admin/users/{id} // --------------------------------------------------------------------------- // TestAdminUsers_Get verifies that an admin can retrieve detailed information // about a specific user. func TestAdminUsers_Get(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) } handler := http.HandlerFunc(GetAdminUserHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/"+userID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response AdminUserDetail if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if response.ID != userID { t.Errorf("expected user ID %s, got %s", userID, response.ID) } if response.AccountType != "email" { t.Errorf("expected account type 'email', got %s", response.AccountType) } } // TestAdminUsers_Get_NotFound verifies that requesting a non-existent user // returns HTTP 404. func TestAdminUsers_Get_NotFound(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetAdminUserHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/nonexist", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminUsers_Get_ValidFormatNotFound tests that a valid-format hex ID // that doesn't exist in the DB triggers the pgx.ErrNoRows path (404). func TestAdminUsers_Get_ValidFormatNotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx handler := http.HandlerFunc(GetAdminUserHandler) req := httptest.NewRequest("GET", "/api/admin/users/000000000000", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "000000000000") ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, "admin-id") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } // --------------------------------------------------------------------------- // GET /api/admin/users // --------------------------------------------------------------------------- // TestAdminUsers_List verifies that an admin can list all users with // their details including name history and completed booking counts. func TestAdminUsers_List(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create test users var ninaID, bobID string err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Nina', 'Smith', 'nina.adminlist@test.com', '+447123456789', '1990-01-01', 'hash', 'admin', 'email') RETURNING id `).Scan(&ninaID) if err != nil { t.Fatalf("failed to create nina: %v", err) } err = tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Bob', 'Jones', 'bob.adminlist@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id `).Scan(&bobID) if err != nil { t.Fatalf("failed to create bob: %v", err) } // Create a completed booking for Nina _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW(), 'completed') `, ninaID) if err != nil { t.Fatalf("failed to create booking for nina: %v", err) } // Insert name history for Bob _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'Bobby', 'Jones') `, bobID) if err != nil { t.Fatalf("failed to insert name_history for bob: %v", err) } handler := http.HandlerFunc(ListAdminUsersHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response UserListResponse if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } // The baseline seed creates some users too, so we check at least 2 if response.Total < 2 { t.Errorf("expected at least 2 users, got %d", response.Total) } // Verify name history and completed_count var bobFound bool for _, u := range response.Users { if u.ID == bobID { bobFound = true if u.PreviousFirstName == nil || *u.PreviousFirstName != "Bobby" { t.Errorf("expected bob previousFirstName 'Bobby', got %v", u.PreviousFirstName) } if u.PreviousLastName == nil || *u.PreviousLastName != "Jones" { t.Errorf("expected bob previousLastName 'Jones', got %v", u.PreviousLastName) } } if u.ID == ninaID { if u.CompletedCount != 1 { t.Errorf("expected nina completed_count 1, got %d", u.CompletedCount) } if u.PreviousFirstName != nil { t.Errorf("expected nina previousFirstName nil, got %v", *u.PreviousFirstName) } } } if !bobFound { t.Error("expected bob in user list") } } // TestAdminUsers_List_Page verifies the page parameter is echoed in the response. func TestAdminUsers_List_Page(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) for i := 0; i < 5; i++ { _, err := tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('User', $1, $2, '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') `, fmt.Sprintf("LastName_%d", i), fmt.Sprintf("user.pagelist.%d@test.com", i)) if err != nil { t.Fatalf("failed to create user %d: %v", i, err) } } handler := http.HandlerFunc(ListAdminUsersHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users?page=2", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp UserListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Page != 2 { t.Errorf("expected page 2 echoed back, got %d", resp.Page) } if resp.Total < 5 { t.Errorf("expected at least 5 users total, got %d", resp.Total) } if resp.PerPage == 0 { t.Error("expected per_page to be set") } } // --------------------------------------------------------------------------- // GET /api/admin/users/{id}/patch-tests/eligible // --------------------------------------------------------------------------- // TestAdminUsers_PatchTests_Eligible verifies that eligible patch test // services are correctly returned. func TestAdminUsers_PatchTests_Eligible(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create services _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Basic Manicure', 'Basic manicure', 25.00, 30, true, 0), ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16), ('Luxury Gel Manicure', 'Luxury gel', 55.00, 75, true, 16), ('Inactive Service', 'Inactive', 30.00, 30, false, 16) `) if err != nil { t.Fatalf("failed to create services: %v", err) } // Get service IDs for patch test services var gelPolishID, luxuryGelID string err = tx.QueryRow(ctx, "SELECT id FROM services WHERE name = 'Gel Polish Full Set'").Scan(&gelPolishID) if err != nil { t.Fatalf("failed to get gel polish service ID: %v", err) } err = tx.QueryRow(ctx, "SELECT id FROM services WHERE name = 'Luxury Gel Manicure'").Scan(&luxuryGelID) if err != nil { t.Fatalf("failed to get luxury gel service ID: %v", err) } // Create patch tests that link to these services _, err = tx.Exec(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) `, []string{gelPolishID}) if err != nil { t.Fatalf("failed to create patch test for gel polish: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Luxury Gel Test', 'Patch test for luxury gel', 24, 6, $1) `, []string{luxuryGelID}) if err != nil { t.Fatalf("failed to create patch test for luxury gel: %v", err) } handler := http.HandlerFunc(GetEligiblePatchTestServicesHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/"+userID+"/patch-tests/eligible", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceForPatchTest if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) != 2 { t.Errorf("expected 2 eligible services, got %d. body: %s", len(response), w.Body.String()) } } // TestAdminUsers_PatchTests_Eligible_WithExisting verifies that a service is // filtered out when the user already has a valid (non-expired) patch test on file. func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create services var serviceID1, serviceID2 string err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16) RETURNING id `).Scan(&serviceID1) if err != nil { t.Fatalf("failed to create service 1: %v", err) } err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Luxury Gel Manicure', 'Luxury gel', 55.00, 75, true, 16) RETURNING id `).Scan(&serviceID2) if err != nil { t.Fatalf("failed to create service 2: %v", err) } // Create patch tests var patchTestID1 string err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id `, []string{serviceID1}).Scan(&patchTestID1) if err != nil { t.Fatalf("failed to create patch test 1: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Luxury Gel Test', 'Patch test for luxury gel', 24, 6, $1) `, []string{serviceID2}) if err != nil { t.Fatalf("failed to create patch test 2: %v", err) } // Add one patch test for the user (within expiry window) _, err = tx.Exec(ctx, ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW() - INTERVAL '2 months') `, userID, patchTestID1) if err != nil { t.Fatalf("failed to add user patch test: %v", err) } handler := http.HandlerFunc(GetEligiblePatchTestServicesHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/"+userID+"/patch-tests/eligible", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceForPatchTest if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) != 1 { t.Errorf("expected 1 eligible service, got %d. body: %s", len(response), w.Body.String()) } if len(response) > 0 && response[0].ID != serviceID2 { t.Errorf("expected service %s, got %s", serviceID2, response[0].ID) } } // --------------------------------------------------------------------------- // POST /api/admin/users/{id}/patch-tests // --------------------------------------------------------------------------- // TestAdminUsers_AddPatchTest verifies that an admin can record a patch test // for a user, creating a user_patch_tests record. func TestAdminUsers_AddPatchTest(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create a service var serviceID string err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16) RETURNING id `).Scan(&serviceID) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a patch test that links to this service var patchTestID string err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id `, []string{serviceID}).Scan(&patchTestID) if err != nil { t.Fatalf("failed to create patch test: %v", err) } handler := http.HandlerFunc(AddPatchTestHandler) reqBody := AddPatchTestRequest{PatchTestID: patchTestID} w := makeAdminHandlerRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify patch test was added var count int err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2 `, userID, patchTestID).Scan(&count) if err != nil { t.Fatalf("failed to check patch test: %v", err) } if count != 1 { t.Errorf("expected 1 patch test record, got %d", count) } } // TestAdminUsers_AddPatchTest_InvalidPatchTest verifies that providing a // non-existent patch_test_id returns HTTP 400. func TestAdminUsers_AddPatchTest_InvalidPatchTest(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } handler := http.HandlerFunc(AddPatchTestHandler) reqBody := AddPatchTestRequest{PatchTestID: "nonexist123"} w := makeAdminHandlerRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // --------------------------------------------------------------------------- // GET /api/admin/users/{id} — social logins // --------------------------------------------------------------------------- // TestAdminUsers_Get_WithSocialLogins verifies that the admin user detail // response includes social logins when the user has linked OAuth providers. func TestAdminUsers_Get_WithSocialLogins(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Insert social login rows _, err = tx.Exec(ctx, `INSERT INTO user_social_logins (user_id, provider, immutable_id) VALUES ($1, 'google', 'google-123')`, userID) require.NoError(t, err) _, err = tx.Exec(ctx, `INSERT INTO user_social_logins (user_id, provider, immutable_id) VALUES ($1, 'microsoft', 'ms-456')`, userID) require.NoError(t, err) handler := http.HandlerFunc(GetAdminUserHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/"+userID, nil, ctx) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]any err = json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) assert.Contains(t, resp, "socialLogins") // socialLogins should be a non-empty array logins, ok := resp["socialLogins"].([]any) if assert.True(t, ok, "socialLogins should be an array") { assert.Len(t, logins, 2) } }