# app/modules/loyalty/tests/integration/test_merchant_api.py """ Integration tests for merchant loyalty API endpoints. Tests the merchant program CRUD endpoints at: /api/v1/merchants/loyalty/* Authentication: Uses dependency overrides (merch_auth_headers pattern). """ import pytest BASE = "/api/v1/merchants/loyalty" # ============================================================================ # GET /program # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantGetProgram: """Tests for GET /api/v1/merchants/loyalty/program.""" def test_get_program_success( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Returns the merchant's loyalty program.""" response = client.get( f"{BASE}/program", headers=loyalty_merchant_headers ) assert response.status_code == 200 data = response.json() assert data["id"] == loyalty_store_setup["program"].id assert data["merchant_id"] == loyalty_store_setup["merchant"].id assert data["points_per_euro"] == 10 assert data["is_active"] is True def test_get_program_includes_display_fields( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Response includes computed display fields.""" response = client.get( f"{BASE}/program", headers=loyalty_merchant_headers ) assert response.status_code == 200 data = response.json() assert "display_name" in data assert "is_stamps_enabled" in data assert "is_points_enabled" in data def test_get_program_not_found( self, client, loyalty_merchant_headers_no_program ): """Returns 404 when merchant has no program.""" response = client.get( f"{BASE}/program", headers=loyalty_merchant_headers_no_program ) assert response.status_code == 404 # ============================================================================ # POST /program # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantCreateProgram: """Tests for POST /api/v1/merchants/loyalty/program.""" def test_create_program_success( self, client, loyalty_merchant_headers_no_program, loyalty_merchant_setup ): """Create a new loyalty program for the merchant.""" response = client.post( f"{BASE}/program", json={ "loyalty_type": "points", "points_per_euro": 5, "card_name": "My Rewards", "card_color": "#FF5733", }, headers=loyalty_merchant_headers_no_program, ) assert response.status_code == 201 data = response.json() assert data["loyalty_type"] == "points" assert data["points_per_euro"] == 5 assert data["card_name"] == "My Rewards" assert data["card_color"] == "#FF5733" assert data["merchant_id"] == loyalty_merchant_setup["merchant"].id def test_create_program_with_rewards( self, client, loyalty_merchant_headers_no_program ): """Create program with point rewards configured.""" response = client.post( f"{BASE}/program", json={ "loyalty_type": "points", "points_per_euro": 10, "points_rewards": [ { "id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True, }, { "id": "r2", "name": "10 EUR off", "points_required": 200, "is_active": True, }, ], }, headers=loyalty_merchant_headers_no_program, ) assert response.status_code == 201 data = response.json() assert len(data["points_rewards"]) == 2 assert data["points_rewards"][0]["name"] == "5 EUR off" def test_create_program_defaults( self, client, loyalty_merchant_headers_no_program ): """Creating with minimal data uses sensible defaults.""" response = client.post( f"{BASE}/program", json={"loyalty_type": "points"}, headers=loyalty_merchant_headers_no_program, ) assert response.status_code == 201 data = response.json() assert data["points_per_euro"] == 1 assert data["is_active"] is True def test_create_program_duplicate_fails( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Cannot create a second program for the same merchant.""" response = client.post( f"{BASE}/program", json={"loyalty_type": "points"}, headers=loyalty_merchant_headers, ) # Should fail - merchant already has a program assert response.status_code in (400, 409, 422) # ============================================================================ # PATCH /program # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantUpdateProgram: """Tests for PATCH /api/v1/merchants/loyalty/program.""" def test_update_program_success( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Update program fields.""" response = client.patch( f"{BASE}/program", json={ "points_per_euro": 20, "card_name": "Updated Rewards", }, headers=loyalty_merchant_headers, ) assert response.status_code == 200 data = response.json() assert data["points_per_euro"] == 20 assert data["card_name"] == "Updated Rewards" def test_update_program_partial( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Partial update only modifies specified fields.""" response = client.patch( f"{BASE}/program", json={"card_name": "New Name Only"}, headers=loyalty_merchant_headers, ) assert response.status_code == 200 data = response.json() assert data["card_name"] == "New Name Only" # Other fields should be unchanged assert data["points_per_euro"] == 10 def test_update_program_deactivate( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Can deactivate program via update.""" response = client.patch( f"{BASE}/program", json={"is_active": False}, headers=loyalty_merchant_headers, ) assert response.status_code == 200 assert response.json()["is_active"] is False def test_update_program_not_found( self, client, loyalty_merchant_headers_no_program ): """Update returns 404 when no program exists.""" response = client.patch( f"{BASE}/program", json={"card_name": "No Program"}, headers=loyalty_merchant_headers_no_program, ) assert response.status_code == 404 # ============================================================================ # DELETE /program # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantDeleteProgram: """Tests for DELETE /api/v1/merchants/loyalty/program.""" def test_delete_program_success( self, client, loyalty_merchant_headers, loyalty_store_setup, db ): """Delete the merchant's loyalty program.""" program_id = loyalty_store_setup["program"].id response = client.delete( f"{BASE}/program", headers=loyalty_merchant_headers ) assert response.status_code == 204 # Verify program is deleted from app.modules.loyalty.models import LoyaltyProgram deleted = db.get(LoyaltyProgram, program_id) assert deleted is None def test_delete_program_not_found( self, client, loyalty_merchant_headers_no_program ): """Delete returns 404 when no program exists.""" response = client.delete( f"{BASE}/program", headers=loyalty_merchant_headers_no_program ) assert response.status_code == 404 def test_delete_then_get_returns_404( self, client, loyalty_merchant_headers, loyalty_store_setup ): """After deletion, GET returns 404.""" client.delete(f"{BASE}/program", headers=loyalty_merchant_headers) response = client.get( 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"] == [] # ============================================================================ # GET /cards # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantListCards: """Tests for GET /api/v1/merchants/loyalty/cards.""" def test_list_cards_success( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Returns cards list for the merchant.""" response = client.get(f"{BASE}/cards", headers=loyalty_merchant_headers) assert response.status_code == 200 data = response.json() assert "cards" in data assert "total" in data assert isinstance(data["cards"], list) def test_list_cards_requires_auth(self, client): """Unauthenticated request is rejected.""" response = client.get(f"{BASE}/cards") assert response.status_code in [401, 403] # ============================================================================ # GET /transactions # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantListTransactions: """Tests for GET /api/v1/merchants/loyalty/transactions.""" def test_list_transactions_empty( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Returns empty list when no transactions exist.""" response = client.get(f"{BASE}/transactions", headers=loyalty_merchant_headers) assert response.status_code == 200 data = response.json() assert data["transactions"] == [] assert data["total"] == 0 def test_list_transactions_requires_auth(self, client): """Unauthenticated request is rejected.""" response = client.get(f"{BASE}/transactions") assert response.status_code in [401, 403] # ============================================================================ # GET /pins # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantListPins: """Tests for GET /api/v1/merchants/loyalty/pins.""" def test_list_pins_empty( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Returns empty list when no PINs exist.""" response = client.get(f"{BASE}/pins", headers=loyalty_merchant_headers) assert response.status_code == 200 data = response.json() assert data["pins"] == [] assert data["total"] == 0 def test_list_pins_requires_auth(self, client): """Unauthenticated request is rejected.""" response = client.get(f"{BASE}/pins") assert response.status_code in [401, 403] # ============================================================================ # GET /settings # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantGetSettings: """Tests for GET /api/v1/merchants/loyalty/settings.""" def test_get_settings_success( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Returns merchant loyalty settings.""" response = client.get(f"{BASE}/settings", headers=loyalty_merchant_headers) assert response.status_code == 200 data = response.json() assert "staff_pin_policy" in data def test_get_settings_requires_auth(self, client): """Unauthenticated request is rejected.""" response = client.get(f"{BASE}/settings") assert response.status_code in [401, 403] # ============================================================================ # GET /locations # ============================================================================ @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantGetLocations: """Tests for GET /api/v1/merchants/loyalty/locations.""" def test_get_locations_success( self, client, loyalty_merchant_headers, loyalty_store_setup ): """Returns merchant's store locations.""" response = client.get(f"{BASE}/locations", headers=loyalty_merchant_headers) assert response.status_code == 200 data = response.json() assert isinstance(data, list) assert len(data) >= 1 def test_get_locations_requires_auth(self, client): """Unauthenticated request is rejected.""" response = client.get(f"{BASE}/locations") assert response.status_code in [401, 403]