"""Tests for loyalty program tools and create_order integration.""" import json from typing import Any import pytest from shopify import state as shopify_state from shopify.models import ( AwardPointsArgs, ConfigureLoyaltyProgramArgs, CreateDiscountCodeArgs, CreateOrderArgs, GetLoyaltyBalanceArgs, GetLoyaltyProgramArgs, GetLoyaltyTierArgs, ListLoyaltyTiersArgs, LoyaltyTier, RedeemPointsArgs, UpdateDiscountCodeArgs, ) from shopify.tools.discounts import handle_create_discount_code, handle_update_discount_code from shopify.tools.loyalty import ( compute_tier, handle_award_points, handle_configure_loyalty_program, handle_get_loyalty_balance, handle_get_loyalty_program, handle_get_loyalty_tier, handle_list_loyalty_tiers, handle_redeem_points, ) from shopify.tools.orders import handle_create_order @pytest.fixture def shopify_data(tmp_path): data_file = tmp_path / "shopify_data.json" data_file.write_text( json.dumps( { "products": { "gid://shopify/Product/1": { "id": "gid://shopify/Product/1", "title": "Widget", "variants": [ { "id": "gid://shopify/ProductVariant/1", "title": "Default", "price": {"amount": "100.00", "currencyCode": "USD"}, "availableForSale": True, } ], } }, "carts": { "gid://shopify/Cart/c1001": { "id": "gid://shopify/Cart/c1001", "lines": [ { "id": "gid://shopify/CartLine/1001", "quantity": 1, "merchandise": { "id": "gid://shopify/ProductVariant/1", "title": "Default", "price": {"amount": "100.00", "currencyCode": "USD"}, "product": {"id": "gid://shopify/Product/1", "title": "Widget"}, }, "cost": { "amountPerQuantity": {"amount": "100.00", "currencyCode": "USD"}, "subtotalAmount": {"amount": "100.00", "currencyCode": "USD"}, "totalAmount": {"amount": "100.00", "currencyCode": "USD"}, }, } ], } }, "orders": {}, "customers": { "gid://shopify/Customer/5001": { "id": "gid://shopify/Customer/5001", "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "phone": None, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", "defaultAddress": None, "addresses": [], "ordersCount": 0, "totalSpent": None, "tags": [], "note": None, "acceptsMarketing": False, "state": "ENABLED", } }, "collections": {}, "reviews": {}, "returns": {}, "discount_codes": {}, "shipping_methods": { "standard": { "id": "standard", "title": "Standard Shipping", "price": {"amount": "5.00", "currencyCode": "USD"}, "estimatedDays": "5-7", "active": True, } }, "loyalty_program": { "enabled": True, "earn_rate": 1, "redemption_rate": 100, "max_redemption_percent": 50, "tiers": [ {"name": "Bronze", "min_lifetime_points": 0, "discount_percent": 5}, {"name": "Silver", "min_lifetime_points": 1000, "discount_percent": 10}, {"name": "Gold", "min_lifetime_points": 5000, "discount_percent": 15}, ], }, "policies": [], "counters": { "cart_id": 1001, "line_id": 1001, "order_id": 2001, "line_item_id": 3001, "customer_id": 5001, "collection_id": 6001, "review_id": 7001, "return_id": 8001, "product_id": 9001, "variant_id": 10001, "discount_id": 11001, "policy_id": 12001, }, } ) ) return data_file @pytest.fixture(autouse=True) def _patch_state(shopify_data, monkeypatch): monkeypatch.setattr(shopify_state, "_STATE_FILE", shopify_data) shopify_state._current_state = None shopify_state._stores.clear() shopify_state._active_store_id = "default" shopify_state.load_state() # ============================================ # compute_tier UNIT TESTS # ============================================ class TestComputeTier: def test_no_tiers_returns_none(self): assert compute_tier(1000, []) is None def test_below_lowest_threshold_returns_none(self): tiers = [LoyaltyTier(name="Silver", min_lifetime_points=1000, discount_percent=10)] assert compute_tier(500, tiers) is None def test_highest_matching_tier(self): tiers = [ LoyaltyTier(name="Bronze", min_lifetime_points=0, discount_percent=5), LoyaltyTier(name="Silver", min_lifetime_points=1000, discount_percent=10), LoyaltyTier(name="Gold", min_lifetime_points=5000, discount_percent=15), ] result = compute_tier(6000, tiers) assert result is not None assert result.name == "Gold" def test_exact_threshold_qualifies(self): tiers = [LoyaltyTier(name="Silver", min_lifetime_points=1000, discount_percent=10)] result = compute_tier(1000, tiers) assert result is not None assert result.name == "Silver" # ============================================ # PROGRAM CONFIG TESTS # ============================================ class TestConfigureProgram: def test_toggle_enabled(self): result = handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(enabled=False)) assert result["userErrors"] == [] assert result["program"]["enabled"] is False def test_update_earn_rate(self): result = handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(earn_rate=2)) assert result["program"]["earn_rate"] == 2 def test_replace_tiers(self): new_tiers = [LoyaltyTier(name="VIP", min_lifetime_points=100, discount_percent=25)] result = handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(tiers=new_tiers)) assert len(result["program"]["tiers"]) == 1 assert result["program"]["tiers"][0]["name"] == "VIP" def test_rejects_invalid_earn_rate(self): result = handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(earn_rate=-1)) assert result["program"] is None assert len(result["userErrors"]) == 1 def test_rejects_invalid_config_before_any_mutation(self): state = shopify_state.get_state() result = handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(enabled=False, earn_rate=-1)) assert result["program"] is None assert state.loyalty_program.enabled is True assert state.loyalty_program.earn_rate == 1 def test_rejects_invalid_redemption_rate(self): result = handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(redemption_rate=0)) assert result["program"] is None def test_rejects_invalid_percent(self): result = handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(max_redemption_percent=150)) assert result["program"] is None class TestGetProgram: def test_read_program(self): result = handle_get_loyalty_program(GetLoyaltyProgramArgs()) assert result["program"]["enabled"] is True assert result["program"]["earn_rate"] == 1 class TestListTiers: def test_sorted_ascending(self): result = handle_list_loyalty_tiers(ListLoyaltyTiersArgs()) assert result["totalCount"] == 3 names = [t["name"] for t in result["tiers"]] assert names == ["Bronze", "Silver", "Gold"] # ============================================ # BALANCE / TIER LOOKUP # ============================================ class TestGetBalance: def test_new_customer_zero_balance(self): result = handle_get_loyalty_balance(GetLoyaltyBalanceArgs(customer_id="gid://shopify/Customer/5001")) assert result["userErrors"] == [] assert result["balance"]["pointsBalance"] == 0 assert result["balance"]["tier"] is None def test_nonexistent_customer(self): result = handle_get_loyalty_balance(GetLoyaltyBalanceArgs(customer_id="nope")) assert result["balance"] is None assert len(result["userErrors"]) == 1 class TestGetTier: def test_customer_with_lifetime_points(self): handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=1200)) result = handle_get_loyalty_tier(GetLoyaltyTierArgs(customer_id="gid://shopify/Customer/5001")) assert result["tier"]["name"] == "Silver" def test_customer_no_tier(self): result = handle_get_loyalty_tier(GetLoyaltyTierArgs(customer_id="gid://shopify/Customer/5001")) # 0 lifetime points still qualifies for Bronze (min_lifetime_points=0) assert result["tier"]["name"] == "Bronze" # ============================================ # AWARD / REDEEM # ============================================ class TestAwardPoints: def test_award_grows_both_balances(self): result = handle_award_points( AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=500, reason="welcome bonus") ) assert result["userErrors"] == [] assert result["balance"]["pointsBalance"] == 500 assert result["balance"]["lifetimePoints"] == 500 def test_award_updates_tier(self): handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=1500)) customer = shopify_state.get_customer_by_id("gid://shopify/Customer/5001") assert customer is not None assert customer["tier"] == "Silver" def test_reject_negative_points(self): result = handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=-10)) assert result["balance"] is None class TestRedeemPoints: def test_redeem_reduces_balance(self): handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=1000)) result = handle_redeem_points(RedeemPointsArgs(customer_id="gid://shopify/Customer/5001", points=500)) assert result["userErrors"] == [] assert result["redemption"]["pointsBalance"] == 500 assert result["redemption"]["dollarValue"]["amount"] == "5.00" def test_redeem_preserves_lifetime_points(self): handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=2000)) handle_redeem_points(RedeemPointsArgs(customer_id="gid://shopify/Customer/5001", points=500)) customer = shopify_state.get_customer_by_id("gid://shopify/Customer/5001") assert customer is not None assert customer["lifetimePoints"] == 2000 assert customer["pointsBalance"] == 1500 def test_insufficient_balance(self): result = handle_redeem_points(RedeemPointsArgs(customer_id="gid://shopify/Customer/5001", points=100)) assert result["redemption"] is None # ============================================ # CREATE_ORDER INTEGRATION # ============================================ def _default_order_args(**overrides): base: dict[str, Any] = { "cart_id": "gid://shopify/Cart/c1001", "payment_method": { "type": "credit_card", "card_number": "4111111111111111", "cvv": "123", "expiry": "12/26", }, "shipping_address": {"address1": "1 Main", "city": "Portland", "countryCode": "US"}, "billing_address": {"address1": "1 Main", "city": "Portland", "countryCode": "US"}, "shipping_method_id": "standard", "email": "jane@example.com", } base.update(overrides) return CreateOrderArgs.model_validate(base) class TestOrderLoyaltyIntegration: def test_order_awards_points(self): result = handle_create_order(_default_order_args()) assert result["userErrors"] == [] order = result["order"] # Bronze tier = 5% off $100 subtotal = $5 tier discount, post-tier-discount = $95 → 95 points assert order["loyaltyPointsEarned"] == 95 def test_order_applies_tier_discount(self): result = handle_create_order(_default_order_args()) order = result["order"] assert order["tierDiscount"]["name"] == "Bronze" assert order["tierDiscountAmount"]["amount"] == "5.00" # total = 100 - 5 tier + 5 shipping = 100 assert order["totalPrice"]["amount"] == "100.00" def test_tier_discount_can_be_disabled(self): result = handle_create_order(_default_order_args(apply_tier_discount=False)) order = result["order"] assert order["tierDiscount"] is None assert order["tierDiscountAmount"]["amount"] == "0.00" def test_order_redeems_points(self): handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=1000)) result = handle_create_order(_default_order_args(redeem_points=1000)) order = result["order"] assert order["loyaltyPointsRedeemed"] == 1000 # Bronze tier still qualifies (1000 lifetime points = Silver actually) # Silver = 10% off, so subtotal after tier = 90, redemption_cap = 45 (50% of 90) # $10 redemption requested (1000/100), capped at $45 → $10 applied assert order["loyaltyRedemptionAmount"]["amount"] == "10.00" @pytest.mark.asyncio async def test_public_create_order_exposes_redeem_points(self): from shopify.server import create_order handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=1000)) result = await create_order( cart_id="gid://shopify/Cart/c1001", payment_method={ "type": "credit_card", "card_number": "4111111111111111", "cvv": "123", "expiry": "12/26", }, shipping_address={"address1": "1 Main", "city": "Portland", "countryCode": "US"}, billing_address={"address1": "1 Main", "city": "Portland", "countryCode": "US"}, shipping_method_id="standard", email="jane@example.com", redeem_points=1000, ) order = result["order"] assert result["userErrors"] == [] assert order["loyaltyPointsRedeemed"] == 1000 assert order["loyaltyRedemptionAmount"]["amount"] == "10.00" @pytest.mark.asyncio async def test_public_create_order_falls_back_to_current_customer_email(self): from shopify.server import create_order shopify_state.get_state().current_customer_email = "jane@example.com" result = await create_order( cart_id="gid://shopify/Cart/c1001", payment_method={ "type": "credit_card", "card_number": "4111111111111111", "cvv": "123", "expiry": "12/26", }, shipping_address={"address1": "1 Main", "city": "Portland", "countryCode": "US"}, billing_address={"address1": "1 Main", "city": "Portland", "countryCode": "US"}, shipping_method_id="standard", ) assert result["userErrors"] == [] assert result["order"]["email"] == "jane@example.com" assert shopify_state.get_state().customers["gid://shopify/Customer/5001"].ordersCount == 1 def test_redeem_respects_max_percent_cap(self): # Award 50000 points ($500 of value), subtotal only $100 handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=50000)) result = handle_create_order(_default_order_args(redeem_points=10000)) order = result["order"] # 50000 lifetime = Gold tier (15% off), subtotal after tier = $85 # Max redemption = 50% of 85 = $42.50 # Requested $100 (10000/100), capped at $42.50 assert order["loyaltyRedemptionAmount"]["amount"] == "42.50" # Only the points that became discount should be deducted: $42.50 * 100 = 4250. # Burning the full 10000 would cost the customer 5750 points of value for # nothing. assert order["loyaltyPointsRedeemed"] == 4250 def test_redeem_requires_enabled_program(self): handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=1000)) handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(enabled=False)) result = handle_create_order(_default_order_args(redeem_points=500)) assert result["order"] is None assert any(e["field"] == "redeem_points" for e in result["userErrors"]) def test_redeem_requires_customer_lookup(self): # Email with no matching customer result = handle_create_order(_default_order_args(redeem_points=500, email="unknown@example.com")) assert result["order"] is None def test_order_updates_customer_totals(self): handle_create_order(_default_order_args()) customer = shopify_state.get_customer_by_id("gid://shopify/Customer/5001") assert customer is not None assert customer["ordersCount"] == 1 assert float(customer["totalSpent"]["amount"]) == 100.0 assert customer["pointsBalance"] == 95 def test_unknown_email_no_loyalty_effects(self): result = handle_create_order(_default_order_args(email="ghost@example.com")) order = result["order"] assert order["loyaltyPointsEarned"] == 0 assert order["tierDiscount"] is None def test_disabled_program_no_loyalty_effects(self): handle_configure_loyalty_program(ConfigureLoyaltyProgramArgs(enabled=False)) result = handle_create_order(_default_order_args()) order = result["order"] assert order["loyaltyPointsEarned"] == 0 assert order["tierDiscount"] is None # ============================================ # TIER-GATED DISCOUNT CODES # ============================================ class TestTierGatedDiscountCodes: def test_create_code_with_minimum_tier(self): result = handle_create_discount_code( CreateDiscountCodeArgs( code="GOLD25", value="25", discount_type="PERCENTAGE", minimum_tier="Gold", ) ) assert result["userErrors"] == [] assert result["discountCode"]["minimumTier"] == "Gold" def test_update_clears_tier_restriction(self): handle_create_discount_code( CreateDiscountCodeArgs(code="TIERED", value="10", discount_type="PERCENTAGE", minimum_tier="Silver") ) result = handle_update_discount_code(UpdateDiscountCodeArgs(code="TIERED", minimum_tier="")) assert result["discountCode"]["minimumTier"] is None def test_order_with_qualified_customer_succeeds(self): handle_create_discount_code( CreateDiscountCodeArgs(code="SILVERONLY", value="15", discount_type="PERCENTAGE", minimum_tier="Silver") ) # Award enough points to reach Silver (1000 lifetime) handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=1200)) result = handle_create_order(_default_order_args(discount_code="SILVERONLY")) assert result["userErrors"] == [] order = result["order"] assert order["discount"]["code"] == "SILVERONLY" assert order["discount"]["minimumTier"] == "Silver" def test_order_with_underqualified_customer_rejected(self): handle_create_discount_code( CreateDiscountCodeArgs(code="GOLDONLY", value="20", discount_type="PERCENTAGE", minimum_tier="Gold") ) # Customer is only Bronze (0 lifetime points) result = handle_create_order(_default_order_args(discount_code="GOLDONLY")) assert result["order"] is None assert any("Gold" in e["message"] for e in result["userErrors"]) def test_higher_tier_qualifies_for_lower_code(self): handle_create_discount_code( CreateDiscountCodeArgs(code="BRONZEANDUP", value="5", discount_type="PERCENTAGE", minimum_tier="Bronze") ) # Customer reaches Gold (5000+ lifetime) handle_award_points(AwardPointsArgs(customer_id="gid://shopify/Customer/5001", points=6000)) result = handle_create_order(_default_order_args(discount_code="BRONZEANDUP")) assert result["userErrors"] == [] def test_anonymous_customer_rejected_by_tier_code(self): handle_create_discount_code( CreateDiscountCodeArgs(code="BRONZEUP", value="5", discount_type="PERCENTAGE", minimum_tier="Bronze") ) # Use email that doesn't match any customer result = handle_create_order(_default_order_args(discount_code="BRONZEUP", email="ghost@example.com")) assert result["order"] is None assert any("Bronze" in e["message"] for e in result["userErrors"]) def test_unknown_tier_rejected(self): result = handle_create_discount_code( CreateDiscountCodeArgs(code="PLATONLY", value="30", discount_type="PERCENTAGE", minimum_tier="Platinum") ) assert result["discountCode"] is None assert any("Platinum" in e["message"] for e in result["userErrors"])