Files
Crussell/backend/handlers/user/admin_handlers_test.go
T
popertots 3029fd5179
CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
test: add coverage tests across backend + fix mock for PENDING checkout support
New test files cover previously untested paths across DAV, validators,
S3, Square, mw, bookings, user, and payments packages.

Includes mock fix: HoldCheckouts flag on MockClient allows tests to
pause auto-complete goroutine for testing PENDING checkout states.

Coverage: 50.4% → 65.0% (+14.6pp)
2026-07-10 18:13:44 +01:00

543 lines
18 KiB
Go

//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)
}
}