//go:build test // Package services contains tests for service listing and eligibility endpoints. // // Test Coverage: // - ServicesHandler: GET /api/services - List all active services for users // - ServicesEligibleForUserHandler: GET /api/services/eligible - List services user is eligible for // (based on patch test completion for applicable services) // // Patch Test Logic: Services with minimum_age_required > 0 require patch test. // Users who haven't completed a patch test for a service cannot book it. // Tests verify eligibility filtering works correctly. package services import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "crussell/db" "crussell/handlers/user" "crussell/mw" "crussell/testutils" "github.com/go-chi/chi/v5" ) // createUserWithDOB creates a test user with specified date of birth func createUserWithDOB(ctx context.Context, q db.Querier, dob string) (string, error) { var userID string err := q.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, "Test", "User", "testuser@test.com", "+44770000001", dob, "hash", "verified_email", "email").Scan(&userID) return userID, err } func makeRequest(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, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } return makeRequestWithContext(handler, req, ctx) } // makeRequestWithContext executes request with chi routing context for path params func makeRequestWithContext(handler http.HandlerFunc, req *http.Request, ctx context.Context) *httptest.ResponseRecorder { // Set up chi routing context for path params on top of the tx context rctx := chi.NewRouteContext() if id, paramName := extractIDFromPath(req.URL.Path); id != "" { rctx.URLParams.Add(paramName, id) } req = req.WithContext(ctx) chiCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(chiCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // extractIDFromPath extracts the ID from URL paths func extractIDFromPath(path string) (string, string) { patterns := []struct { prefix string paramName string }{ {"/api/services/eligible-for/", "user_id"}, {"/api/admin/services/", "id"}, } for _, p := range patterns { if idx := findLastSegment(path, p.prefix); idx >= 0 { rest := path[idx:] // Handle cases where there are additional path segments after the ID (e.g., /toggle) if slashIdx := strings.IndexByte(rest, '/'); slashIdx >= 0 { return rest[:slashIdx], p.paramName } return rest, p.paramName } } return "", "" } // makeAdminRequest creates an HTTP request with admin auth context set, suitable for // testing admin service handlers (ToggleService, CreateServiceHandler, etc.). func makeAdminRequest(handler http.Handler, 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, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } rctx := chi.NewRouteContext() if id, paramName := extractIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, "admin001") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } func findLastSegment(path, prefix string) int { for i := len(path); i >= len(prefix); i-- { if i > 0 && path[i-len(prefix):i] == prefix { return i } } return -1 } // eligibleForUserCtx injects the authenticated-owner context that the // B4-fixed ServicesEligibleForUserHandler now requires: the caller must own // the requested user_id (or be an admin), so tests fetch eligibility as the // owner themselves. func eligibleForUserCtx(ctx context.Context, userID string) context.Context { ctx = context.WithValue(ctx, mw.UserIDKey, userID) ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") return ctx } // TestServices_EligibleForUser_UnauthenticatedDenied verifies the B4 fix: an // unauthenticated request for an arbitrary user_id is rejected 401 before any // DOB/age or patch-test data is exposed. func TestServices_EligibleForUser_UnauthenticatedDenied(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := createUserWithDOB(ctx, tx, "2000-01-01") if err != nil { t.Fatalf("failed to create user: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) w := makeRequestWithContext(handler, req, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 for unauthenticated eligible-for request, got %d. body: %s", w.Code, w.Body.String()) } } // TestServices_EligibleForUser_OtherUserForbidden verifies the B4 ownership // check: an authenticated non-admin caller requesting ANOTHER user's // eligibility is rejected 403. func TestServices_EligibleForUser_OtherUserForbidden(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := createUserWithDOB(ctx, tx, "2000-01-01") if err != nil { t.Fatalf("failed to create user: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) w := makeRequestWithContext(handler, req, eligibleForUserCtx(ctx, "someone-else")) if w.Code != http.StatusForbidden { t.Errorf("expected 403 for cross-user eligible-for request, got %d. body: %s", w.Code, w.Body.String()) } } // TestServices_EligibleForUser_AdminAllowed verifies the B4 admin carve-out: an // admin may fetch eligibility for any user (the admin booking flows). func TestServices_EligibleForUser_AdminAllowed(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := createUserWithDOB(ctx, tx, "2000-01-01") if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Admin View Service', 'Visible to admins', 25.00, 30, true, 0) `) if err != nil { t.Fatalf("failed to create service: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) adminCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") adminCtx = context.WithValue(adminCtx, mw.UserRoleKey, "admin") w := makeRequestWithContext(handler, req, adminCtx) if w.Code != http.StatusOK { t.Errorf("expected 200 for admin eligible-for request, got %d. body: %s", w.Code, w.Body.String()) } } // TestServices_ListAll verifies that listing all services returns only active services, // filtering out inactive services from the response. func TestServices_ListAll(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true, 0), ('Pedicure', 'Basic pedicure', 30.00, 45, true, 0), ('Inactive Service', 'Should not appear', 50.00, 60, false, 0) `) if err != nil { t.Fatalf("failed to create services: %v", err) } handler := http.HandlerFunc(ServicesHandler) w := makeRequest(handler, "GET", "/api/services", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceResponse 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 services, got %d", len(response)) } found := map[string]bool{} for _, s := range response { found[s.Name] = true } if !found["Manicure"] { t.Error("expected Manicure in response") } if !found["Pedicure"] { t.Error("expected Pedicure in response") } if found["Inactive Service"] { t.Error("should not include inactive service") } } // TestServices_EligibleForUser_AgeFilter verifies that eligible services are filtered based on the user's age, // excluding services with minimum_age_required higher than the user's age. func TestServices_EligibleForUser_AgeFilter(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) dob := "2006-01-01" // Age 20 in Feb 2026 userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Under 18 Service', 'For minors', 20.00, 30, true, 16), ('Adult Only Service', 'For adults only', 50.00, 60, true, 21), ('No Age Restriction', 'Everyone welcome', 30.00, 45, true, 0) `) if err != nil { t.Fatalf("failed to create services: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) w := makeRequestWithContext(handler, req, eligibleForUserCtx(ctx, userID)) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceResponse 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 services, got %d. Response: %s", len(response), w.Body.String()) } found := map[string]bool{} for _, s := range response { found[s.Name] = true } if !found["Under 18 Service"] { t.Error("expected Under 18 Service in response (age 20 >= 16)") } if !found["No Age Restriction"] { t.Error("expected No Age Restriction in response") } if found["Adult Only Service"] { t.Error("should not include Adult Only Service (age 20 < 21)") } } // TestServices_EligibleForUser_PatchTest verifies that services requiring patch tests include a // PatchTestStatus field set to 'ok' when the user has completed a valid patch test for that service. func TestServices_EligibleForUser_PatchTest(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) dob := "2000-01-01" userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create a regular service (no patch test required) _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Regular Service', 'No patch test needed', 30.00, 30, true, 0) `) if err != nil { t.Fatalf("failed to create regular service: %v", err) } // Create a service that will require a patch test var patchTestSvcID string err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Patch Test Required', 'Requires patch test', 75.00, 60, true, 0) RETURNING id `).Scan(&patchTestSvcID) if err != nil { t.Fatalf("failed to create patch test 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 ('Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id `, []string{patchTestSvcID}).Scan(&patchTestID) if err != nil { t.Fatalf("failed to create patch test: %v", err) } // Create a valid user patch test record (tested 24+ hours ago, within expiry) _, err = tx.Exec(ctx, ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW() - INTERVAL '48 hours') `, userID, patchTestID) if err != nil { t.Fatalf("failed to create user patch test record: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) w := makeRequestWithContext(handler, req, eligibleForUserCtx(ctx, userID)) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceResponse 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 services, got %d. Response: %s", len(response), w.Body.String()) } var patchTestSvc *ServiceResponse var regularSvc *ServiceResponse for i := range response { if response[i].Name == "Patch Test Required" { patchTestSvc = &response[i] } if response[i].Name == "Regular Service" { regularSvc = &response[i] } } if patchTestSvc == nil { t.Fatal("Patch Test Required service not found in response") } if patchTestSvc.PatchTestStatus == nil || *patchTestSvc.PatchTestStatus != "ok" { t.Errorf("expected patch test status 'ok', got %v", patchTestSvc.PatchTestStatus) } if regularSvc == nil { t.Fatal("Regular Service not found in response") } if regularSvc.PatchTestStatus != nil { t.Errorf("expected no patch test status for regular service, got %v", regularSvc.PatchTestStatus) } } // TestContact_ReturnsInfo verifies that the contact info endpoint returns the salon's contact // details (name, phone, email, role) from the first admin user in the database. func TestContact_ReturnsInfo(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, 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 ('John', 'Smith', 'john@test.com', '+447700000001', '1990-01-01', 'hash', 'admin', 'email') `) if err != nil { t.Fatalf("failed to create admin user: %v", err) } handler := http.HandlerFunc(user.GetContactInfoHandler) w := makeRequest(handler, "GET", "/api/contact", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response user.ContactInfo if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if response.Name == "" { t.Error("expected name in response") } if response.Phone == "" { t.Error("expected phone in response") } if response.Email == "" { t.Error("expected email in response") } if response.Role == "" { t.Error("expected role in response") } } // TestAdminServices_Create_Success verifies that an admin can create a new service // with name, description, price, duration, and minimum age requirements. func TestAdminServices_Create_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create admin user in DB first _, err := tx.Exec(context.Background(), ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Admin', 'User', 'admin@test.com', '+447123456789', '1990-01-01', 'hash', 'admin', 'email') `) if err != nil { t.Fatalf("failed to create admin user: %v", err) } handler := http.HandlerFunc(CreateServiceHandler) createReq := CreateServiceRequest{ Name: "Test Manicure", Description: strPtr("A test manicure service"), Price: 35.00, DurationMinutes: 45, MinimumAgeRequired: 16, } w := makeAdminRequest(handler, "POST", "/api/admin/services", createReq, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var response Service if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if response.Name != "Test Manicure" { t.Errorf("expected name 'Test Manicure', got %s", response.Name) } if response.Price != 35.00 { t.Errorf("expected price 35.00, got %f", response.Price) } if !response.IsActive { t.Error("expected new service to be active by default") } } // TestAdminServices_List_All verifies that all services (including inactive) are // returned when listing from the admin endpoint. func TestAdminServices_List_All(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Insert test services _, err := tx.Exec(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Manicure', 'Basic manicure', 25.00, 30, true, 0), ('Pedicure', 'Basic pedicure', 30.00, 45, false, 0), ('Gel Polish', 'Gel polish service', 40.00, 60, true, 16) `) if err != nil { t.Fatalf("failed to create services: %v", err) } handler := http.HandlerFunc(AllServicesHandler) w := makeAdminRequest(handler, "GET", "/api/admin/services", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []Service if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) != 3 { t.Errorf("expected 3 services, got %d", len(response)) } // Verify all services including inactive are returned found := map[string]bool{} for _, s := range response { found[s.Name] = true } if !found["Manicure"] { t.Error("expected Manicure in response") } if !found["Pedicure"] { t.Error("expected Pedicure in response (including inactive)") } if !found["Gel Polish"] { t.Error("expected Gel Polish in response") } } // TestAdminServices_Toggle verifies that toggling a service switches its // is_active status on/off. func TestAdminServices_Toggle(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create a service var serviceID string err := tx.QueryRow(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id `).Scan(&serviceID) if err != nil { t.Fatalf("failed to create service: %v", err) } handler := http.HandlerFunc(ToggleService) w := makeAdminRequest(handler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify service is now inactive var isActive bool err = tx.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) if err != nil { t.Fatalf("failed to check service: %v", err) } if isActive { t.Error("expected service to be inactive after toggle") } // Toggle again w = makeAdminRequest(handler, "PUT", "/api/admin/services/"+serviceID+"/toggle", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 on second toggle, got %d", w.Code) } // Verify service is active again err = tx.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) if err != nil { t.Fatalf("failed to check service: %v", err) } if !isActive { t.Error("expected service to be active after second toggle") } } // TestAdminServices_Delete verifies soft-deleting a service sets is_active to false. func TestAdminServices_Delete(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create a service var serviceID string err := tx.QueryRow(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id `).Scan(&serviceID) if err != nil { t.Fatalf("failed to create service: %v", err) } handler := http.HandlerFunc(DeleteServiceHandler) w := makeAdminRequest(handler, "DELETE", "/api/admin/services/"+serviceID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify service is soft deleted (is_active = false) var isActive bool err = tx.QueryRow(context.Background(), "SELECT is_active FROM services WHERE id = $1", serviceID).Scan(&isActive) if err != nil { t.Fatalf("failed to check service: %v", err) } if isActive { t.Error("expected service to be soft deleted (is_active = false)") } } // TestAdminServices_NotFound verifies that operations on non-existent service IDs // return appropriate error statuses. func TestAdminServices_NotFound(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) toggleHandler := http.HandlerFunc(ToggleService) w := makeAdminRequest(toggleHandler, "PUT", "/api/admin/services/nonexistent-id/toggle", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("TOGGLE: expected status 404, got %d. body: %s", w.Code, w.Body.String()) } deleteHandler := http.HandlerFunc(DeleteServiceHandler) w = makeAdminRequest(deleteHandler, "DELETE", "/api/admin/services/nonexistent-id", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("DELETE: expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestServices_EligibleForUser_PatchTest_Required_NoRecord verifies that a service returns // patch_test_status = "required" when the user has no user_patch_tests record. func TestServices_EligibleForUser_PatchTest_Required_NoRecord(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) dob := "2000-01-01" userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create a service that requires a patch test var svcID string err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Patch Test Needed', 'Requires patch test', 50.00, 30, true, 0) RETURNING id `).Scan(&svcID) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a patch test linked to this service but do NOT create a user_patch_tests record _, err = tx.Exec(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Allergy Test', 'Patch test for gel products', 24, 6, $1) `, []string{svcID}) if err != nil { t.Fatalf("failed to create patch test: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) w := makeRequestWithContext(handler, req, eligibleForUserCtx(ctx, userID)) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceResponse 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 service, got %d. Response: %s", len(response), w.Body.String()) } if response[0].PatchTestStatus == nil || *response[0].PatchTestStatus != "required" { t.Errorf("expected patch test status 'required', got %v", response[0].PatchTestStatus) } } // TestServices_EligibleForUser_PatchTest_Required_NoticePeriod verifies that a service returns // patch_test_status = "required" when the user is within the notice window (tested but not yet eligible). func TestServices_EligibleForUser_PatchTest_Required_NoticePeriod(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) dob := "2000-01-01" userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } var svcID string err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Patch Test Notice', 'Within notice period', 50.00, 30, true, 0) RETURNING id `).Scan(&svcID) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a patch test with 24-hour notice duration var patchTestID string err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Allergy Test', 'Patch test for gel products', 24, 6, $1) RETURNING id `, []string{svcID}).Scan(&patchTestID) if err != nil { t.Fatalf("failed to create patch test: %v", err) } // Create a user_patch_tests record with tested_at = 1 hour ago (within 24h notice window) _, err = tx.Exec(ctx, ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW() - INTERVAL '1 hour') `, userID, patchTestID) if err != nil { t.Fatalf("failed to create user patch test record: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) w := makeRequestWithContext(handler, req, eligibleForUserCtx(ctx, userID)) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceResponse 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 service, got %d. Response: %s", len(response), w.Body.String()) } if response[0].PatchTestStatus == nil || *response[0].PatchTestStatus != "required" { t.Errorf("expected patch test status 'required', got %v", response[0].PatchTestStatus) } } // TestServices_EligibleForUser_PatchTest_Expired verifies that a service returns // patch_test_status = "expired" when the user's patch test record is past its expiry date. func TestServices_EligibleForUser_PatchTest_Expired(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) dob := "2000-01-01" userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } var svcID string err = tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('Expired Patch Test', 'Patch test expired', 50.00, 30, true, 0) RETURNING id `).Scan(&svcID) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a patch test with 6-month expiry and no notice period var patchTestID string err = tx.QueryRow(ctx, ` INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) VALUES ('Allergy Test', 'Patch test for gel products', 0, 6, $1) RETURNING id `, []string{svcID}).Scan(&patchTestID) if err != nil { t.Fatalf("failed to create patch test: %v", err) } // Create a user_patch_tests record with tested_at = 2 years ago (well beyond 6-month expiry) _, err = tx.Exec(ctx, ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW() - INTERVAL '2 years') `, userID, patchTestID) if err != nil { t.Fatalf("failed to create user patch test record: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) req := httptest.NewRequest("GET", "/api/services/eligible-for/"+userID, nil) w := makeRequestWithContext(handler, req, eligibleForUserCtx(ctx, userID)) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceResponse 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 service, got %d. Response: %s", len(response), w.Body.String()) } if response[0].PatchTestStatus == nil || *response[0].PatchTestStatus != "expired" { t.Errorf("expected patch test status 'expired', got %v", response[0].PatchTestStatus) } } // TestServices_ListAll_NonAdmin_AgeFiltered verifies that the ServicesHandler filters services // by minimum_age_required for non-admin authenticated users, excluding services the user is // too young for. func TestServices_ListAll_NonAdmin_AgeFiltered(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create user with DOB producing age 20 (born 2006-01-01, in 2026 this is age 20) dob := "2006-01-01" userID, err := createUserWithDOB(ctx, tx, dob) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create services with various minimum_age_required values (no patch tests) _, err = tx.Exec(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ('All Ages', 'Everyone welcome', 20.00, 30, true, 0), ('Teen Service', 'Ages 16+', 30.00, 45, true, 16), ('Adult Only', 'Ages 21+', 50.00, 60, true, 21) `) if err != nil { t.Fatalf("failed to create services: %v", err) } // Create non-admin authenticated context userCtx := context.WithValue(ctx, mw.UserIDKey, userID) userCtx = context.WithValue(userCtx, mw.UserRoleKey, "verified_email") handler := http.HandlerFunc(ServicesHandler) w := makeRequest(handler, "GET", "/api/services", nil, userCtx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ServiceResponse if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } // Should return 2 services: All Ages (min_age=0) and Teen Service (min_age=16) // Adult Only (min_age=21) should be excluded (user age 20 < 21) if len(response) != 2 { t.Errorf("expected 2 services, got %d. Response: %s", len(response), w.Body.String()) } found := map[string]bool{} for _, s := range response { found[s.Name] = true } if !found["All Ages"] { t.Error("expected All Ages in response") } if !found["Teen Service"] { t.Error("expected Teen Service in response") } if found["Adult Only"] { t.Error("should not include Adult Only service (age 20 < 21)") } } func strPtr(s string) *string { return &s }