From efca9734d29f0adf227f86f24224c66deb6c7a23 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 11 Mar 2026 11:32:06 +0100 Subject: [PATCH] test(loyalty): add integration and unit tests for analytics, pages, and stats - Add merchant stats API tests (GET /merchants/loyalty/stats) with 7 test cases - Add merchant page route tests (program, program-edit, analytics) with 6 test cases - Add store page route tests (terminal, cards, card-detail, program, program-edit, analytics, enroll) with 16 test cases - Add unit tests for get_merchant_stats() enhanced fields (new_this_month, estimated_liability_cents, location breakdown) with 6 test cases - Add unit tests for get_platform_stats() enhanced fields (total_points_issued/redeemed, total_points_balance, new_this_month, estimated_liability_cents) with 4 test cases - Total: 38 new tests (174 -> 212 passing) Co-Authored-By: Claude Opus 4.6 --- .../tests/integration/test_merchant_api.py | 107 ++++++ .../tests/integration/test_merchant_pages.py | 160 +++++++++ .../tests/integration/test_store_pages.py | 268 +++++++++++++++ .../tests/unit/test_program_service.py | 320 ++++++++++++++++++ 4 files changed, 855 insertions(+) create mode 100644 app/modules/loyalty/tests/integration/test_merchant_pages.py create mode 100644 app/modules/loyalty/tests/integration/test_store_pages.py diff --git a/app/modules/loyalty/tests/integration/test_merchant_api.py b/app/modules/loyalty/tests/integration/test_merchant_api.py index 7a0aa403..63e5fd13 100644 --- a/app/modules/loyalty/tests/integration/test_merchant_api.py +++ b/app/modules/loyalty/tests/integration/test_merchant_api.py @@ -265,3 +265,110 @@ class TestMerchantDeleteProgram: f"{BASE}/program", headers=loyalty_merchant_headers ) assert response.status_code == 404 + + +# ============================================================================ +# GET /stats +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantGetStats: + """Tests for GET /api/v1/merchants/loyalty/stats.""" + + def test_get_stats_success( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Returns merchant-wide loyalty statistics.""" + response = client.get( + f"{BASE}/stats", headers=loyalty_merchant_headers + ) + assert response.status_code == 200 + data = response.json() + assert data["merchant_id"] == loyalty_store_setup["merchant"].id + assert data["program_id"] == loyalty_store_setup["program"].id + + def test_get_stats_includes_card_counts( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Stats include card counts from existing setup.""" + response = client.get( + f"{BASE}/stats", headers=loyalty_merchant_headers + ) + data = response.json() + # loyalty_store_setup creates 1 card + assert data["total_cards"] == 1 + assert data["active_cards"] == 1 + + def test_get_stats_includes_all_fields( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Response includes all expected stat fields.""" + response = client.get( + f"{BASE}/stats", headers=loyalty_merchant_headers + ) + data = response.json() + expected_fields = [ + "merchant_id", + "program_id", + "total_cards", + "active_cards", + "new_this_month", + "total_points_issued", + "total_points_redeemed", + "points_issued_30d", + "points_redeemed_30d", + "transactions_30d", + "estimated_liability_cents", + "program", + "locations", + ] + for field in expected_fields: + assert field in data, f"Missing field: {field}" + + def test_get_stats_includes_program_info( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Stats include program configuration details.""" + response = client.get( + f"{BASE}/stats", headers=loyalty_merchant_headers + ) + data = response.json() + assert data["program"] is not None + assert data["program"]["loyalty_type"] == "points" + assert data["program"]["points_per_euro"] == 10 + + def test_get_stats_includes_locations( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Stats include per-location breakdown.""" + response = client.get( + f"{BASE}/stats", headers=loyalty_merchant_headers + ) + data = response.json() + assert isinstance(data["locations"], list) + # loyalty_store_setup creates 1 store + assert len(data["locations"]) >= 1 + loc = data["locations"][0] + assert "store_id" in loc + assert "store_name" in loc + assert "enrolled_count" in loc + assert "points_earned" in loc + assert "points_redeemed" in loc + assert "transactions_30d" in loc + + def test_get_stats_no_program( + self, client, loyalty_merchant_headers_no_program + ): + """Returns empty stats when merchant has no program.""" + response = client.get( + f"{BASE}/stats", headers=loyalty_merchant_headers_no_program + ) + assert response.status_code == 200 + data = response.json() + assert data["program_id"] is None + assert data["total_cards"] == 0 + assert data["program"] is None + assert data["locations"] == [] diff --git a/app/modules/loyalty/tests/integration/test_merchant_pages.py b/app/modules/loyalty/tests/integration/test_merchant_pages.py new file mode 100644 index 00000000..cc4b6592 --- /dev/null +++ b/app/modules/loyalty/tests/integration/test_merchant_pages.py @@ -0,0 +1,160 @@ +# app/modules/loyalty/tests/integration/test_merchant_pages.py +""" +Integration tests for merchant loyalty page routes (HTML rendering). + +Tests the merchant page routes at: + /merchants/loyalty/program + /merchants/loyalty/program/edit + /merchants/loyalty/analytics + +Authentication: Uses dependency overrides for cookie-based merchant auth. +""" + +import uuid + +import pytest + +from app.api.deps import ( + get_current_merchant_from_cookie_or_header, + get_merchant_for_current_user_page, +) +from app.modules.tenancy.models import Merchant, User +from app.modules.tenancy.schemas.auth import UserContext +from main import app + +BASE = "/merchants/loyalty" + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def merchant_page_setup(db): + """Create a merchant owner for page tests.""" + from middleware.auth import AuthManager + + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + + owner = User( + email=f"merchpageowner_{uid}@test.com", + username=f"merchpageowner_{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 {"owner": owner, "merchant": merchant} + + +@pytest.fixture +def merchant_page_headers(merchant_page_setup): + """Override auth dependencies for merchant page routes.""" + owner = merchant_page_setup["owner"] + merchant = merchant_page_setup["merchant"] + + user_context = UserContext( + id=owner.id, + email=owner.email, + username=owner.username, + role="merchant_owner", + is_active=True, + ) + + app.dependency_overrides[get_current_merchant_from_cookie_or_header] = lambda: user_context + app.dependency_overrides[get_merchant_for_current_user_page] = lambda: merchant + yield {"Cookie": "merchant_token=fake-token"} + app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None) + app.dependency_overrides.pop(get_merchant_for_current_user_page, None) + + +# ============================================================================ +# Program View Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantProgramPage: + """Tests for GET /merchants/loyalty/program.""" + + def test_program_page_renders(self, client, merchant_page_headers): + """Program page returns HTML.""" + response = client.get( + f"{BASE}/program", + headers=merchant_page_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_program_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/program") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Program Edit Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantProgramEditPage: + """Tests for GET /merchants/loyalty/program/edit.""" + + def test_program_edit_page_renders(self, client, merchant_page_headers): + """Program edit page returns HTML.""" + response = client.get( + f"{BASE}/program/edit", + headers=merchant_page_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_program_edit_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/program/edit") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Analytics Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantAnalyticsPage: + """Tests for GET /merchants/loyalty/analytics.""" + + def test_analytics_page_renders(self, client, merchant_page_headers): + """Analytics page returns HTML.""" + response = client.get( + f"{BASE}/analytics", + headers=merchant_page_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] diff --git a/app/modules/loyalty/tests/integration/test_store_pages.py b/app/modules/loyalty/tests/integration/test_store_pages.py new file mode 100644 index 00000000..b2afc9ce --- /dev/null +++ b/app/modules/loyalty/tests/integration/test_store_pages.py @@ -0,0 +1,268 @@ +# app/modules/loyalty/tests/integration/test_store_pages.py +""" +Integration tests for store loyalty page routes (HTML rendering). + +Tests the store page routes at: + /store/{store_code}/loyalty (redirect to terminal) + /store/{store_code}/loyalty/terminal + /store/{store_code}/loyalty/cards + /store/{store_code}/loyalty/cards/{card_id} + /store/{store_code}/loyalty/program + /store/{store_code}/loyalty/program/edit + /store/{store_code}/loyalty/analytics + /store/{store_code}/loyalty/enroll + +Authentication: Uses real JWT login via loyalty_store_headers fixture. +""" + +import pytest + +# ============================================================================ +# Helper +# ============================================================================ + + +def _base(store_code: str) -> str: + return f"/store/{store_code}/loyalty" + + +# ============================================================================ +# Loyalty Root (Redirect) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreLoyaltyRoot: + """Tests for GET /store/{store_code}/loyalty — redirects to terminal.""" + + def test_root_redirects_to_terminal( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Root loyalty URL redirects to terminal.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + _base(store_code), + headers=loyalty_store_headers, + follow_redirects=False, + ) + assert response.status_code == 302 + assert "/loyalty/terminal" in response.headers["location"] + + def test_root_requires_auth(self, client, loyalty_store_setup): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + _base(store_code), + follow_redirects=False, + ) + assert response.status_code in [401, 403] + + +# ============================================================================ +# Terminal Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreTerminalPage: + """Tests for GET /store/{store_code}/loyalty/terminal.""" + + def test_terminal_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Terminal page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + f"{_base(store_code)}/terminal", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_terminal_page_requires_auth(self, client, loyalty_store_setup): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get(f"{_base(store_code)}/terminal") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Cards (Members List) Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreCardsPage: + """Tests for GET /store/{store_code}/loyalty/cards.""" + + def test_cards_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Cards page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + f"{_base(store_code)}/cards", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_cards_page_requires_auth(self, client, loyalty_store_setup): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get(f"{_base(store_code)}/cards") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Card Detail Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreCardDetailPage: + """Tests for GET /store/{store_code}/loyalty/cards/{card_id}.""" + + def test_card_detail_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Card detail page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + card_id = loyalty_store_setup["card"].id + response = client.get( + f"{_base(store_code)}/cards/{card_id}", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_card_detail_page_requires_auth( + self, client, loyalty_store_setup + ): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + card_id = loyalty_store_setup["card"].id + response = client.get(f"{_base(store_code)}/cards/{card_id}") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Program View Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreProgramPage: + """Tests for GET /store/{store_code}/loyalty/program.""" + + def test_program_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Program page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + f"{_base(store_code)}/program", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_program_page_requires_auth(self, client, loyalty_store_setup): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get(f"{_base(store_code)}/program") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Program Edit Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreProgramEditPage: + """Tests for GET /store/{store_code}/loyalty/program/edit.""" + + def test_program_edit_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Program edit page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + f"{_base(store_code)}/program/edit", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_program_edit_page_requires_auth( + self, client, loyalty_store_setup + ): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get(f"{_base(store_code)}/program/edit") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Analytics Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreAnalyticsPage: + """Tests for GET /store/{store_code}/loyalty/analytics.""" + + def test_analytics_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Analytics page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + f"{_base(store_code)}/analytics", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_analytics_page_requires_auth(self, client, loyalty_store_setup): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get(f"{_base(store_code)}/analytics") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Enrollment Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStoreEnrollPage: + """Tests for GET /store/{store_code}/loyalty/enroll.""" + + def test_enroll_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """Enrollment page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + f"{_base(store_code)}/enroll", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_enroll_page_requires_auth(self, client, loyalty_store_setup): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get(f"{_base(store_code)}/enroll") + assert response.status_code in [401, 403] diff --git a/app/modules/loyalty/tests/unit/test_program_service.py b/app/modules/loyalty/tests/unit/test_program_service.py index 9de2820d..268dc8ef 100644 --- a/app/modules/loyalty/tests/unit/test_program_service.py +++ b/app/modules/loyalty/tests/unit/test_program_service.py @@ -447,3 +447,323 @@ class TestGetProgramStats: assert stats["active_cards"] == 3 assert stats["total_points_balance"] == 600 # 100+200+300 assert stats["avg_points_per_member"] == 200.0 # 600/3 + + +# ============================================================================ +# Merchant Stats +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestGetMerchantStats: + """Tests for get_merchant_stats.""" + + def setup_method(self): + self.service = ProgramService() + + def test_merchant_stats_returns_all_fields(self, db, ps_program, ps_merchant): + """Merchant stats include all required fields.""" + stats = self.service.get_merchant_stats(db, ps_merchant.id) + + expected_fields = [ + "merchant_id", + "program_id", + "total_cards", + "active_cards", + "new_this_month", + "total_points_issued", + "total_points_redeemed", + "points_issued_30d", + "points_redeemed_30d", + "transactions_30d", + "estimated_liability_cents", + "program", + "locations", + ] + for field in expected_fields: + assert field in stats, f"Missing field: {field}" + + def test_merchant_stats_empty_program(self, db, ps_program, ps_merchant): + """Merchant stats for program with no cards.""" + stats = self.service.get_merchant_stats(db, ps_merchant.id) + + assert stats["merchant_id"] == ps_merchant.id + assert stats["program_id"] == ps_program.id + assert stats["total_cards"] == 0 + assert stats["active_cards"] == 0 + assert stats["new_this_month"] == 0 + assert stats["estimated_liability_cents"] == 0 + + def test_merchant_stats_no_program(self, db): + """Returns empty stats when merchant has no program.""" + from middleware.auth import AuthManager + + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + + owner = User( + email=f"noprg_{uid}@test.com", + username=f"noprg_{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"No Program 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) + + stats = self.service.get_merchant_stats(db, merchant.id) + assert stats["program_id"] is None + assert stats["total_cards"] == 0 + assert stats["program"] is None + assert stats["locations"] == [] + + def test_merchant_stats_includes_program_info(self, db, ps_program, ps_merchant): + """Merchant stats include program configuration.""" + stats = self.service.get_merchant_stats(db, ps_merchant.id) + + assert stats["program"] is not None + assert stats["program"]["id"] == ps_program.id + assert stats["program"]["loyalty_type"] == "points" + assert stats["program"]["points_per_euro"] == 10 + + def test_merchant_stats_with_cards(self, db, ps_program, ps_merchant): + """Merchant stats reflect card data including new fields.""" + from datetime import UTC, datetime + + from app.modules.customers.models.customer import Customer + from app.modules.loyalty.models import LoyaltyCard + from app.modules.tenancy.models import Store + + uid_store = uuid.uuid4().hex[:8] + store = Store( + merchant_id=ps_merchant.id, + store_code=f"MSTAT_{uid_store.upper()}", + subdomain=f"mstat{uid_store}", + name=f"Merchant Stats Store {uid_store}", + is_active=True, + is_verified=True, + ) + db.add(store) + db.flush() + + for i in range(2): + uid = uuid.uuid4().hex[:8] + customer = Customer( + email=f"mstat_{uid}@test.com", + first_name="MS", + last_name=f"Customer{i}", + hashed_password="!unused!", # noqa: SEC001 + customer_number=f"MS-{uid.upper()}", + store_id=store.id, + is_active=True, + ) + db.add(customer) + db.flush() + + card = LoyaltyCard( + merchant_id=ps_merchant.id, + program_id=ps_program.id, + customer_id=customer.id, + enrolled_at_store_id=store.id, + card_number=f"MSTAT-{i}-{uuid.uuid4().hex[:6]}", + points_balance=200, + total_points_earned=200, + is_active=True, + last_activity_at=datetime.now(UTC), + ) + db.add(card) + db.commit() + + stats = self.service.get_merchant_stats(db, ps_merchant.id) + + assert stats["total_cards"] == 2 + assert stats["active_cards"] == 2 + # new_this_month should include cards created today + assert stats["new_this_month"] == 2 + # estimated_liability_cents: 2 cards * 200 points each = 400 points + # 400 // 100 * 100 = 400 cents + assert stats["estimated_liability_cents"] == 400 + + def test_merchant_stats_location_breakdown(self, db, ps_program, ps_merchant): + """Location breakdown includes per-store data.""" + from datetime import UTC, datetime + + from app.modules.customers.models.customer import Customer + from app.modules.loyalty.models import LoyaltyCard + from app.modules.tenancy.models import Store + + uid = uuid.uuid4().hex[:8] + store = Store( + merchant_id=ps_merchant.id, + store_code=f"MLOC_{uid.upper()}", + subdomain=f"mloc{uid}", + name=f"Location Store {uid}", + is_active=True, + is_verified=True, + ) + db.add(store) + db.flush() + + customer = Customer( + email=f"mloc_{uid}@test.com", + first_name="Loc", + last_name="Customer", + hashed_password="!unused!", # noqa: SEC001 + customer_number=f"LC-{uid.upper()}", + store_id=store.id, + is_active=True, + ) + db.add(customer) + db.flush() + + card = LoyaltyCard( + merchant_id=ps_merchant.id, + program_id=ps_program.id, + customer_id=customer.id, + enrolled_at_store_id=store.id, + card_number=f"MLOC-{uuid.uuid4().hex[:6]}", + points_balance=50, + total_points_earned=50, + is_active=True, + last_activity_at=datetime.now(UTC), + ) + db.add(card) + db.commit() + + stats = self.service.get_merchant_stats(db, ps_merchant.id) + + assert len(stats["locations"]) >= 1 + # Find our store in locations + our_loc = next( + (loc for loc in stats["locations"] if loc["store_id"] == store.id), + None, + ) + assert our_loc is not None + assert our_loc["store_name"] == store.name + assert our_loc["enrolled_count"] == 1 + + +# ============================================================================ +# Platform Stats +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestGetPlatformStats: + """Tests for get_platform_stats.""" + + def setup_method(self): + self.service = ProgramService() + + def test_platform_stats_returns_all_fields(self, db): + """Platform stats include all required fields.""" + stats = self.service.get_platform_stats(db) + + expected_fields = [ + "total_programs", + "active_programs", + "merchants_with_programs", + "total_cards", + "active_cards", + "transactions_30d", + "points_issued_30d", + "points_redeemed_30d", + "total_points_issued", + "total_points_redeemed", + "total_points_balance", + "new_this_month", + "estimated_liability_cents", + ] + for field in expected_fields: + assert field in stats, f"Missing field: {field}" + + def test_platform_stats_counts_programs(self, db, ps_program): + """Platform stats count programs correctly.""" + stats = self.service.get_platform_stats(db) + + assert stats["total_programs"] >= 1 + assert stats["active_programs"] >= 1 + assert stats["merchants_with_programs"] >= 1 + + def test_platform_stats_with_cards(self, db, ps_program, ps_merchant): + """Platform stats reflect card data across all programs.""" + from datetime import UTC, datetime + + from app.modules.customers.models.customer import Customer + from app.modules.loyalty.models import LoyaltyCard + from app.modules.tenancy.models import Store + + uid = uuid.uuid4().hex[:8] + store = Store( + merchant_id=ps_merchant.id, + store_code=f"PLAT_{uid.upper()}", + subdomain=f"plat{uid}", + name=f"Platform Stats Store {uid}", + is_active=True, + is_verified=True, + ) + db.add(store) + db.flush() + + customer = Customer( + email=f"plat_{uid}@test.com", + first_name="Plat", + last_name="Customer", + hashed_password="!unused!", # noqa: SEC001 + customer_number=f"PC-{uid.upper()}", + store_id=store.id, + is_active=True, + ) + db.add(customer) + db.flush() + + card = LoyaltyCard( + merchant_id=ps_merchant.id, + program_id=ps_program.id, + customer_id=customer.id, + card_number=f"PLAT-{uuid.uuid4().hex[:6]}", + points_balance=300, + total_points_earned=300, + is_active=True, + last_activity_at=datetime.now(UTC), + ) + db.add(card) + db.commit() + + stats = self.service.get_platform_stats(db) + + assert stats["total_cards"] >= 1 + assert stats["active_cards"] >= 1 + assert stats["total_points_balance"] >= 300 + assert stats["new_this_month"] >= 1 + assert stats["estimated_liability_cents"] >= 0 + + def test_platform_stats_zero_when_empty(self, db): + """All numeric fields are zero or positive.""" + stats = self.service.get_platform_stats(db) + + assert stats["total_cards"] >= 0 + assert stats["active_cards"] >= 0 + assert stats["transactions_30d"] >= 0 + assert stats["points_issued_30d"] >= 0 + assert stats["points_redeemed_30d"] >= 0 + assert stats["total_points_issued"] >= 0 + assert stats["total_points_redeemed"] >= 0 + assert stats["total_points_balance"] >= 0 + assert stats["new_this_month"] >= 0 + assert stats["estimated_liability_cents"] >= 0