From 7ef3f6dcf75b962444b4850caa9823e4297cc887 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 18 Jun 2026 16:26:09 +0100 Subject: [PATCH] feat(backend): update admin discount campaigns handler Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/admin/discount_campaigns.go | 26 +- .../handlers/admin/discount_campaigns_test.go | 539 ++++++++++++++++++ 2 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 backend/handlers/admin/discount_campaigns_test.go diff --git a/backend/handlers/admin/discount_campaigns.go b/backend/handlers/admin/discount_campaigns.go index e42ffd9..4207c73 100644 --- a/backend/handlers/admin/discount_campaigns.go +++ b/backend/handlers/admin/discount_campaigns.go @@ -38,17 +38,17 @@ type DiscountCampaign struct { // CreateCampaignRequest represents the request payload for creating a new campaign type CreateCampaignRequest struct { - Name string `json:"name" validate:"required,min=1,max=200"` - Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` - CampaignType string `json:"campaign_type"` // "time_based" or "milestone" - DiscountPercent float64 `json:"discount_percent"` - Scope *string `json:"scope,omitempty"` - StartDate *string `json:"start_date,omitempty"` // ISO 8601 - EndDate *string `json:"end_date,omitempty"` // ISO 8601 - MilestoneType *string `json:"milestone_type,omitempty"` - MilestoneValue *int `json:"milestone_value,omitempty"` - MilestoneUnit *string `json:"milestone_unit,omitempty"` - MaxRedemptions *int `json:"max_redemptions,omitempty"` + Name string `json:"name" validate:"required,min=1,max=200"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + CampaignType string `json:"campaign_type"` // "time_based" or "milestone" + DiscountPercent float64 `json:"discount_percent"` + Scope *string `json:"scope,omitempty"` + StartDate *string `json:"start_date,omitempty"` // ISO 8601 + EndDate *string `json:"end_date,omitempty"` // ISO 8601 + MilestoneType *string `json:"milestone_type,omitempty"` + MilestoneValue *int `json:"milestone_value,omitempty"` + MilestoneUnit *string `json:"milestone_unit,omitempty"` + MaxRedemptions *int `json:"max_redemptions,omitempty"` } // UpdateCampaignRequest represents the request payload for updating a campaign @@ -70,7 +70,7 @@ type UpdateCampaignRequest struct { type CampaignStats struct { Campaign DiscountCampaign `json:"campaign"` TotalDiscounts float64 `json:"total_discount_amount"` - BookingCount int `json:"booking_count"` + BookingCount int `json:"booking_count"` } // GetDiscountCampaigns handles GET /api/admin/discount-campaigns @@ -738,4 +738,4 @@ func GetCampaignStats(w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(stats); err != nil { log.Printf("Error encoding stats: %v", err) } -} \ No newline at end of file +} diff --git a/backend/handlers/admin/discount_campaigns_test.go b/backend/handlers/admin/discount_campaigns_test.go new file mode 100644 index 0000000..202c163 --- /dev/null +++ b/backend/handlers/admin/discount_campaigns_test.go @@ -0,0 +1,539 @@ +//go:build test +// +build test + +package admin + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "crussell/db" + "crussell/mw" + "crussell/testutils/fixtures" + + "github.com/go-chi/chi/v5" +) + +func makeCampaignRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + bodyBytes, _ := json.Marshal(body) + req = httptest.NewRequest(method, path, strings.NewReader(string(bodyBytes))) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + + rctx := chi.NewRouteContext() + prefix := "/api/admin/discount-campaigns/" + if strings.HasPrefix(path, prefix) { + suffix := path[len(prefix):] + if slashIdx := strings.Index(suffix, "/"); slashIdx >= 0 { + rctx.URLParams.Add("id", suffix[:slashIdx]) + } else if suffix != "" { + rctx.URLParams.Add("id", suffix) + } + } + + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, mw.UserIDKey, testAdminID) + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +func insertTimeBasedCampaign(t *testing.T, name string, discount float64, status string) string { + t.Helper() + startDate := fmt.Sprintf("%sZ", time.Now().Add(-1*time.Hour).Format("2006-01-02T15:04:05")) + endDate := fmt.Sprintf("%sZ", time.Now().Add(7*24*time.Hour).Format("2006-01-02T15:04:05")) + var id string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, created_by) + VALUES ($1, 'time_based', $2, $3, $4::timestamptz, $5::timestamptz, $6) + RETURNING id + `, name, discount, status, startDate, endDate, testAdminID).Scan(&id) + if err != nil { + t.Fatalf("failed to insert time_based campaign: %v", err) + } + return id +} + +func insertMilestoneCampaign(t *testing.T, name string, discount float64, status string) string { + t.Helper() + mt := "per_user_booking_count" + mv := 5 + mu := "bookings" + var id string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, + milestone_type, milestone_value, milestone_unit, created_by) + VALUES ($1, 'milestone', $2, $3, $4, $5, $6, $7) + RETURNING id + `, name, discount, status, mt, mv, mu, testAdminID).Scan(&id) + if err != nil { + t.Fatalf("failed to insert milestone campaign: %v", err) + } + return id +} + +// ============================================================================= +// List Campaigns Tests +// ============================================================================= + +func TestGetDiscountCampaigns_Empty(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + handler := http.HandlerFunc(GetDiscountCampaigns) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var campaigns []DiscountCampaign + if err := json.Unmarshal(w.Body.Bytes(), &campaigns); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(campaigns) != 0 { + t.Errorf("expected empty list, got %d items", len(campaigns)) + } +} + +func TestGetDiscountCampaigns_WithCampaigns(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + insertTimeBasedCampaign(t, "Summer Sale", 15, "active") + + handler := http.HandlerFunc(GetDiscountCampaigns) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var campaigns []DiscountCampaign + if err := json.Unmarshal(w.Body.Bytes(), &campaigns); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(campaigns) != 1 { + t.Fatalf("expected 1 campaign, got %d", len(campaigns)) + } + if campaigns[0].Name != "Summer Sale" { + t.Errorf("expected 'Summer Sale', got %q", campaigns[0].Name) + } + if campaigns[0].DiscountPercent != 15 { + t.Errorf("expected 15%% discount, got %.0f%%", campaigns[0].DiscountPercent) + } + if campaigns[0].CampaignType != "time_based" { + t.Errorf("expected 'time_based', got %q", campaigns[0].CampaignType) + } + if campaigns[0].Status != "active" { + t.Errorf("expected 'active', got %q", campaigns[0].Status) + } +} + +func TestGetDiscountCampaigns_FilterByStatus(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + insertMilestoneCampaign(t, "Draft Campaign", 10, "draft") + insertMilestoneCampaign(t, "Active Campaign", 20, "active") + insertMilestoneCampaign(t, "Cancelled Campaign", 5, "cancelled") + + handler := http.HandlerFunc(GetDiscountCampaigns) + + t.Run("filter_by_active", func(t *testing.T) { + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns?status=active", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var campaigns []DiscountCampaign + json.Unmarshal(w.Body.Bytes(), &campaigns) + if len(campaigns) != 1 { + t.Errorf("expected 1 active campaign, got %d", len(campaigns)) + } + }) + + t.Run("filter_by_invalid_status", func(t *testing.T) { + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns?status=invalid", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid status filter, got %d", w.Code) + } + }) +} + +// ============================================================================= +// Create Campaign Tests +// ============================================================================= + +func TestCreateDiscountCampaign_TimeBased(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + req := CreateCampaignRequest{ + Name: "Summer Sale", + CampaignType: "time_based", + DiscountPercent: 15, + StartDate: strPtr(fmt.Sprintf("%sZ", time.Now().Format("2006-01-02T15:04:05"))), + EndDate: strPtr(fmt.Sprintf("%sZ", time.Now().Add(7*24*time.Hour).Format("2006-01-02T15:04:05"))), + MaxRedemptions: intPtr(100), + } + + handler := http.HandlerFunc(CreateDiscountCampaign) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var campaign DiscountCampaign + if err := json.Unmarshal(w.Body.Bytes(), &campaign); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if campaign.Name != "Summer Sale" { + t.Errorf("expected 'Summer Sale', got %q", campaign.Name) + } + if campaign.Status != "draft" { + t.Errorf("expected initial status 'draft', got %q", campaign.Status) + } + if campaign.ID == "" { + t.Error("expected campaign ID to be set") + } +} + +func TestCreateDiscountCampaign_Milestone(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + mt := "per_user_booking_count" + mv := 5 + mu := "bookings" + req := CreateCampaignRequest{ + Name: "Loyalty Milestone", + CampaignType: "milestone", + DiscountPercent: 25, + MilestoneType: &mt, + MilestoneValue: &mv, + MilestoneUnit: &mu, + } + + handler := http.HandlerFunc(CreateDiscountCampaign) + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var campaign DiscountCampaign + if err := json.Unmarshal(w.Body.Bytes(), &campaign); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if campaign.CampaignType != "milestone" { + t.Errorf("expected 'milestone', got %q", campaign.CampaignType) + } + if campaign.MilestoneValue == nil || *campaign.MilestoneValue != 5 { + t.Errorf("expected milestone value 5, got %v", campaign.MilestoneValue) + } +} + +func TestCreateDiscountCampaign_ValidationErrors(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + handler := http.HandlerFunc(CreateDiscountCampaign) + + t.Run("empty_name", func(t *testing.T) { + req := CreateCampaignRequest{ + Name: "", + CampaignType: "time_based", + DiscountPercent: 10, + } + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for empty name, got %d", w.Code) + } + }) + + t.Run("invalid_discount_percent_zero", func(t *testing.T) { + req := CreateCampaignRequest{ + Name: "Test", + CampaignType: "time_based", + DiscountPercent: 0, + } + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for zero discount, got %d", w.Code) + } + }) + + t.Run("invalid_discount_percent_over_100", func(t *testing.T) { + req := CreateCampaignRequest{ + Name: "Test", + CampaignType: "time_based", + DiscountPercent: 150, + } + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for discount >100, got %d", w.Code) + } + }) + + t.Run("invalid_campaign_type", func(t *testing.T) { + req := CreateCampaignRequest{ + Name: "Test", + CampaignType: "invalid_type", + DiscountPercent: 10, + } + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid type, got %d", w.Code) + } + }) + + t.Run("time_based_missing_dates", func(t *testing.T) { + req := CreateCampaignRequest{ + Name: "Test", + CampaignType: "time_based", + DiscountPercent: 10, + } + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing dates, got %d", w.Code) + } + }) + + t.Run("time_based_end_before_start", func(t *testing.T) { + future := time.Now().Add(7 * 24 * time.Hour) + past := time.Now().Add(-7 * 24 * time.Hour) + req := CreateCampaignRequest{ + Name: "Test", + CampaignType: "time_based", + DiscountPercent: 10, + StartDate: strPtr(fmt.Sprintf("%sZ", future.Format("2006-01-02T15:04:05"))), + EndDate: strPtr(fmt.Sprintf("%sZ", past.Format("2006-01-02T15:04:05"))), + } + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for end before start, got %d", w.Code) + } + }) + + t.Run("milestone_missing_required_fields", func(t *testing.T) { + req := CreateCampaignRequest{ + Name: "Test", + CampaignType: "milestone", + DiscountPercent: 10, + } + w := makeCampaignRequest(handler, "POST", "/api/admin/discount-campaigns", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing milestone fields, got %d", w.Code) + } + }) +} + +// ============================================================================= +// Update Campaign Tests +// ============================================================================= + +func TestUpdateDiscountCampaign_UpdateName(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + campaignID := insertMilestoneCampaign(t, "Old Name", 10, "draft") + + newName := "New Name" + req := UpdateCampaignRequest{Name: &newName} + handler := http.HandlerFunc(UpdateDiscountCampaign) + w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var campaign DiscountCampaign + if err := json.Unmarshal(w.Body.Bytes(), &campaign); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if campaign.Name != "New Name" { + t.Errorf("expected 'New Name', got %q", campaign.Name) + } +} + +func TestUpdateDiscountCampaign_NotFound(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + newName := "Test" + req := UpdateCampaignRequest{Name: &newName} + handler := http.HandlerFunc(UpdateDiscountCampaign) + w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/nonexistent-id", req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code) + } +} + +func TestUpdateDiscountCampaign_InvalidStatus(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + campaignID := insertMilestoneCampaign(t, "Test", 10, "draft") + + badStatus := "invalid_status" + req := UpdateCampaignRequest{Status: &badStatus} + handler := http.HandlerFunc(UpdateDiscountCampaign) + w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid status, got %d", w.Code) + } +} + +// ============================================================================= +// Delete Campaign Tests +// ============================================================================= + +func TestDeleteDiscountCampaign_HappyPath(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + campaignID := insertMilestoneCampaign(t, "Test", 10, "active") + + handler := http.HandlerFunc(DeleteDiscountCampaign) + w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/"+campaignID, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var status string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM discount_campaigns WHERE id = $1", campaignID).Scan(&status) + if err != nil { + t.Fatalf("failed to query campaign: %v", err) + } + if status != "cancelled" { + t.Errorf("expected status 'cancelled', got %q", status) + } +} + +func TestDeleteDiscountCampaign_NotFound(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + handler := http.HandlerFunc(DeleteDiscountCampaign) + w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/nonexistent-id", nil) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code) + } +} + +// ============================================================================= +// Campaign Stats Tests +// ============================================================================= + +func TestGetCampaignStats_NoUsage(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + campaignID := insertMilestoneCampaign(t, "Test", 10, "active") + + handler := http.HandlerFunc(GetCampaignStats) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/"+campaignID+"/stats", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var stats CampaignStats + if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if stats.Campaign.ID != campaignID { + t.Errorf("expected campaign ID %q, got %q", campaignID, stats.Campaign.ID) + } + if stats.TotalDiscounts != 0 { + t.Errorf("expected 0 total discounts, got %.2f", stats.TotalDiscounts) + } + if stats.BookingCount != 0 { + t.Errorf("expected 0 booking count, got %d", stats.BookingCount) + } +} + +func TestGetCampaignStats_NotFound(t *testing.T) { + resetTestData(t) + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + testAdminID = adminID + defer func() { testAdminID = "" }() + + handler := http.HandlerFunc(GetCampaignStats) + w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/nonexistent-id/stats", nil) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code) + } +}