From 9d70d42bd4a48a7a34f10c54b20c19dc203d6d97 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 17:05:52 +0100 Subject: [PATCH] test(admin): add settings validation, public info, and out_of_hours tests Add comprehensive tests for business settings validation (name length, phone, email, VAT number, URL, voucher type, VAT rate). Add GetPublicBusinessInfo tests verifying correct JSON shape. Add out_of_hours field search tests for admin booking search. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/admin/bookings_test.go | 141 ++++ backend/handlers/admin/settings_test.go | 967 +++++++++++++++++++++--- 2 files changed, 1017 insertions(+), 91 deletions(-) diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index 986bc4a..7a66855 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -1444,6 +1444,147 @@ func TestAdminBookings_Search_MultipleResults(t *testing.T) { } } +// ============================================================================= +// SearchAdminBookingsHandler — out_of_hours field +// ============================================================================= + +func TestAdminBookings_Search_OutOfHoursField(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, out_of_hours, notes) + VALUES ($1, $2, 'confirmed', true, 'OutOfHoursSearchTest') + RETURNING id + `, userID, futureTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create out-of-hours booking: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=OutOfHoursSearchTest", nil, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.BookingListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Bookings) == 0 { + t.Fatal("expected at least 1 booking in search results") + } + + var found bool + for _, b := range resp.Bookings { + if b.ID == bookingID { + if !b.OutOfHours { + t.Error("expected out_of_hours=true for the out-of-hours booking in search results") + } + found = true + break + } + } + if !found { + t.Error("expected out-of-hours booking to appear in search results") + } +} + +func TestAdminBookings_Search_OutOfHoursFalseByDefault(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, 'confirmed', 'NormalSearchBooking') + RETURNING id + `, userID, time.Now().Add(72*time.Hour).Truncate(time.Second)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NormalSearchBooking", nil, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.BookingListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Bookings) == 0 { + t.Fatal("expected at least 1 booking in search results") + } + + var found bool + for _, b := range resp.Bookings { + if b.ID == bookingID { + if b.OutOfHours { + t.Error("expected out_of_hours=false (default) for normal booking in search results") + } + found = true + break + } + } + if !found { + t.Error("expected normal booking to appear in search results") + } +} + // ============================================================================= // Admin List Edit Requests Tests // ============================================================================= diff --git a/backend/handlers/admin/settings_test.go b/backend/handlers/admin/settings_test.go index 5b743f2..8c8a7bc 100644 --- a/backend/handlers/admin/settings_test.go +++ b/backend/handlers/admin/settings_test.go @@ -5,6 +5,8 @@ package admin import ( "net/http" + "net/http/httptest" + "strings" "testing" "crussell/testutils" @@ -14,6 +16,115 @@ func intPtr(i int) *int { return &i } func float64Ptr(f float64) *float64 { return &f } func boolPtr(b bool) *bool { return &b } +// ─── Public info ────────────────────────────────────────────────────────────── + +// TestGetPublicBusinessInfo verifies that GET /business-info returns the public +// subset of business settings (no admin-only fields like currency_code, +// website_url, gift_card_expiry_months, voucher_type). +func TestGetPublicBusinessInfo(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET + business_name = 'Test Nail Salon', + business_address = '456 High Street', + business_phone = '+441234567890', + business_email = 'info@testsalon.com', + is_vat_registered = true, + vat_registration_number = 'GB123456789', + default_vat_rate = 20.00, + currency_code = 'GBP', + website_url = 'https://testsalon.com', + gift_card_expiry_months = 24, + voucher_type = 'SPV' + `) + if err != nil { + t.Fatalf("failed to seed business settings: %v", err) + } + + // GetPublicBusinessInfo does NOT require admin auth — it's a public endpoint. + handler := http.HandlerFunc(GetPublicBusinessInfo) + req := httptest.NewRequest("GET", "/business-info", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var info PublicBusinessInfo + if err := parseResponseBody(w, &info); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if info.BusinessName != "Test Nail Salon" { + t.Errorf("expected BusinessName 'Test Nail Salon', got '%s'", info.BusinessName) + } + if info.BusinessAddress != "456 High Street" { + t.Errorf("expected BusinessAddress '456 High Street', got '%s'", info.BusinessAddress) + } + if info.BusinessPhone == nil || *info.BusinessPhone != "+441234567890" { + t.Errorf("expected BusinessPhone '+441234567890', got %v", info.BusinessPhone) + } + if info.BusinessEmail == nil || *info.BusinessEmail != "info@testsalon.com" { + t.Errorf("expected BusinessEmail 'info@testsalon.com', got %v", info.BusinessEmail) + } + if !info.IsVATRegistered { + t.Errorf("expected IsVATRegistered to be true") + } + if info.VATRegistrationNumber == nil || *info.VATRegistrationNumber != "GB123456789" { + t.Errorf("expected VATRegistrationNumber 'GB123456789', got %v", info.VATRegistrationNumber) + } + if info.DefaultVATRate != 20.00 { + t.Errorf("expected DefaultVATRate 20.00, got %f", info.DefaultVATRate) + } +} + +// TestGetPublicBusinessInfo_NotAdminSafe verifies that the public info endpoint +// does NOT expose admin-only fields (currency_code, website_url, +// gift_card_expiry_months, voucher_type). +func TestGetPublicBusinessInfo_NotAdminSafe(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET + business_name = 'Test Salon', business_address = '123 St', + currency_code = 'GBP', website_url = 'https://secret.admin.url', + gift_card_expiry_months = 48, voucher_type = 'MPV' + `) + if err != nil { + t.Fatalf("failed to seed business settings: %v", err) + } + + handler := http.HandlerFunc(GetPublicBusinessInfo) + req := httptest.NewRequest("GET", "/business-info", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + bodyStr := w.Body.String() + // Admin-only fields MUST NOT appear in the JSON response + if strings.Contains(bodyStr, "currency_code") { + t.Error("expected currency_code to be excluded from public info response") + } + if strings.Contains(bodyStr, "website_url") { + t.Error("expected website_url to be excluded from public info response") + } + if strings.Contains(bodyStr, "gift_card_expiry_months") { + t.Error("expected gift_card_expiry_months to be excluded from public info response") + } + if strings.Contains(bodyStr, "voucher_type") { + t.Error("expected voucher_type to be excluded from public info response") + } +} + +// ─── GET ───────────────────────────────────────────────────────────────────── + // TestGetBusinessSettings verifies that GET /api/admin/settings returns the // current business settings row. func TestGetBusinessSettings(t *testing.T) { @@ -53,9 +164,10 @@ func TestGetBusinessSettings(t *testing.T) { } } -// TestUpdateBusinessSettings verifies that updating a single field via -// PUT /api/admin/settings returns 200 with the updated settings. -func TestUpdateBusinessSettings(t *testing.T) { +// ─── Successful updates ────────────────────────────────────────────────────── + +// TestUpdateBusinessSettings_SingleField verifies updating a single field (business_name). +func TestUpdateBusinessSettings_SingleField(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UpdateBusinessSettings) @@ -78,8 +190,8 @@ func TestUpdateBusinessSettings(t *testing.T) { } } -// TestUpdateBusinessSettings_MultipleFields verifies that updating several -// fields at once works correctly. +// TestUpdateBusinessSettings_MultipleFields verifies that updating several fields +// at once works correctly. func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) @@ -87,8 +199,12 @@ func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { body := UpdateBusinessSettingsRequest{ BusinessName: stringPtr("Multi Update Salon"), BusinessAddress: stringPtr("456 New St"), + BusinessPhone: stringPtr("+441234567890"), + BusinessEmail: stringPtr("salon@example.com"), + WebsiteURL: stringPtr("https://salon.example.com"), GiftCardExpiryMonths: intPtr(24), VoucherType: stringPtr("MPV"), + DefaultVATRate: float64Ptr(20.00), } w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) @@ -107,6 +223,15 @@ func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { if s.BusinessAddress != "456 New St" { t.Errorf("expected BusinessAddress '456 New St', got '%s'", s.BusinessAddress) } + if s.BusinessPhone == nil || *s.BusinessPhone != "+441234567890" { + t.Errorf("expected BusinessPhone '+441234567890', got %v", s.BusinessPhone) + } + if s.BusinessEmail == nil || *s.BusinessEmail != "salon@example.com" { + t.Errorf("expected BusinessEmail 'salon@example.com', got %v", s.BusinessEmail) + } + if s.WebsiteURL == nil || *s.WebsiteURL != "https://salon.example.com" { + t.Errorf("expected WebsiteURL 'https://salon.example.com', got %v", s.WebsiteURL) + } if s.GiftCardExpiryMonths != 24 { t.Errorf("expected GiftCardExpiryMonths 24, got %d", s.GiftCardExpiryMonths) } @@ -115,91 +240,6 @@ func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { } } -// TestUpdateBusinessSettings_InvalidVoucherType verifies that an invalid -// voucher_type value returns 400. -func TestUpdateBusinessSettings_InvalidVoucherType(t *testing.T) { - ctx, _ := testutils.SetupTestTx(t) - - handler := http.HandlerFunc(UpdateBusinessSettings) - body := UpdateBusinessSettingsRequest{ - VoucherType: stringPtr("INVALID"), - } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) - } - if w.Body.String() != "voucher_type must be 'SPV' or 'MPV'\n" { - t.Errorf("unexpected error message: %s", w.Body.String()) - } -} - -// TestUpdateBusinessSettings_NegativeExpiryMonths verifies that a -// gift_card_expiry_months value less than 1 returns 400. -func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) { - ctx, _ := testutils.SetupTestTx(t) - - handler := http.HandlerFunc(UpdateBusinessSettings) - body := UpdateBusinessSettingsRequest{ - GiftCardExpiryMonths: intPtr(0), - } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) - } - if w.Body.String() != "gift_card_expiry_months must be at least 1\n" { - t.Errorf("unexpected error message: %s", w.Body.String()) - } -} - -// TestUpdateBusinessSettings_InvalidVATRate verifies that a default_vat_rate -// outside the 0-100 range returns 400. -func TestUpdateBusinessSettings_InvalidVATRate(t *testing.T) { - ctx, _ := testutils.SetupTestTx(t) - - handler := http.HandlerFunc(UpdateBusinessSettings) - - body := UpdateBusinessSettingsRequest{ - DefaultVATRate: float64Ptr(-1), - } - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400 for negative rate, got %d. body: %s", w.Code, w.Body.String()) - } - if w.Body.String() != "default_vat_rate must be between 0 and 100\n" { - t.Errorf("unexpected error message: %s", w.Body.String()) - } - body2 := UpdateBusinessSettingsRequest{ - DefaultVATRate: float64Ptr(101), - } - w2 := makeAdminRequest(handler, "PUT", "/api/admin/settings", body2, ctx) - - if w2.Code != http.StatusBadRequest { - t.Errorf("expected status 400 for rate > 100, got %d. body: %s", w2.Code, w2.Body.String()) - } - if w2.Body.String() != "default_vat_rate must be between 0 and 100\n" { - t.Errorf("unexpected error message: %s", w2.Body.String()) - } -} - -// TestUpdateBusinessSettings_NoFields verifies that an empty request body -// (no fields to update) returns 400. -func TestUpdateBusinessSettings_NoFields(t *testing.T) { - ctx, _ := testutils.SetupTestTx(t) - - handler := http.HandlerFunc(UpdateBusinessSettings) - w := makeAdminRequest(handler, "PUT", "/api/admin/settings", UpdateBusinessSettingsRequest{}, ctx) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) - } - if w.Body.String() != "No fields to update\n" { - t.Errorf("unexpected error message: %s", w.Body.String()) - } -} - // TestUpdateBusinessSettings_PartialUpdate verifies that updating a single field // leaves other fields unchanged. func TestUpdateBusinessSettings_PartialUpdate(t *testing.T) { @@ -228,7 +268,6 @@ func TestUpdateBusinessSettings_PartialUpdate(t *testing.T) { if s.GiftCardExpiryMonths != 36 { t.Errorf("expected GiftCardExpiryMonths 36, got %d", s.GiftCardExpiryMonths) } - if s.BusinessName != "Test Salon" { t.Errorf("expected BusinessName 'Test Salon' (unchanged), got '%s'", s.BusinessName) } @@ -242,3 +281,749 @@ func TestUpdateBusinessSettings_PartialUpdate(t *testing.T) { t.Errorf("expected CurrencyCode 'GBP' (unchanged), got '%s'", s.CurrencyCode) } } + +// ─── Empty request ─────────────────────────────────────────────────────────── + +// TestUpdateBusinessSettings_NoFields verifies that an empty request body +// (no fields to update) returns 400. +func TestUpdateBusinessSettings_NoFields(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", UpdateBusinessSettingsRequest{}, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "No fields to update\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } +} + +// ─── business_name validation ──────────────────────────────────────────────── + +// TestUpdateBusinessSettings_Name_Empty rejects an empty business_name. +func TestUpdateBusinessSettings_Name_Empty(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessName: stringPtr(""), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for empty name, got %d. body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "business_name") { + t.Errorf("expected error about business_name, got: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_Name_TooLong rejects a business_name > 255 chars. +func TestUpdateBusinessSettings_Name_TooLong(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + longName := strings.Repeat("A", 256) + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessName: stringPtr(longName), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for long name, got %d. body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "business_name") { + t.Errorf("expected error about business_name, got: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_Name_Boundary accepts a 255-char name (DB max). +func TestUpdateBusinessSettings_Name_Boundary(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + name := strings.Repeat("A", 255) + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessName: stringPtr(name), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for 255-char name, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.BusinessName != name { + t.Errorf("expected business name to match, got %q", s.BusinessName) + } +} + +// ─── business_address validation ───────────────────────────────────────────── + +// TestUpdateBusinessSettings_Address_Empty rejects an empty business_address. +func TestUpdateBusinessSettings_Address_Empty(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessAddress: stringPtr(""), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for empty address, got %d. body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "business_address") { + t.Errorf("expected error about business_address, got: %s", w.Body.String()) + } +} + +// ─── business_phone validation ─────────────────────────────────────────────── + +// TestUpdateBusinessSettings_Phone_TooLong rejects a phone > 20 chars. +func TestUpdateBusinessSettings_Phone_TooLong(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessPhone: stringPtr("+44" + strings.Repeat("1", 18)), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for long phone, got %d. body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "business_phone") { + t.Errorf("expected error about business_phone, got: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_Phone_Boundary accepts a 20-char phone (DB max). +func TestUpdateBusinessSettings_Phone_Boundary(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + phone := "+44" + strings.Repeat("1", 16) // 19 chars — within limit + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessPhone: stringPtr(phone), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for valid phone, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.BusinessPhone == nil || *s.BusinessPhone != phone { + t.Errorf("expected phone %q, got %v", phone, s.BusinessPhone) + } +} + +// TestUpdateBusinessSettings_Phone_Null clears phone by setting it to null. +func TestUpdateBusinessSettings_Phone_Null(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + // Seed a phone value first + _, err := tx.Exec(ctx, `UPDATE business_settings SET business_phone = '+441234567890'`) + if err != nil { + t.Fatalf("failed to seed phone: %v", err) + } + + handler := http.HandlerFunc(UpdateBusinessSettings) + nullPhone := "" + body := UpdateBusinessSettingsRequest{ + BusinessPhone: &nullPhone, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for clearing phone, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.BusinessPhone == nil || *s.BusinessPhone != "" { + t.Errorf("expected BusinessPhone to be cleared, got %v", s.BusinessPhone) + } +} + +// ─── business_email validation ─────────────────────────────────────────────── + +// TestUpdateBusinessSettings_Email_TooLong rejects an email > 254 chars. +func TestUpdateBusinessSettings_Email_TooLong(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + local := strings.Repeat("a", 250) + email := local + "@b.co" + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessEmail: stringPtr(email), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for long email, got %d. body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "business_email") { + t.Errorf("expected error about business_email, got: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_Email_Null clears email by setting it to null. +func TestUpdateBusinessSettings_Email_Null(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET business_email = 'test@example.com'`) + if err != nil { + t.Fatalf("failed to seed email: %v", err) + } + + handler := http.HandlerFunc(UpdateBusinessSettings) + nullEmail := "" + body := UpdateBusinessSettingsRequest{ + BusinessEmail: &nullEmail, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for clearing email, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.BusinessEmail == nil || *s.BusinessEmail != "" { + t.Errorf("expected BusinessEmail to be cleared, got %v", s.BusinessEmail) + } +} + +// ─── vat_registration_number validation ────────────────────────────────────── + +// TestUpdateBusinessSettings_VatNumber_TooLong rejects a VAT number > 20 chars. +func TestUpdateBusinessSettings_VatNumber_TooLong(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + VATRegistrationNumber: stringPtr(strings.Repeat("A", 21)), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for long VAT number, got %d. body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "vat_registration_number") { + t.Errorf("expected error about vat_registration_number, got: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_VatNumber_Boundary accepts a 20-char VAT number. +func TestUpdateBusinessSettings_VatNumber_Boundary(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + vatNum := strings.Repeat("A", 20) + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + VATRegistrationNumber: stringPtr(vatNum), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for valid VAT number, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.VATRegistrationNumber == nil || *s.VATRegistrationNumber != vatNum { + t.Errorf("expected VAT number %q, got %v", vatNum, s.VATRegistrationNumber) + } +} + +// ─── website_url validation ────────────────────────────────────────────────── + +// TestUpdateBusinessSettings_Website_NoScheme rejects a URL without http/https. +func TestUpdateBusinessSettings_Website_NoScheme(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + WebsiteURL: stringPtr("www.example.com"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for no scheme, got %d. body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "website_url") { + t.Errorf("expected error about website_url, got: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_Website_FTPScheme rejects a URL with ftp://. +func TestUpdateBusinessSettings_Website_FTPScheme(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + WebsiteURL: stringPtr("ftp://files.example.com"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for ftp scheme, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestUpdateBusinessSettings_Website_EmptyString clears the website URL. +func TestUpdateBusinessSettings_Website_EmptyString(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET website_url = 'https://example.com'`) + if err != nil { + t.Fatalf("failed to seed website: %v", err) + } + + handler := http.HandlerFunc(UpdateBusinessSettings) + empty := "" + body := UpdateBusinessSettingsRequest{ + WebsiteURL: &empty, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for clearing website, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.WebsiteURL != nil && *s.WebsiteURL != "" { + t.Errorf("expected WebsiteURL to be cleared, got %v", *s.WebsiteURL) + } +} + +// TestUpdateBusinessSettings_Website_ValidHTTPS accepts a valid https URL. +func TestUpdateBusinessSettings_Website_ValidHTTPS(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + WebsiteURL: stringPtr("https://www.example.com"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for valid website, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.WebsiteURL == nil || *s.WebsiteURL != "https://www.example.com" { + t.Errorf("expected website URL, got %v", s.WebsiteURL) + } +} + +// TestUpdateBusinessSettings_Website_ValidHTTP accepts a valid http URL. +func TestUpdateBusinessSettings_Website_ValidHTTP(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + WebsiteURL: stringPtr("http://localhost:3000"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for valid website, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ─── voucher_type validation ───────────────────────────────────────────────── + +// TestUpdateBusinessSettings_InvalidVoucherType verifies that an invalid +// voucher_type value returns 400. +func TestUpdateBusinessSettings_InvalidVoucherType(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + VoucherType: stringPtr("INVALID"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "voucher_type must be 'SPV' or 'MPV'\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_InvalidVoucherType_Empty rejects an empty voucher_type. +func TestUpdateBusinessSettings_InvalidVoucherType_Empty(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + VoucherType: stringPtr(""), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for empty voucher_type, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestUpdateBusinessSettings_VoucherType_SPV accepts SPV. +func TestUpdateBusinessSettings_VoucherType_SPV(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + VoucherType: stringPtr("SPV"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for SPV, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.VoucherType != "SPV" { + t.Errorf("expected VoucherType 'SPV', got '%s'", s.VoucherType) + } +} + +// TestUpdateBusinessSettings_VoucherType_MPV accepts MPV. +func TestUpdateBusinessSettings_VoucherType_MPV(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + VoucherType: stringPtr("MPV"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for MPV, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.VoucherType != "MPV" { + t.Errorf("expected VoucherType 'MPV', got '%s'", s.VoucherType) + } +} + +// ─── gift_card_expiry_months validation ────────────────────────────────────── + +// TestUpdateBusinessSettings_NegativeExpiryMonths verifies that a +// gift_card_expiry_months value less than 1 returns 400. +func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + GiftCardExpiryMonths: intPtr(0), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "gift_card_expiry_months must be at least 1\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_ExpiryMonths_Negative rejects negative values. +func TestUpdateBusinessSettings_ExpiryMonths_Negative(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + GiftCardExpiryMonths: intPtr(-5), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for negative expiry, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "gift_card_expiry_months must be at least 1\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_ExpiryMonths_LargeValue accepts a large but valid value. +func TestUpdateBusinessSettings_ExpiryMonths_LargeValue(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + GiftCardExpiryMonths: intPtr(9999), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for large expiry, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ─── default_vat_rate validation ───────────────────────────────────────────── + +// TestUpdateBusinessSettings_InvalidVATRate verifies that a default_vat_rate +// outside the 0-100 range returns 400. +func TestUpdateBusinessSettings_InvalidVATRate(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + + body := UpdateBusinessSettingsRequest{ + DefaultVATRate: float64Ptr(-1), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for negative rate, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "default_vat_rate must be between 0 and 100\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } + + body2 := UpdateBusinessSettingsRequest{ + DefaultVATRate: float64Ptr(101), + } + w2 := makeAdminRequest(handler, "PUT", "/api/admin/settings", body2, ctx) + + if w2.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for rate > 100, got %d. body: %s", w2.Code, w2.Body.String()) + } + if w2.Body.String() != "default_vat_rate must be between 0 and 100\n" { + t.Errorf("unexpected error message: %s", w2.Body.String()) + } +} + +// TestUpdateBusinessSettings_VATRate_Boundary_Zero accepts 0% VAT rate. +func TestUpdateBusinessSettings_VATRate_Boundary_Zero(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + DefaultVATRate: float64Ptr(0), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for 0%% VAT rate, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestUpdateBusinessSettings_VATRate_Boundary_100 accepts 100% VAT rate. +func TestUpdateBusinessSettings_VATRate_Boundary_100(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + DefaultVATRate: float64Ptr(100), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for 100%% VAT rate, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestUpdateBusinessSettings_VATRate_Fractional accepts fractional VAT rates. +func TestUpdateBusinessSettings_VATRate_Fractional(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + DefaultVATRate: float64Ptr(5.5), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for 5.5%% VAT rate, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.DefaultVATRate != 5.5 { + t.Errorf("expected DefaultVATRate 5.5, got %f", s.DefaultVATRate) + } +} + +// ─── is_vat_registered toggle ──────────────────────────────────────────────── + +// TestUpdateBusinessSettings_EnableVATRegistration enables VAT registration. +func TestUpdateBusinessSettings_EnableVATRegistration(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + trueVal := true + vatRate := 20.0 + vatNum := "GB123456789" + body := UpdateBusinessSettingsRequest{ + IsVATRegistered: &trueVal, + DefaultVATRate: &vatRate, + VATRegistrationNumber: &vatNum, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 when enabling VAT, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if !s.IsVATRegistered { + t.Errorf("expected IsVATRegistered to be true") + } + if s.DefaultVATRate != 20.0 { + t.Errorf("expected DefaultVATRate 20.0, got %f", s.DefaultVATRate) + } + if s.VATRegistrationNumber == nil || *s.VATRegistrationNumber != "GB123456789" { + t.Errorf("expected VATRegistrationNumber 'GB123456789', got %v", s.VATRegistrationNumber) + } +} + +// TestUpdateBusinessSettings_DisableVATRegistration disables VAT registration. +// ─── validateURL tests ─────────────────────────────────────────────────────── + +func TestValidateURL_ValidHTTPS(t *testing.T) { + if err := validateURL("https://example.com"); err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func TestValidateURL_ValidHTTP(t *testing.T) { + if err := validateURL("http://example.com"); err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func TestValidateURL_ValidHTTP_Localhost(t *testing.T) { + if err := validateURL("http://localhost:3000"); err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func TestValidateURL_ValidHTTPS_WithPath(t *testing.T) { + if err := validateURL("https://example.com/path/to/page?q=1"); err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func TestValidateURL_NoScheme(t *testing.T) { + err := validateURL("www.example.com") + if err == nil { + t.Fatal("expected error for missing scheme") + } + if !strings.Contains(err.Error(), "http") { + t.Errorf("expected error mentioning http/https, got: %v", err) + } +} + +func TestValidateURL_FTPScheme(t *testing.T) { + err := validateURL("ftp://files.example.com") + if err == nil { + t.Fatal("expected error for ftp scheme") + } + if !strings.Contains(err.Error(), "http") { + t.Errorf("expected error mentioning http/https, got: %v", err) + } +} + +func TestValidateURL_EmptyHost(t *testing.T) { + err := validateURL("https://") + if err == nil { + t.Fatal("expected error for empty host") + } + if !strings.Contains(err.Error(), "host") { + t.Errorf("expected error about host, got: %v", err) + } +} + +func TestValidateURL_InvalidString(t *testing.T) { + err := validateURL("not a url at all") + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestValidateURL_EmptyString(t *testing.T) { + err := validateURL("") + // url.Parse("") returns a valid url.URL with empty Scheme + // So validateURL should reject it (no scheme, no host) + if err == nil { + t.Fatal("expected error for empty string") + } +} + +func TestValidateURL_HTTPSOnlyScheme(t *testing.T) { + if err := validateURL("https://"); err == nil { + t.Fatal("expected error for https:// with no host") + } +} + +func TestValidateURL_IPAddress(t *testing.T) { + if err := validateURL("https://192.168.1.1"); err != nil { + t.Errorf("expected no error for HTTPS IP, got %v", err) + } +} + +// ─── Disable VAT registration ───────────────────────────────────────────────── + +func TestUpdateBusinessSettings_DisableVATRegistration(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`) + if err != nil { + t.Fatalf("failed to enable VAT: %v", err) + } + + handler := http.HandlerFunc(UpdateBusinessSettings) + falseVal := false + body := UpdateBusinessSettingsRequest{ + IsVATRegistered: &falseVal, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 when disabling VAT, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if s.IsVATRegistered { + t.Errorf("expected IsVATRegistered to be false") + } +}