diff --git a/app/modules/loyalty/routes/pages/admin.py b/app/modules/loyalty/routes/pages/admin.py index aac21656..1d556457 100644 --- a/app/modules/loyalty/routes/pages/admin.py +++ b/app/modules/loyalty/routes/pages/admin.py @@ -99,6 +99,31 @@ async def admin_loyalty_merchant_detail( ) +@router.get( + "/merchants/{merchant_id}/program", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_program_edit( + request: Request, + merchant_id: int = Path(..., description="Merchant ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render program configuration edit page for a merchant. + Allows admin to create or edit the merchant's loyalty program settings. + """ + return templates.TemplateResponse( + "loyalty/admin/program-edit.html", + { + "request": request, + "user": current_user, + "merchant_id": merchant_id, + }, + ) + + @router.get( "/merchants/{merchant_id}/settings", response_class=HTMLResponse, diff --git a/app/modules/loyalty/static/admin/js/loyalty-program-edit.js b/app/modules/loyalty/static/admin/js/loyalty-program-edit.js new file mode 100644 index 00000000..f417c140 --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-program-edit.js @@ -0,0 +1,211 @@ +// app/modules/loyalty/static/admin/js/loyalty-program-edit.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const loyaltyProgramEditLog = window.LogConfig.loggers.loyaltyProgramEdit || window.LogConfig.createLogger('loyaltyProgramEdit'); + +function adminLoyaltyProgramEdit() { + return { + ...data(), + currentPage: 'loyalty-programs', + + merchantId: null, + merchant: null, + programId: null, + + settings: { + loyalty_type: 'points', + points_per_euro: 1, + welcome_bonus_points: 0, + minimum_redemption_points: 100, + points_expiration_days: null, + points_rewards: [], + stamps_target: 10, + stamps_reward_description: '', + card_name: '', + card_color: '#4F46E5', + is_active: true + }, + + loading: false, + saving: false, + deleting: false, + error: null, + isNewProgram: false, + showDeleteModal: false, + + get backUrl() { + return `/admin/loyalty/merchants/${this.merchantId}`; + }, + + async init() { + loyaltyProgramEditLog.info('=== ADMIN PROGRAM EDIT PAGE INITIALIZING ==='); + if (window._adminProgramEditInitialized) return; + window._adminProgramEditInitialized = true; + + // Extract merchant ID from URL: /admin/loyalty/merchants/{id}/program + const pathParts = window.location.pathname.split('/'); + const merchantsIndex = pathParts.indexOf('merchants'); + if (merchantsIndex !== -1 && pathParts[merchantsIndex + 1]) { + this.merchantId = parseInt(pathParts[merchantsIndex + 1]); + } + + if (!this.merchantId) { + this.error = 'Invalid merchant ID'; + loyaltyProgramEditLog.error('Could not extract merchant ID from URL'); + return; + } + + loyaltyProgramEditLog.info('Merchant ID:', this.merchantId); + await this.loadData(); + loyaltyProgramEditLog.info('=== ADMIN PROGRAM EDIT PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadData() { + this.loading = true; + this.error = null; + + try { + // Load merchant info and program data in parallel + await Promise.all([ + this.loadMerchant(), + this.loadProgram() + ]); + } catch (error) { + loyaltyProgramEditLog.error('Failed to load data:', error); + this.error = error.message || 'Failed to load data'; + } finally { + this.loading = false; + } + }, + + async loadMerchant() { + const response = await apiClient.get(`/admin/merchants/${this.merchantId}`); + if (response) { + this.merchant = response; + loyaltyProgramEditLog.info('Merchant loaded:', this.merchant.name); + } + }, + + async loadProgram() { + try { + // Get program via merchant stats endpoint (includes program data) + const response = await apiClient.get(`/admin/loyalty/merchants/${this.merchantId}/stats`); + + if (response && response.program) { + const program = response.program; + this.programId = program.id; + this.isNewProgram = false; + + this.settings = { + loyalty_type: program.loyalty_type || 'points', + points_per_euro: program.points_per_euro || 1, + welcome_bonus_points: program.welcome_bonus_points || 0, + minimum_redemption_points: program.minimum_redemption_points || 100, + points_expiration_days: program.points_expiration_days || null, + points_rewards: program.points_rewards || [], + stamps_target: program.stamps_target || 10, + stamps_reward_description: program.stamps_reward_description || '', + card_name: program.card_name || '', + card_color: program.card_color || '#4F46E5', + is_active: program.is_active !== false + }; + + loyaltyProgramEditLog.info('Program loaded, ID:', this.programId); + } else { + this.isNewProgram = true; + // Set default card name from merchant + if (this.merchant) { + this.settings.card_name = this.merchant.name + ' Loyalty'; + } + loyaltyProgramEditLog.info('No program found, create mode'); + } + } catch (error) { + // If stats fail (e.g., no program), we're in create mode + this.isNewProgram = true; + if (this.merchant) { + this.settings.card_name = this.merchant.name + ' Loyalty'; + } + loyaltyProgramEditLog.info('No program, switching to create mode'); + } + }, + + async saveSettings() { + this.saving = true; + + try { + // Ensure rewards have IDs + this.settings.points_rewards = this.settings.points_rewards.map((r, i) => ({ + ...r, + id: r.id || `reward_${i + 1}`, + is_active: r.is_active !== false + })); + + if (this.isNewProgram) { + const response = await apiClient.post( + `/admin/loyalty/merchants/${this.merchantId}/program`, + this.settings + ); + this.programId = response.id; + this.isNewProgram = false; + Utils.showToast('Program created successfully', 'success'); + } else { + await apiClient.patch( + `/admin/loyalty/programs/${this.programId}`, + this.settings + ); + Utils.showToast('Program updated successfully', 'success'); + } + + loyaltyProgramEditLog.info('Program saved'); + // Navigate back to merchant detail + window.location.href = this.backUrl; + } catch (error) { + Utils.showToast(`Failed to save: ${error.message}`, 'error'); + loyaltyProgramEditLog.error('Save failed:', error); + } finally { + this.saving = false; + } + }, + + confirmDelete() { + this.showDeleteModal = true; + }, + + async deleteProgram() { + if (!this.programId) return; + this.deleting = true; + + try { + await apiClient.delete(`/admin/loyalty/programs/${this.programId}`); + Utils.showToast('Program deleted', 'success'); + loyaltyProgramEditLog.info('Program deleted'); + window.location.href = this.backUrl; + } catch (error) { + Utils.showToast(`Failed to delete: ${error.message}`, 'error'); + loyaltyProgramEditLog.error('Delete failed:', error); + } finally { + this.deleting = false; + this.showDeleteModal = false; + } + }, + + addReward() { + this.settings.points_rewards.push({ + id: `reward_${Date.now()}`, + name: '', + points_required: 100, + description: '', + is_active: true + }); + }, + + removeReward(index) { + this.settings.points_rewards.splice(index, 1); + } + }; +} + +if (!window.LogConfig.loggers.loyaltyProgramEdit) { + window.LogConfig.loggers.loyaltyProgramEdit = window.LogConfig.createLogger('loyaltyProgramEdit'); +} +loyaltyProgramEditLog.info('Admin loyalty program edit module loaded'); diff --git a/app/modules/loyalty/static/admin/js/loyalty-programs.js b/app/modules/loyalty/static/admin/js/loyalty-programs.js index d13e0f3c..4b182a90 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-programs.js +++ b/app/modules/loyalty/static/admin/js/loyalty-programs.js @@ -29,6 +29,17 @@ function adminLoyaltyPrograms() { loading: false, error: null, + // Delete modal state + showDeleteModal: false, + deletingProgram: null, + + // Create program modal state + showCreateModal: false, + merchantSearch: '', + merchantResults: [], + selectedMerchant: null, + searchingMerchants: false, + // Search and filters filters: { search: '', @@ -246,6 +257,69 @@ function adminLoyaltyPrograms() { } }, + // Delete program + confirmDeleteProgram(program) { + this.deletingProgram = program; + this.showDeleteModal = true; + }, + + async deleteProgram() { + if (!this.deletingProgram) return; + try { + await apiClient.delete(`/admin/loyalty/programs/${this.deletingProgram.id}`); + Utils.showToast('Program deleted successfully', 'success'); + loyaltyProgramsLog.info('Program deleted:', this.deletingProgram.id); + this.showDeleteModal = false; + this.deletingProgram = null; + await Promise.all([this.loadPrograms(), this.loadStats()]); + } catch (error) { + Utils.showToast(`Failed to delete program: ${error.message}`, 'error'); + loyaltyProgramsLog.error('Failed to delete program:', error); + this.showDeleteModal = false; + this.deletingProgram = null; + } + }, + + // Search merchants for create modal + searchMerchants() { + if (this._merchantSearchTimeout) { + clearTimeout(this._merchantSearchTimeout); + } + this.selectedMerchant = null; + this._merchantSearchTimeout = setTimeout(async () => { + if (!this.merchantSearch || this.merchantSearch.length < 2) { + this.merchantResults = []; + return; + } + this.searchingMerchants = true; + try { + const params = new URLSearchParams(); + params.append('search', this.merchantSearch); + params.append('limit', 10); + const response = await apiClient.get(`/admin/merchants?${params}`); + this.merchantResults = response.merchants || response || []; + loyaltyProgramsLog.info(`Found ${this.merchantResults.length} merchants`); + } catch (error) { + loyaltyProgramsLog.error('Merchant search failed:', error); + this.merchantResults = []; + } finally { + this.searchingMerchants = false; + } + }, 300); + }, + + // Check if a merchant already has a program in the loaded list + existingProgramForMerchant(merchantId) { + if (!merchantId) return false; + return this.programs.some(p => p.merchant_id === merchantId); + }, + + // Navigate to create program page for selected merchant + goToCreateProgram() { + if (!this.selectedMerchant) return; + window.location.href = `/admin/loyalty/merchants/${this.selectedMerchant.id}/program`; + }, + // Format date for display formatDate(dateString) { if (!dateString) return 'N/A'; diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html index 026b5e2d..a39a71ed 100644 --- a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html @@ -25,11 +25,17 @@ Quick Actions
+ + + Edit Program + - - Loyalty Settings + class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"> + + Admin Policy This merchant has not set up a loyalty program yet.

- + @@ -237,7 +243,7 @@

- Admin Settings + Admin Policy Settings

@@ -271,7 +277,7 @@ :href="`/admin/loyalty/merchants/${merchantId}/settings`" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400"> - Modify admin settings + Modify admin policy
diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html b/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html index a2ba3024..52ae6dc8 100644 --- a/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html @@ -9,7 +9,7 @@ {% block alpine_data %}adminLoyaltyMerchantSettings(){% endblock %} {% block content %} -{% call detail_page_header("'Loyalty Settings: ' + (merchant?.name || '')", backUrl, subtitle_show='merchant') %} +{% call detail_page_header("'Admin Policy: ' + (merchant?.name || '')", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='merchant') %} Admin-controlled settings for this merchant's loyalty program {% endcall %} diff --git a/app/modules/loyalty/templates/loyalty/admin/program-edit.html b/app/modules/loyalty/templates/loyalty/admin/program-edit.html new file mode 100644 index 00000000..606639e1 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/program-edit.html @@ -0,0 +1,252 @@ +{# app/modules/loyalty/templates/loyalty/admin/program-edit.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Program Configuration{% endblock %} + +{% block alpine_data %}adminLoyaltyProgramEdit(){% endblock %} + +{% block content %} +{% call detail_page_header("isNewProgram ? 'Create Program: ' + (merchant?.name || '') : 'Edit Program: ' + (merchant?.name || '')", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='merchant') %} + +{% endcall %} + +{{ loading_state('Loading program configuration...') }} +{{ error_state('Error loading program configuration') }} + +
+
+ +
+

+ + Program Type +

+
+ + + +
+
+ + +
+

+ + Points Configuration +

+
+
+ + +

1 EUR = point(s)

+
+
+ + +

Bonus points awarded on enrollment

+
+
+ + +
+
+ + +

Days of inactivity before points expire (0 = never)

+
+
+
+ + +
+

+ + Stamps Configuration +

+
+
+ + +

Number of stamps needed for reward

+
+
+ + +
+
+
+ + +
+
+

+ + Redemption Rewards +

+ +
+
+ + +
+
+ + +
+

+ + Branding +

+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+

+ + Program Status +

+ +
+ + +
+
+ +
+
+ + Cancel + + +
+
+
+
+ + +
+
+

Delete Loyalty Program

+

+ This will permanently delete the loyalty program and all associated data (cards, transactions, rewards). + This action cannot be undone. +

+
+ + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/admin/programs.html b/app/modules/loyalty/templates/loyalty/admin/programs.html index e0ebbbc4..1c0756c3 100644 --- a/app/modules/loyalty/templates/loyalty/admin/programs.html +++ b/app/modules/loyalty/templates/loyalty/admin/programs.html @@ -10,7 +10,7 @@ {% block alpine_data %}adminLoyaltyPrograms(){% endblock %} {% block content %} -{{ page_header('Loyalty Programs') }} +{{ page_header('Loyalty Programs', action_label='Create Program', action_onclick="showCreateModal = true") }} {{ loading_state('Loading loyalty programs...') }} @@ -219,13 +219,22 @@ - + + + + + +
+ + + + +
+
+

Create Loyalty Program

+

+ Select a merchant to create a loyalty program for. +

+ + +
+ +
+ + + + +
+
+ + +
+ +
+ +
+ No merchants found +
+ + +
+
+ +
+

This merchant already has a loyalty program.

+ + + View / Edit existing program + +
+
+
+ + +
+ + +
+
+
{% endblock %} {% block extra_scripts %} diff --git a/app/modules/loyalty/tests/integration/test_admin_api.py b/app/modules/loyalty/tests/integration/test_admin_api.py index 5a6dfd24..884a8ff3 100644 --- a/app/modules/loyalty/tests/integration/test_admin_api.py +++ b/app/modules/loyalty/tests/integration/test_admin_api.py @@ -383,3 +383,152 @@ class TestAdminExistingEndpoints: headers=super_admin_headers, ) assert response.status_code == 200 + + +# ============================================================================ +# LIST PROGRAMS — Search & Filters +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminListProgramsSearch: + """Tests for GET /api/v1/admin/loyalty/programs search and filter params.""" + + def test_search_by_merchant_name( + self, client, super_admin_headers, admin_program, admin_merchant + ): + """Search query filters programs by merchant name.""" + # Use a substring of the merchant name + search_term = admin_merchant.name[:10] + response = client.get( + f"{BASE}/programs", + params={"search": search_term}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + # Our program should be in results + program_ids = [p["id"] for p in data["programs"]] + assert admin_program.id in program_ids + + def test_search_no_results( + self, client, super_admin_headers, admin_program + ): + """Search with non-matching term returns empty.""" + response = client.get( + f"{BASE}/programs", + params={"search": "zzz_no_such_merchant_999"}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert data["programs"] == [] + + def test_search_case_insensitive( + self, client, super_admin_headers, admin_program, admin_merchant + ): + """Search is case-insensitive (ilike).""" + search_term = admin_merchant.name.upper() + response = client.get( + f"{BASE}/programs", + params={"search": search_term}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + + def test_filter_by_active_status( + self, client, super_admin_headers, admin_program + ): + """is_active filter returns only matching programs.""" + response = client.get( + f"{BASE}/programs", + params={"is_active": True}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + # All returned programs should be active + for p in data["programs"]: + assert p["is_active"] is True + + def test_filter_inactive_excludes_active( + self, client, super_admin_headers, admin_program + ): + """is_active=false excludes active programs.""" + response = client.get( + f"{BASE}/programs", + params={"is_active": False}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + # admin_program is active, should NOT be in results + program_ids = [p["id"] for p in data["programs"]] + assert admin_program.id not in program_ids + + def test_pagination_skip_limit( + self, client, super_admin_headers, admin_program + ): + """Pagination params control results.""" + response = client.get( + f"{BASE}/programs", + params={"skip": 0, "limit": 1}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["programs"]) <= 1 + + def test_search_combined_with_active_filter( + self, client, super_admin_headers, admin_program, admin_merchant + ): + """Search and is_active filter work together.""" + search_term = admin_merchant.name[:10] + response = client.get( + f"{BASE}/programs", + params={"search": search_term, "is_active": True}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + for p in data["programs"]: + assert p["is_active"] is True + + def test_list_programs_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/programs") + assert response.status_code == 401 + + +# ============================================================================ +# CREATE — Duplicate Prevention +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminCreateProgramDuplicate: + """Tests for duplicate program creation prevention.""" + + def test_create_duplicate_program_rejected( + self, client, super_admin_headers, admin_program, admin_merchant + ): + """Cannot create a second program for a merchant that already has one.""" + response = client.post( + f"{BASE}/merchants/{admin_merchant.id}/program", + json={ + "loyalty_type": "stamps", + "stamps_target": 8, + }, + headers=super_admin_headers, + ) + # Should fail — merchant already has admin_program + assert response.status_code in [409, 422] diff --git a/app/modules/loyalty/tests/integration/test_admin_pages.py b/app/modules/loyalty/tests/integration/test_admin_pages.py new file mode 100644 index 00000000..eb2019ee --- /dev/null +++ b/app/modules/loyalty/tests/integration/test_admin_pages.py @@ -0,0 +1,219 @@ +# app/modules/loyalty/tests/integration/test_admin_pages.py +""" +Integration tests for admin loyalty page routes (HTML rendering). + +Tests the admin page routes at: + /loyalty/programs + /loyalty/analytics + /loyalty/merchants/{merchant_id} + /loyalty/merchants/{merchant_id}/program + /loyalty/merchants/{merchant_id}/settings + +Authentication: Uses super_admin_headers fixture (real JWT login). +""" + +import uuid + +import pytest + +from app.modules.tenancy.models import Merchant, User + +BASE = "/admin/loyalty" + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def admin_merchant(db): + """Create a merchant for admin page tests.""" + from middleware.auth import AuthManager + + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + + owner = User( + email=f"pagemerchowner_{uid}@test.com", + username=f"pagemerchowner_{uid}", + hashed_password=auth.hash_password("testpass123"), + role="merchant_owner", + is_active=True, + is_email_verified=True, + ) + db.add(owner) + db.commit() + db.refresh(owner) + + merchant = Merchant( + name=f"Page Test Merchant {uid}", + owner_user_id=owner.id, + contact_email=owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + + return merchant + + +# ============================================================================ +# Programs Dashboard Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminProgramsPage: + """Tests for GET /loyalty/programs.""" + + def test_programs_page_renders(self, client, super_admin_headers): + """Programs dashboard returns HTML.""" + response = client.get( + f"{BASE}/programs", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_programs_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/programs") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Analytics Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminAnalyticsPage: + """Tests for GET /loyalty/analytics.""" + + def test_analytics_page_renders(self, client, super_admin_headers): + """Analytics page returns HTML.""" + response = client.get( + f"{BASE}/analytics", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_analytics_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/analytics") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Merchant Detail Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminMerchantDetailPage: + """Tests for GET /loyalty/merchants/{merchant_id}.""" + + def test_merchant_detail_page_renders( + self, client, super_admin_headers, admin_merchant + ): + """Merchant detail page returns HTML with valid merchant.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_merchant_detail_page_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/merchants/{admin_merchant.id}") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Program Edit Page (NEW — the uncommitted route) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminProgramEditPage: + """Tests for GET /loyalty/merchants/{merchant_id}/program.""" + + def test_program_edit_page_renders( + self, client, super_admin_headers, admin_merchant + ): + """Program edit page returns HTML.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/program", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_program_edit_page_passes_merchant_id( + self, client, super_admin_headers, admin_merchant + ): + """Page response contains the merchant_id for the Alpine component.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/program", + headers=super_admin_headers, + ) + assert response.status_code == 200 + # Template should include merchant_id so JS can use it + assert str(admin_merchant.id) in response.text + + def test_program_edit_page_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/program" + ) + assert response.status_code in [401, 403] + + def test_program_edit_page_without_existing_program( + self, client, super_admin_headers, admin_merchant + ): + """Page renders even when merchant has no program yet (create mode).""" + # admin_merchant has no program — page should still render + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/program", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +# ============================================================================ +# Merchant Settings Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminMerchantSettingsPage: + """Tests for GET /loyalty/merchants/{merchant_id}/settings.""" + + def test_settings_page_renders( + self, client, super_admin_headers, admin_merchant + ): + """Merchant settings page returns HTML.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/settings", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_settings_page_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/settings" + ) + assert response.status_code in [401, 403]