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)
969 lines
33 KiB
Go
969 lines
33 KiB
Go
//go:build test
|
|
|
|
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
var testAdminID string
|
|
|
|
func makeCampaignRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context, adminID string) *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(ctx, chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
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, ctx context.Context, tx db.Querier, adminID, name string, discount float64, status string) string {
|
|
t.Helper()
|
|
startDate := fmt.Sprintf("%sZ", clock.Now().Add(-1*time.Hour).Format("2006-01-02T15:04:05"))
|
|
endDate := fmt.Sprintf("%sZ", clock.Now().Add(7*24*time.Hour).Format("2006-01-02T15:04:05"))
|
|
var id string
|
|
err := tx.QueryRow(ctx, `
|
|
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, adminID).Scan(&id)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert time_based campaign: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func insertMilestoneCampaign(t *testing.T, ctx context.Context, tx db.Querier, adminID, name string, discount float64, status string) string {
|
|
t.Helper()
|
|
mt := "per_user_booking_count"
|
|
mv := 5
|
|
mu := "bookings"
|
|
var id string
|
|
err := tx.QueryRow(ctx, `
|
|
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, adminID).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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
handler := http.HandlerFunc(GetDiscountCampaigns)
|
|
w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
insertTimeBasedCampaign(t, ctx, tx, adminID, "Summer Sale", 15, "active")
|
|
|
|
handler := http.HandlerFunc(GetDiscountCampaigns)
|
|
w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns", nil, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
insertMilestoneCampaign(t, ctx, tx, adminID, "Draft Campaign", 10, "draft")
|
|
insertMilestoneCampaign(t, ctx, tx, adminID, "Active Campaign", 20, "active")
|
|
insertMilestoneCampaign(t, ctx, tx, adminID, "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, ctx, adminID)
|
|
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, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
req := CreateCampaignRequest{
|
|
Name: "Summer Sale",
|
|
CampaignType: "time_based",
|
|
DiscountPercent: 15,
|
|
StartDate: strPtr(fmt.Sprintf("%sZ", clock.Now().Format("2006-01-02T15:04:05"))),
|
|
EndDate: strPtr(fmt.Sprintf("%sZ", clock.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, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
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, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
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, ctx, adminID)
|
|
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, ctx, adminID)
|
|
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, ctx, adminID)
|
|
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, ctx, adminID)
|
|
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, ctx, adminID)
|
|
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 := clock.Now().Add(7 * 24 * time.Hour)
|
|
past := clock.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, ctx, adminID)
|
|
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, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "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, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
newName := "Test"
|
|
req := UpdateCampaignRequest{Name: &newName}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/nonexistent-id", req, ctx, adminID)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestUpdateDiscountCampaign_InvalidStatus(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
badStatus := "invalid_status"
|
|
req := UpdateCampaignRequest{Status: &badStatus}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "active")
|
|
|
|
handler := http.HandlerFunc(DeleteDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/"+campaignID, nil, ctx, adminID)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx,
|
|
"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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
handler := http.HandlerFunc(DeleteDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "DELETE", "/api/admin/discount-campaigns/nonexistent-id", nil, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "active")
|
|
|
|
handler := http.HandlerFunc(GetCampaignStats)
|
|
w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/"+campaignID+"/stats", nil, ctx, adminID)
|
|
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) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
handler := http.HandlerFunc(GetCampaignStats)
|
|
w := makeCampaignRequest(handler, "GET", "/api/admin/discount-campaigns/nonexistent-id/stats", nil, ctx, adminID)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for nonexistent campaign, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Additional Update Coverage Tests
|
|
// =============================================================================
|
|
|
|
// TestUpdateDiscountCampaign_EmptyID verifies empty campaign ID returns 404.
|
|
func TestUpdateDiscountCampaign_EmptyID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
req := UpdateCampaignRequest{Name: stringPtr("Test")}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/", req, ctx, adminID)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for empty ID, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_NonExistentValidID verifies a valid-format but
|
|
// non-existent campaign ID returns 404.
|
|
func TestUpdateDiscountCampaign_NonExistentValidID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
newName := "Test"
|
|
req := UpdateCampaignRequest{Name: &newName}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/aabbccddee00", req, ctx, adminID)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for nonexistent campaign, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_InvalidJSON verifies malformed JSON body returns 400.
|
|
func TestUpdateDiscountCampaign_InvalidJSON(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
req := httptest.NewRequest("PUT", "/api/admin/discount-campaigns/"+campaignID, strings.NewReader("not json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", campaignID)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
http.HandlerFunc(UpdateDiscountCampaign).ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for invalid JSON, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_ValidatorError verifies struct validation errors return 400.
|
|
func TestUpdateDiscountCampaign_ValidatorError(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
// Empty name (non-nil pointer, empty string) should fail min=1 validation
|
|
emptyName := ""
|
|
req := UpdateCampaignRequest{Name: &emptyName}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for validation error, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_InvalidDiscountPercent verifies discount_percent
|
|
// out of range returns 400.
|
|
func TestUpdateDiscountCampaign_InvalidDiscountPercent(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
|
|
t.Run("zero_percent", func(t *testing.T) {
|
|
zero := 0.0
|
|
req := UpdateCampaignRequest{DiscountPercent: &zero}
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for zero discount, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("over_100", func(t *testing.T) {
|
|
over := 150.0
|
|
req := UpdateCampaignRequest{DiscountPercent: &over}
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for discount >100, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_InvalidStartDateFormat verifies bad start_date
|
|
// format returns 400.
|
|
func TestUpdateDiscountCampaign_InvalidStartDateFormat(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertTimeBasedCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
badDate := "not-a-date"
|
|
req := UpdateCampaignRequest{StartDate: &badDate}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for bad start date, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_InvalidEndDateFormat verifies bad end_date
|
|
// format returns 400.
|
|
func TestUpdateDiscountCampaign_InvalidEndDateFormat(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertTimeBasedCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
badDate := "not-a-date"
|
|
req := UpdateCampaignRequest{EndDate: &badDate}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for bad end date, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_InvalidMilestoneValue verifies milestone_value
|
|
// out of range returns 400.
|
|
func TestUpdateDiscountCampaign_InvalidMilestoneValue(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
|
|
t.Run("zero_value", func(t *testing.T) {
|
|
zero := 0
|
|
req := UpdateCampaignRequest{MilestoneValue: &zero}
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for zero milestone value, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("negative_value", func(t *testing.T) {
|
|
neg := -1
|
|
req := UpdateCampaignRequest{MilestoneValue: &neg}
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for negative milestone value, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_UpdateDescription verifies updating description
|
|
// only.
|
|
func TestUpdateDiscountCampaign_UpdateDescription(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
desc := "New description"
|
|
req := UpdateCampaignRequest{Description: &desc}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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.Description == nil || *campaign.Description != "New description" {
|
|
t.Errorf("expected description 'New description', got %v", campaign.Description)
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_UpdateDiscountPercent verifies updating discount
|
|
// percent successfully.
|
|
func TestUpdateDiscountCampaign_UpdateDiscountPercent(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
dp := 25.0
|
|
req := UpdateCampaignRequest{DiscountPercent: &dp}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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.DiscountPercent != 25 {
|
|
t.Errorf("expected discount percent 25, got %f", campaign.DiscountPercent)
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_UpdateScope verifies updating scope successfully.
|
|
func TestUpdateDiscountCampaign_UpdateScope(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
scope := "all_bookings"
|
|
req := UpdateCampaignRequest{Scope: &scope}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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.Scope == nil || *campaign.Scope != "all_bookings" {
|
|
t.Errorf("expected scope 'all_bookings', got %v", campaign.Scope)
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_UpdateDates verifies updating start_date and
|
|
// end_date successfully.
|
|
func TestUpdateDiscountCampaign_UpdateDates(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertTimeBasedCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
startDate := fmt.Sprintf("%sZ", clock.Now().Add(24*time.Hour).Format("2006-01-02T15:04:05"))
|
|
endDate := fmt.Sprintf("%sZ", clock.Now().Add(14*24*time.Hour).Format("2006-01-02T15:04:05"))
|
|
req := UpdateCampaignRequest{
|
|
StartDate: &startDate,
|
|
EndDate: &endDate,
|
|
}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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.StartDate == nil {
|
|
t.Error("expected start_date to be set")
|
|
}
|
|
if campaign.EndDate == nil {
|
|
t.Error("expected end_date to be set")
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_UpdateMilestoneFields verifies updating milestone
|
|
// fields successfully.
|
|
func TestUpdateDiscountCampaign_UpdateMilestoneFields(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
mt := "per_user_booking_count"
|
|
mv := 10
|
|
mu := "bookings"
|
|
req := UpdateCampaignRequest{
|
|
MilestoneType: &mt,
|
|
MilestoneValue: &mv,
|
|
MilestoneUnit: &mu,
|
|
}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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.MilestoneType == nil || *campaign.MilestoneType != "per_user_booking_count" {
|
|
t.Errorf("expected milestone type 'per_user_booking_count', got %v", campaign.MilestoneType)
|
|
}
|
|
if campaign.MilestoneValue == nil || *campaign.MilestoneValue != 10 {
|
|
t.Errorf("expected milestone value 10, got %v", campaign.MilestoneValue)
|
|
}
|
|
if campaign.MilestoneUnit == nil || *campaign.MilestoneUnit != "bookings" {
|
|
t.Errorf("expected milestone unit 'bookings', got %v", campaign.MilestoneUnit)
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_UpdateMaxRedemptions verifies updating
|
|
// max_redemptions successfully.
|
|
func TestUpdateDiscountCampaign_UpdateMaxRedemptions(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Test", 10, "draft")
|
|
|
|
mr := 200
|
|
req := UpdateCampaignRequest{MaxRedemptions: &mr}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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.MaxRedemptions == nil || *campaign.MaxRedemptions != 200 {
|
|
t.Errorf("expected max redemptions 200, got %v", campaign.MaxRedemptions)
|
|
}
|
|
}
|
|
|
|
// TestUpdateDiscountCampaign_UpdateMultipleFields verifies updating several
|
|
// fields at once: name, description, discount_percent, status, max_redemptions.
|
|
func TestUpdateDiscountCampaign_UpdateMultipleFields(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
testAdminID = adminID
|
|
|
|
campaignID := insertMilestoneCampaign(t, ctx, tx, adminID, "Original Name", 10, "draft")
|
|
|
|
newName := "Updated Name"
|
|
newDesc := "Updated description"
|
|
newDP := 20.0
|
|
newStatus := "active"
|
|
newMR := 50
|
|
req := UpdateCampaignRequest{
|
|
Name: &newName,
|
|
Description: &newDesc,
|
|
DiscountPercent: &newDP,
|
|
Status: &newStatus,
|
|
MaxRedemptions: &newMR,
|
|
}
|
|
handler := http.HandlerFunc(UpdateDiscountCampaign)
|
|
w := makeCampaignRequest(handler, "PUT", "/api/admin/discount-campaigns/"+campaignID, req, ctx, adminID)
|
|
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 != "Updated Name" {
|
|
t.Errorf("expected name 'Updated Name', got %q", campaign.Name)
|
|
}
|
|
if campaign.Description == nil || *campaign.Description != "Updated description" {
|
|
t.Errorf("expected description 'Updated description', got %v", campaign.Description)
|
|
}
|
|
if campaign.DiscountPercent != 20 {
|
|
t.Errorf("expected discount percent 20, got %f", campaign.DiscountPercent)
|
|
}
|
|
if campaign.Status != "active" {
|
|
t.Errorf("expected status 'active', got %q", campaign.Status)
|
|
}
|
|
if campaign.MaxRedemptions == nil || *campaign.MaxRedemptions != 50 {
|
|
t.Errorf("expected max redemptions 50, got %v", campaign.MaxRedemptions)
|
|
}
|
|
}
|