Files
popertotsandSisyphus 510828c924
CI / Go vulnerabilities (push) Successful in 1m10s
CI / Build & Vet (push) Successful in 1m39s
CI / Frontend build (gate) (push) Successful in 1m42s
CI / Frontend QC (audit) (push) Successful in 56s
CI / Frontend QC (typecheck) (push) Successful in 1m36s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Tests (prod) (push) Has been cancelled
CI / Tests (dev) (push) Has been cancelled
CI / Race (prod) (push) Has been cancelled
CI / Race (dev) (push) Has been cancelled
chore: run go fix for Go 1.26 modernization
106 files: interface{}→any, strings.Split→SplitSeq, CutPrefix/Cut, strings.Builder, slices.Contains, remove redundant // +build directives, gofmt import ordering and indentation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-09 17:25:23 +01:00

696 lines
25 KiB
Go

//go:build test
package admin
// Package admin contains tests for admin user management endpoints.
//
// Test Coverage:
// - ListAdminUsersHandler: GET /api/admin/users - List all users with pagination
// - GetAdminUserHandler: GET /api/admin/users/{id} - Get single user details
// - GetEligiblePatchTestServicesHandler: GET /api/admin/users/{id}/eligible-patch-tests
// - AddPatchTestHandler: POST /api/admin/users/{id}/patch-tests - Add patch test record
// - RequireAdmin middleware: All endpoints require admin role (403 for non-admins)
//
// Database State: Tests create and clean up users in the users table.
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"crussell/handlers/user"
"crussell/mw"
"crussell/testutils"
)
// TestAdminUsers_List verifies that an admin can list all users in the
// system with their details including account role and type.
func TestAdminUsers_List(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test users with name history
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@test.com', '+447123456789', '1990-01-01', 'hash1', '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@test.com', '+447123456789', '1990-01-01', 'hash2', 'verified_email', 'email')
RETURNING id
`).Scan(&bobID)
if err != nil {
t.Fatalf("failed to create bob: %v", err)
}
// Create a completed booking for Nina so she has a completed_count
_, 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 (previous name that differs from current)
_, 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)
}
userID, _ := json.Marshal(bobID)
_ = userID
handler := http.HandlerFunc(user.ListAdminUsersHandler)
w := makeAdminRequest(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 user.UserListResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.Total != 2 {
t.Errorf("expected 2 users, got %d", response.Total)
}
if len(response.Users) != 2 {
t.Errorf("expected 2 users in list, got %d", len(response.Users))
}
// Verify name history and completed_count in user list
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)
}
// Nina has no name history — should be nil
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.
// Note: The user list uses cursor-based pagination, not offset-based, so page
// is metadata only — actual page navigation is driven by the next_cursor field.
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%d@test.com", i))
if err != nil {
t.Fatalf("failed to create user %d: %v", i, err)
}
}
handler := http.HandlerFunc(user.ListAdminUsersHandler)
w := makeAdminRequest(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 user.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 total 5, got %d", resp.Total)
}
if resp.PerPage == 0 {
t.Error("expected per_page to be set")
}
}
// TestAdminUsers_Get tests that an admin can retrieve detailed information
// about a specific user including their profile and account settings.
func TestAdminUsers_Get(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID 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 ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
handler := http.HandlerFunc(user.GetAdminUserHandler)
w := makeAdminRequest(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 user.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 details for a
// non-existent user returns HTTP 404 Not Found.
func TestAdminUsers_Get_NotFound(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(user.GetAdminUserHandler)
// Use 12-char or less ID to avoid CHAR(12) constraint error
w := makeAdminRequest(handler, "GET", "/api/admin/users/nonexist", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// TestAdminUsers_PatchTests_Eligible tests that the system correctly
// identifies which services require patch tests and returns only those services
// the user is eligible for based on age requirements.
func TestAdminUsers_PatchTests_Eligible(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID 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 ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create services - some with patch test, some without
_, 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(user.GetEligiblePatchTestServicesHandler)
w := makeAdminRequest(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 []user.ServiceForPatchTest
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
// Should return 2 services (the two with patch tests that are active)
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 when a
// user already has a valid patch test on file, that service is filtered out
// from the eligible list (since they've already completed it).
func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID 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 ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
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 (valid - within expiry)
_, 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 patch test: %v", err)
}
handler := http.HandlerFunc(user.GetEligiblePatchTestServicesHandler)
w := makeAdminRequest(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 []user.ServiceForPatchTest
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
// Should return only 1 service (the one not already added)
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)
}
}
// TestAdminUsers_AddPatchTest verifies that an admin can record a patch
// test completion for a user, creating a user_patch_tests record.
func TestAdminUsers_AddPatchTest(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID 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 ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
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(user.AddPatchTestHandler)
reqBody := user.AddPatchTestRequest{PatchTestID: patchTestID}
w := makeAdminRequest(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)
}
}
func TestAdminUsers_AddPatchTest_InvalidPatchTest(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID 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 ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
handler := http.HandlerFunc(user.AddPatchTestHandler)
// Try to add a non-existent patch test
reqBody := user.AddPatchTestRequest{PatchTestID: "nonexist123"}
w := makeAdminRequest(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())
}
}
// TestAdminUsers_NonAdmin verifies that non-admin users receive HTTP 403
// Forbidden when attempting to list users, get user details, or manage patch tests.
func TestAdminUsers_NonAdmin(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create regular user in DB
_, 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 ('Regular', 'User', 'user@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
`)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create test user for GET
var targetUserID 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 ('Target', 'User', 'target@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&targetUserID)
if err != nil {
t.Fatalf("failed to create target user: %v", err)
}
// Test LIST - should get 403 when using middleware
listHandler := mw.RequireAdmin(http.HandlerFunc(user.ListAdminUsersHandler))
w := makeUserRequest(listHandler, "GET", "/api/admin/users", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("LIST: expected status 403, got %d", w.Code)
}
// Test GET - should get 403 when using middleware
getHandler := mw.RequireAdmin(http.HandlerFunc(user.GetAdminUserHandler))
w = makeUserRequest(getHandler, "GET", "/api/admin/users/"+targetUserID, nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("GET: expected status 403, got %d", w.Code)
}
// Test eligible patch tests - should get 403 when using middleware
eligibleHandler := mw.RequireAdmin(http.HandlerFunc(user.GetEligiblePatchTestServicesHandler))
w = makeUserRequest(eligibleHandler, "GET", "/api/admin/users/"+targetUserID+"/patch-tests/eligible", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("ELIGIBLE: expected status 403, got %d", w.Code)
}
// Test add patch test - should get 403 when using middleware
addHandler := mw.RequireAdmin(http.HandlerFunc(user.AddPatchTestHandler))
w = makeUserRequest(addHandler, "POST", "/api/admin/users/"+targetUserID+"/patch-tests", map[string]string{"patch_test_id": "some-test-id"}, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("ADD: expected status 403, got %d", w.Code)
}
}
// TestAdminUsers_Get_Success is an additional test verifying admin can
// retrieve user details including ID, name, email, and account role.
func TestAdminUsers_Get_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create a test user
var userID 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 ('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)
}
// Insert name history (simulating a previous name change)
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
VALUES ($1, 'OldFirst', 'OldLast')
`, userID)
if err != nil {
t.Fatalf("failed to insert name_history: %v", err)
}
// Call admin get user endpoint
handler := http.HandlerFunc(user.GetAdminUserHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil, ctx)
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)
}
// Verify previous name from history is returned
if resp.PreviousFirstName == nil || *resp.PreviousFirstName != "OldFirst" {
t.Errorf("expected previousFirstName 'OldFirst', got %v", resp.PreviousFirstName)
}
if resp.PreviousLastName == nil || *resp.PreviousLastName != "OldLast" {
t.Errorf("expected previousLastName 'OldLast', got %v", resp.PreviousLastName)
}
}
// TestAdminUsers_Get_ShowsPreviousNameOnlyWhenDifferent verifies that previous
// name is omitted when the name_history entry matches the current user name.
func TestAdminUsers_Get_ShowsPreviousNameOnlyWhenDifferent(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var userID 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 ('Alice', 'Smith', 'alice@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Insert name_history with the SAME name as current — should be omitted
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
VALUES ($1, 'Alice', 'Smith')
`, userID)
if err != nil {
t.Fatalf("failed to insert name_history: %v", err)
}
handler := http.HandlerFunc(user.GetAdminUserHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID, nil, ctx)
var resp user.AdminUserDetail
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.PreviousFirstName != nil {
t.Errorf("expected previousFirstName nil (same as current), got %v", *resp.PreviousFirstName)
}
if resp.PreviousLastName != nil {
t.Errorf("expected previousLastName nil (same as current), got %v", *resp.PreviousLastName)
}
}
// TestAdminUsers_AddPatchTest_Duplicate verifies that recording the same patch test
// twice updates the tested_at timestamp (upsert behavior).
func TestAdminUsers_AddPatchTest_Duplicate(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID 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 ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
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(user.AddPatchTestHandler)
// Record patch test first time - should return 201 Created
reqBody := user.AddPatchTestRequest{PatchTestID: patchTestID}
w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx)
if w.Code != http.StatusCreated {
t.Errorf("first record: expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Get the transaction's NOW() value as baseline
var txNow time.Time
err = tx.QueryRow(ctx, `SELECT NOW()`).Scan(&txNow)
if err != nil {
t.Fatalf("failed to get tx now: %v", err)
}
// Record same patch test again - should return 201 or 200 (upsert updates)
reqBody = user.AddPatchTestRequest{PatchTestID: patchTestID}
w = makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Errorf("second record: expected status 200 or 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify upsert updated tested_at by comparing against the same NOW()
// (within a transaction NOW() is stable, so both should be equal to txNow,
// proving the upsert SET tested_at = NOW() clause executed)
var testedAt time.Time
err = tx.QueryRow(ctx, `
SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2
`, userID, patchTestID).Scan(&testedAt)
if err != nil {
t.Fatalf("failed to get tested_at: %v", err)
}
if testedAt.IsZero() {
t.Errorf("expected tested_at to be set, got zero time")
}
// NOW() is transaction-stable: both writes use the same value
if !testedAt.Equal(txNow) && !testedAt.After(txNow) {
t.Errorf("expected tested_at %v to equal or be after transaction NOW() %v", testedAt, txNow)
}
}