feat: migrate to dedicated patch test management
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crussell/db"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// PatchTest represents a patch test definition
|
||||
type PatchTest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
NoticeDurationHours int `json:"notice_duration_hours"`
|
||||
ExpiryMonths int `json:"expiry_months"`
|
||||
ServiceIDs []string `json:"service_ids"`
|
||||
}
|
||||
|
||||
// CreatePatchTestRequest represents the request payload
|
||||
type CreatePatchTestRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
NoticeDurationHours int `json:"notice_duration_hours"`
|
||||
ExpiryMonths int `json:"expiry_months"`
|
||||
ServiceIDs []string `json:"service_ids"`
|
||||
}
|
||||
|
||||
// UpdatePatchTestRequest represents the request payload
|
||||
type UpdatePatchTestRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
NoticeDurationHours *int `json:"notice_duration_hours,omitempty"`
|
||||
ExpiryMonths *int `json:"expiry_months,omitempty"`
|
||||
ServiceIDs []string `json:"service_ids,omitempty"`
|
||||
}
|
||||
|
||||
// GetPatchTests handles GET /api/admin/patch-tests
|
||||
func GetPatchTests(w http.ResponseWriter, r *http.Request) {
|
||||
query := `
|
||||
SELECT id, name, description, notice_duration_hours, expiry_months, service_ids
|
||||
FROM patch_tests
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), query)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch patch tests: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var patchTests []PatchTest
|
||||
for rows.Next() {
|
||||
var pt PatchTest
|
||||
var desc sql.NullString
|
||||
err := rows.Scan(&pt.ID, &pt.Name, &desc, &pt.NoticeDurationHours, &pt.ExpiryMonths, &pt.ServiceIDs)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read patch test data: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if desc.Valid {
|
||||
pt.Description = &desc.String
|
||||
}
|
||||
patchTests = append(patchTests, pt)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(patchTests)
|
||||
}
|
||||
|
||||
// CreatePatchTest handles POST /api/admin/patch-tests
|
||||
func CreatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreatePatchTestRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
var id string
|
||||
err := db.DB.QueryRow(r.Context(), query, req.Name, req.Description, req.NoticeDurationHours, req.ExpiryMonths, req.ServiceIDs).Scan(&id)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||
}
|
||||
|
||||
// UpdatePatchTest handles PUT /api/admin/patch-tests/{id}
|
||||
func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" || !validators.IsValidID(id) {
|
||||
http.Error(w, "Patch test not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdatePatchTestRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE patch_tests SET "
|
||||
args := []interface{}{}
|
||||
i := 1
|
||||
|
||||
if req.Name != nil {
|
||||
query += "name = $" + string(rune('0'+i)) + ", "
|
||||
args = append(args, *req.Name)
|
||||
i++
|
||||
}
|
||||
if req.Description != nil {
|
||||
query += "description = $" + string(rune('0'+i)) + ", "
|
||||
args = append(args, *req.Description)
|
||||
i++
|
||||
}
|
||||
if req.NoticeDurationHours != nil {
|
||||
query += "notice_duration_hours = $" + string(rune('0'+i)) + ", "
|
||||
args = append(args, *req.NoticeDurationHours)
|
||||
i++
|
||||
}
|
||||
if req.ExpiryMonths != nil {
|
||||
query += "expiry_months = $" + string(rune('0'+i)) + ", "
|
||||
args = append(args, *req.ExpiryMonths)
|
||||
i++
|
||||
}
|
||||
if req.ServiceIDs != nil {
|
||||
query += "service_ids = $" + string(rune('0'+i)) + ", "
|
||||
args = append(args, req.ServiceIDs)
|
||||
i++
|
||||
}
|
||||
|
||||
query = query[:len(query)-2]
|
||||
query += " WHERE id = $" + string(rune('0'+i))
|
||||
args = append(args, id)
|
||||
|
||||
_, err := db.DB.Exec(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to update patch test: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeletePatchTest handles DELETE /api/admin/patch-tests/{id}
|
||||
func DeletePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" || !validators.IsValidID(id) {
|
||||
http.Error(w, "Patch test not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := db.DB.Exec(r.Context(), "DELETE FROM patch_tests WHERE id = $1", id)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete patch test: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
|
||||
func TestPatchTests_CRUD(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
// Create a service to link
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
req := CreatePatchTestRequest{
|
||||
Name: "Test Patch Test",
|
||||
Description: strPtr("Description"),
|
||||
NoticeDurationHours: 24,
|
||||
ExpiryMonths: 6,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
w := makeAdminRequest(http.HandlerFunc(CreatePatchTest), "POST", "/api/admin/patch-tests", bytes.NewReader(body))
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
parseResponseBody(w, &created)
|
||||
|
||||
w = makeAdminRequest(http.HandlerFunc(GetPatchTests), "GET", "/api/admin/patch-tests", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var list []PatchTest
|
||||
parseResponseBody(w, &list)
|
||||
if len(list) != 1 {
|
||||
t.Errorf("expected 1 patch test, got %d", len(list))
|
||||
}
|
||||
|
||||
newName := "Updated Name"
|
||||
updateReq := UpdatePatchTestRequest{Name: &newName}
|
||||
updateBody, _ := json.Marshal(updateReq)
|
||||
w = makeAdminRequest(http.HandlerFunc(UpdatePatchTest), "PUT", "/api/admin/patch-tests/"+created.ID, bytes.NewReader(updateBody))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
}
|
||||
|
||||
w = makeAdminRequest(http.HandlerFunc(DeletePatchTest), "DELETE", "/api/admin/patch-tests/"+created.ID, nil)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -45,12 +45,11 @@ type ServiceResponse struct {
|
||||
|
||||
// CreateServiceRequest represents the request payload for creating a new service
|
||||
type CreateServiceRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Price float64 `json:"price" validate:"required,gt=0"`
|
||||
DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"`
|
||||
MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"`
|
||||
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Price float64 `json:"price" validate:"required,gt=0"`
|
||||
DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"`
|
||||
MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"`
|
||||
}
|
||||
|
||||
// ToggleServiceHandler handles toggling a service's active status
|
||||
@@ -163,19 +162,6 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create patch test record if duration > 0
|
||||
if req.PatchTestDurationHours > 0 {
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, req.Name+" Patch Test", "Patch test for "+req.Name, req.PatchTestDurationHours, 6, []string{service.ID})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
service.PatchTestDurationHours = req.PatchTestDurationHours
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Check for duplicate name or other constraints
|
||||
if err.Error() == "pq: duplicate key value violates unique constraint" {
|
||||
|
||||
@@ -348,46 +348,6 @@ func TestContact_ReturnsInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServices_Create_PatchTest verifies that creating a service with
|
||||
// patch_test_duration_hours > 0 automatically creates a patch test record.
|
||||
func TestServices_Create_PatchTest(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
handler := http.HandlerFunc(CreateServiceHandler)
|
||||
|
||||
req := CreateServiceRequest{
|
||||
Name: "Lash Lift",
|
||||
Description: strPtr("Lash lift service"),
|
||||
Price: 50.00,
|
||||
DurationMinutes: 60,
|
||||
MinimumAgeRequired: 18,
|
||||
PatchTestDurationHours: 48,
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(req)
|
||||
httpreq := httptest.NewRequest("POST", "/api/admin/services", bytes.NewReader(reqBody))
|
||||
httpreq.Header.Set("Content-Type", "application/json")
|
||||
httpreq.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httpreq)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM patch_tests WHERE notice_duration_hours = 48").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query patch_tests: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 patch test record, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -251,6 +251,13 @@ func main() {
|
||||
r.Put("/{id}/toggle", services.ToggleService)
|
||||
})
|
||||
|
||||
r.Route("/admin/patch-tests", func(r chi.Router) {
|
||||
r.Get("/", admin.GetPatchTests)
|
||||
r.Post("/", admin.CreatePatchTest)
|
||||
r.Put("/{id}", admin.UpdatePatchTest)
|
||||
r.Delete("/{id}", admin.DeletePatchTest)
|
||||
})
|
||||
|
||||
r.Route("/admin/bookings", func(r chi.Router) {
|
||||
r.Get("/", bookings.GetAllAdminBookingsHandler)
|
||||
r.Post("/", bookings.AdminCreateBookingForUserHandler)
|
||||
|
||||
Reference in New Issue
Block a user