Files

539 lines
22 KiB
Python
Raw Permalink Normal View History

2026-06-24 12:44:34 -07:00
"""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"])