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
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)
808 lines
26 KiB
Go
808 lines
26 KiB
Go
//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
|
|
}
|
|
|
|
// 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, 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. 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, 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. 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, 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) != 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, 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) != 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, 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) != 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
|
|
}
|
|
|