"""Tests for the customer self-only tool handlers. These verify that every operation is scoped to the current customer only — a shopper cannot read or mutate other customers' data even by passing the wrong IDs, and all self-tools error clearly when no current customer is set. """ import json from typing import Any import pytest from pydantic import ValidationError from shopify import state as shopify_state from shopify.models import CreateReturnArgs, CreateReturnLineItemInput, GetOrderArgs from shopify.tools.self import ( handle_create_my_return, handle_create_my_review, handle_get_my_customer, handle_get_my_loyalty_balance, handle_get_my_loyalty_tier, handle_get_my_order, handle_list_my_orders, handle_redeem_my_points, handle_update_my_customer, ) def _base_data(current_customer_email: str | None = "alice@example.com") -> dict[str, Any]: return { "products": { "p1": { "id": "p1", "title": "Widget", "handle": "widget", "variants": [ { "id": "v1", "title": "Default", "price": {"amount": "25.00", "currencyCode": "USD"}, "availableForSale": True, } ], } }, "carts": {}, "orders": { # Order belonging to alice "o1": { "id": "o1", "name": "#o1", "email": "alice@example.com", "createdAt": "2026-03-10T10:00:00Z", "updatedAt": "2026-03-10T10:00:00Z", "cancelledAt": None, "financialStatus": "PAID", "fulfillmentStatus": "UNFULFILLED", "lineItems": [ { "id": "li1", "title": "Widget", "variantId": "v1", "productId": "p1", "quantity": 1, "price": {"amount": "25.00", "currencyCode": "USD"}, "totalPrice": {"amount": "25.00", "currencyCode": "USD"}, } ], "subtotalPrice": {"amount": "25.00", "currencyCode": "USD"}, "totalPrice": {"amount": "25.00", "currencyCode": "USD"}, "shippingAddress": None, "billingAddress": None, }, # Order belonging to bob — alice should not be able to access. "o2": { "id": "o2", "name": "#o2", "email": "bob@example.com", "createdAt": "2026-03-11T10:00:00Z", "updatedAt": "2026-03-11T10:00:00Z", "cancelledAt": None, "financialStatus": "PAID", "fulfillmentStatus": "UNFULFILLED", "lineItems": [ { "id": "li2", "title": "Widget", "variantId": "v1", "productId": "p1", "quantity": 1, "price": {"amount": "25.00", "currencyCode": "USD"}, "totalPrice": {"amount": "25.00", "currencyCode": "USD"}, } ], "subtotalPrice": {"amount": "25.00", "currencyCode": "USD"}, "totalPrice": {"amount": "25.00", "currencyCode": "USD"}, "shippingAddress": None, "billingAddress": None, }, }, "customers": { "c1": { "id": "c1", "firstName": "Alice", "lastName": "Smith", "email": "alice@example.com", "phone": "+1111", "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", "defaultAddress": None, "addresses": [], "ordersCount": 1, "totalSpent": {"amount": "25.00", "currencyCode": "USD"}, "tags": ["vip"], "note": "internal note", "acceptsMarketing": True, "state": "ENABLED", "pointsBalance": 500, "lifetimePoints": 1500, "tier": "Silver", }, "c2": { "id": "c2", "firstName": "Bob", "lastName": "Jones", "email": "bob@example.com", "phone": "+2222", "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", "pointsBalance": 9999, "lifetimePoints": 9999, "tier": "Gold", }, }, "collections": {}, "reviews": {}, "returns": {}, "discount_codes": {}, "shipping_methods": {}, "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}, ], }, "current_customer_email": current_customer_email, "policies": [], "counters": {"cart_id": 1000, "line_id": 1000, "order_id": 2000, "return_id": 7000}, } @pytest.fixture def shopify_data(tmp_path) -> Any: data_file = tmp_path / "shopify_data.json" data_file.write_text(json.dumps(_base_data())) 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() class TestGetMyCustomer: def test_returns_current_customer(self): result = handle_get_my_customer() assert result["userErrors"] == [] assert result["customer"]["email"] == "alice@example.com" def test_errors_when_unset(self, tmp_path, monkeypatch): # Re-init with no current_customer_email data_file = tmp_path / "shopify_data.json" data_file.write_text(json.dumps(_base_data(current_customer_email=None))) monkeypatch.setattr(shopify_state, "_STATE_FILE", data_file) shopify_state._current_state = None shopify_state._stores.clear() shopify_state._active_store_id = "default" shopify_state.load_state() result = handle_get_my_customer() assert result["customer"] is None assert "identity not set" in result["userErrors"][0]["message"].lower() class TestUpdateMyCustomer: def test_updates_allowed_fields(self): result = handle_update_my_customer(first_name="Alicia", phone="+9999", accepts_marketing=False) assert result["userErrors"] == [] assert result["customer"]["firstName"] == "Alicia" assert result["customer"]["phone"] == "+9999" assert result["customer"]["acceptsMarketing"] is False def test_appends_address_and_sets_default(self): addr = {"address1": "1 Main St", "city": "Portland", "countryCode": "US"} result = handle_update_my_customer(address=addr) customer = result["customer"] default_address = customer["defaultAddress"].model_dump(mode="json", exclude_none=True) appended_address = customer["addresses"][-1].model_dump(mode="json", exclude_none=True) assert {key: default_address[key] for key in addr} == addr assert {key: appended_address[key] for key in addr} == addr def test_update_address_dedupes_existing_default_address(self): addr = {"address1": "1 Main St", "city": "Portland", "countryCode": "US"} handle_update_my_customer(address=addr) result = handle_update_my_customer(address=addr) default_address = result["customer"]["defaultAddress"].model_dump(mode="json", exclude_none=True) assert {key: default_address[key] for key in addr} == addr assert len(result["customer"]["addresses"]) == 1 def test_update_invalid_address_validates_before_any_mutation(self): customer = shopify_state.get_state().customers["c1"] original_updated_at = customer.updatedAt original_phone = customer.phone with pytest.raises(ValidationError): handle_update_my_customer( first_name="Alicia", last_name="Changed", phone="+9999", accepts_marketing=False, address={"countryCode": 123}, ) assert customer.firstName == "Alice" assert customer.lastName == "Smith" assert customer.phone == original_phone assert customer.acceptsMarketing is True assert customer.defaultAddress is None assert customer.addresses == [] assert customer.updatedAt == original_updated_at def test_cannot_touch_admin_fields(self): # Even if the function accepted them, the signature doesn't expose # tags/note/state — this test documents that. Sanity check that the # call doesn't accept unknown kwargs. with pytest.raises(TypeError): handle_update_my_customer(tags=["admin_set_tag"]) # ty: ignore[unknown-argument] class TestLoyaltySelf: def test_get_my_loyalty_balance(self): result = handle_get_my_loyalty_balance() assert result["balance"]["customerId"] == "c1" assert result["balance"]["pointsBalance"] == 500 assert result["balance"]["lifetimePoints"] == 1500 def test_get_my_loyalty_tier(self): result = handle_get_my_loyalty_tier() assert result["tier"]["name"] == "Silver" def test_redeem_my_points_succeeds(self): result = handle_redeem_my_points(200) assert result["userErrors"] == [] assert result["redemption"]["pointsRedeemed"] == 200 state = shopify_state.get_state() assert state.customers["c1"]["pointsBalance"] == 300 def test_redeem_insufficient_balance_errors(self): result = handle_redeem_my_points(999_999) assert result["redemption"] is None assert len(result["userErrors"]) == 1 def test_self_loyalty_does_not_touch_other_customer(self): # Bob has 9999 points but alice is current — redeeming 100 should # come from alice's 500, not bob's 9999. handle_redeem_my_points(100) state = shopify_state.get_state() assert state.customers["c1"]["pointsBalance"] == 400 assert state.customers["c2"]["pointsBalance"] == 9999 class TestOrdersSelf: def test_list_my_orders_returns_only_own(self): result = handle_list_my_orders() assert result["userErrors"] == [] assert result["totalCount"] == 1 assert result["orders"][0]["id"] == "o1" def test_get_my_order_returns_own(self): result = handle_get_my_order(GetOrderArgs(order_id="o1")) assert result["userErrors"] == [] assert result["order"]["id"] == "o1" def test_get_my_order_rejects_other_customer_order(self): # Bob's order — alice should not be able to fetch it. result = handle_get_my_order(GetOrderArgs(order_id="o2")) assert result["order"] is None assert "does not belong" in result["userErrors"][0]["message"] def test_get_my_order_nonexistent(self): result = handle_get_my_order(GetOrderArgs(order_id="missing")) assert result["order"] is None assert "not found" in result["userErrors"][0]["message"].lower() class TestReturnSelf: def test_creates_return_on_own_order(self): result = handle_create_my_return( CreateReturnArgs( order_id="o1", line_items=[ CreateReturnLineItemInput( orderLineItemId="li1", quantity=1, reason="changed mind", ) ], ) ) assert result["userErrors"] == [] assert result["return"]["orderId"] == "o1" def test_rejects_other_customer_order(self): result = handle_create_my_return( CreateReturnArgs( order_id="o2", line_items=[ CreateReturnLineItemInput( orderLineItemId="li2", quantity=1, reason="changed mind", ) ], ) ) assert result["return"] is None assert "does not belong" in result["userErrors"][0]["message"] class TestReviewSelf: def test_fills_author_and_email_from_current_customer(self): result = handle_create_my_review(product_id="p1", rating=5, title="Great", body="Love it") assert result["userErrors"] == [] review = result["review"] assert review["author"] == "Alice Smith" assert review["email"] == "alice@example.com" def test_invalid_rating_returns_user_errors(self): result = handle_create_my_review(product_id="p1", rating=6, title="Bad rating", body="") assert result["review"] is None assert result["userErrors"][0]["field"] == "rating" def test_errors_when_unset(self, tmp_path, monkeypatch): data_file = tmp_path / "shopify_data.json" data_file.write_text(json.dumps(_base_data(current_customer_email=None))) monkeypatch.setattr(shopify_state, "_STATE_FILE", data_file) shopify_state._current_state = None shopify_state._stores.clear() shopify_state._active_store_id = "default" shopify_state.load_state() result = handle_create_my_review(product_id="p1", rating=5, title="t", body="b") assert result["review"] is None assert len(result["userErrors"]) == 1