Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1,932 @@
|
||||
"""Tests for order management tools."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from shopify import state as shopify_state
|
||||
from shopify.models import (
|
||||
AppliedGiftCard,
|
||||
CancelOrderArgs,
|
||||
CreateOrderArgs,
|
||||
GetOrderArgs,
|
||||
ListOrdersArgs,
|
||||
LooseCustomer,
|
||||
LooseGiftCard,
|
||||
LooseShippingMethod,
|
||||
MoneyV2,
|
||||
UpdateCartArgs,
|
||||
UpdateOrderArgs,
|
||||
)
|
||||
from shopify.tools.cart import handle_update_cart
|
||||
from shopify.tools.orders import (
|
||||
handle_cancel_order,
|
||||
handle_create_order,
|
||||
handle_get_order,
|
||||
handle_list_orders,
|
||||
handle_update_order,
|
||||
)
|
||||
|
||||
# Reusable test fixtures for required fields
|
||||
VALID_CC = {"type": "credit_card", "card_number": "4111111111111111", "cvv": "123", "expiry": "12/26"}
|
||||
VALID_PAYPAL = {"type": "paypal", "email": "buyer@example.com"}
|
||||
VALID_GPAY = {"type": "google_pay", "email": "buyer@gmail.com"}
|
||||
VALID_APPLEPAY = {"type": "apple_pay", "email": "buyer@icloud.com"}
|
||||
VALID_ADDR = {
|
||||
"firstName": "Alice",
|
||||
"lastName": "Smith",
|
||||
"address1": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"countryCode": "US",
|
||||
"zip": "62701",
|
||||
}
|
||||
|
||||
|
||||
def _order_args(**overrides: Any) -> CreateOrderArgs:
|
||||
"""Build CreateOrderArgs with sensible defaults."""
|
||||
return CreateOrderArgs.model_validate(
|
||||
{
|
||||
"cart_id": overrides.get("cart_id", "cart-1"),
|
||||
"payment_method": overrides.get("payment_method", VALID_CC),
|
||||
"shipping_address": overrides.get("shipping_address", VALID_ADDR),
|
||||
"billing_address": overrides.get("billing_address", VALID_ADDR),
|
||||
"shipping_method_id": overrides.get("shipping_method_id", "standard"),
|
||||
"discount_code": overrides.get("discount_code"),
|
||||
"email": overrides.get("email"),
|
||||
"phone": overrides.get("phone"),
|
||||
"note": overrides.get("note"),
|
||||
"tags": overrides.get("tags"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shopify_data(tmp_path):
|
||||
"""Seed state with a product and a cart containing items."""
|
||||
data_file = tmp_path / "shopify_data.json"
|
||||
data_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"products": {
|
||||
"product-1": {
|
||||
"id": "product-1",
|
||||
"title": "Test Widget",
|
||||
"handle": "test-widget",
|
||||
"variants": [
|
||||
{
|
||||
"id": "variant-1",
|
||||
"title": "Small",
|
||||
"price": {"amount": "25.00", "currencyCode": "USD"},
|
||||
"availableForSale": True,
|
||||
"sku": "WIDGET-S",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"carts": {
|
||||
"cart-1": {
|
||||
"id": "cart-1",
|
||||
"checkoutUrl": "https://shop.example.com/checkout/cart-1",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"lines": [
|
||||
{
|
||||
"id": "line-1",
|
||||
"quantity": 2,
|
||||
"merchandise": {
|
||||
"id": "variant-1",
|
||||
"title": "Small",
|
||||
"product": {"id": "product-1", "title": "Test Widget", "handle": "test-widget"},
|
||||
"price": {"amount": "25.00", "currencyCode": "USD"},
|
||||
"selectedOptions": [{"name": "Size", "value": "Small"}],
|
||||
},
|
||||
"cost": {
|
||||
"amountPerQuantity": {"amount": "25.00", "currencyCode": "USD"},
|
||||
"subtotalAmount": {"amount": "50.00", "currencyCode": "USD"},
|
||||
"totalAmount": {"amount": "50.00", "currencyCode": "USD"},
|
||||
},
|
||||
"attributes": [],
|
||||
"discountAllocations": [],
|
||||
}
|
||||
],
|
||||
"cost": {
|
||||
"subtotalAmount": {"amount": "50.00", "currencyCode": "USD"},
|
||||
"totalAmount": {"amount": "50.00", "currencyCode": "USD"},
|
||||
"checkoutChargeAmount": {"amount": "50.00", "currencyCode": "USD"},
|
||||
},
|
||||
"buyerIdentity": {"email": "alice@example.com", "phone": "+1234567890"},
|
||||
"note": "Please gift wrap",
|
||||
"totalQuantity": 2,
|
||||
},
|
||||
"cart-empty": {
|
||||
"id": "cart-empty",
|
||||
"checkoutUrl": "https://shop.example.com/checkout/cart-empty",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"lines": [],
|
||||
"cost": {
|
||||
"subtotalAmount": {"amount": "0.00", "currencyCode": "USD"},
|
||||
"totalAmount": {"amount": "0.00", "currencyCode": "USD"},
|
||||
"checkoutChargeAmount": {"amount": "0.00", "currencyCode": "USD"},
|
||||
},
|
||||
"buyerIdentity": {},
|
||||
"totalQuantity": 0,
|
||||
},
|
||||
},
|
||||
"orders": {},
|
||||
"customers": {},
|
||||
"collections": {},
|
||||
"reviews": {},
|
||||
"returns": {},
|
||||
"discount_codes": {
|
||||
"dc-1": {
|
||||
"id": "dc-1",
|
||||
"code": "SAVE20",
|
||||
"discountType": "PERCENTAGE",
|
||||
"value": "20",
|
||||
"minimumPurchase": None,
|
||||
"usageLimit": None,
|
||||
"usageCount": 0,
|
||||
"productIds": None,
|
||||
"active": True,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
"dc-2": {
|
||||
"id": "dc-2",
|
||||
"code": "FREESHIP",
|
||||
"discountType": "FREE_SHIPPING",
|
||||
"value": "0",
|
||||
"minimumPurchase": None,
|
||||
"usageLimit": None,
|
||||
"usageCount": 0,
|
||||
"productIds": None,
|
||||
"active": True,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
"dc-3": {
|
||||
"id": "dc-3",
|
||||
"code": "WIDGET10",
|
||||
"discountType": "FIXED_AMOUNT",
|
||||
"value": "10.00",
|
||||
"minimumPurchase": None,
|
||||
"usageLimit": 1,
|
||||
"usageCount": 0,
|
||||
"productIds": ["product-1"],
|
||||
"active": True,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
"dc-4": {
|
||||
"id": "dc-4",
|
||||
"code": "INACTIVE",
|
||||
"discountType": "PERCENTAGE",
|
||||
"value": "50",
|
||||
"minimumPurchase": None,
|
||||
"usageLimit": None,
|
||||
"usageCount": 0,
|
||||
"productIds": None,
|
||||
"active": False,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
"gift_cards": {
|
||||
"GIFT1234": {
|
||||
"id": "gid://shopify/GiftCard/GIFT1234",
|
||||
"code": "GIFT1234",
|
||||
"balance": {"amount": "20.00", "currencyCode": "USD"},
|
||||
"initialValue": {"amount": "20.00", "currencyCode": "USD"},
|
||||
"active": True,
|
||||
},
|
||||
"EMPTYCARD": {
|
||||
"id": "gid://shopify/GiftCard/EMPTYCARD",
|
||||
"code": "EMPTYCARD",
|
||||
"balance": {"amount": "0.00", "currencyCode": "USD"},
|
||||
"active": True,
|
||||
},
|
||||
},
|
||||
"shipping_methods": {
|
||||
"standard": {
|
||||
"id": "standard",
|
||||
"title": "Standard Shipping",
|
||||
"price": {"amount": "5.99", "currencyCode": "USD"},
|
||||
"estimatedDays": "5-7 business days",
|
||||
"active": True,
|
||||
},
|
||||
"express": {
|
||||
"id": "express",
|
||||
"title": "Express Shipping",
|
||||
"price": {"amount": "14.99", "currencyCode": "USD"},
|
||||
"estimatedDays": "1-2 business days",
|
||||
"active": True,
|
||||
},
|
||||
},
|
||||
"policies": [],
|
||||
"counters": {
|
||||
"cart_id": 1000,
|
||||
"line_id": 1000,
|
||||
"order_id": 2000,
|
||||
"line_item_id": 3000,
|
||||
"customer_id": 4000,
|
||||
"collection_id": 5000,
|
||||
"review_id": 6000,
|
||||
"return_id": 7000,
|
||||
"product_id": 8000,
|
||||
"variant_id": 9000,
|
||||
"discount_id": 10000,
|
||||
"policy_id": 11000,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return data_file
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_state(shopify_data, monkeypatch):
|
||||
"""Reset state for each test."""
|
||||
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 TestCreateOrder:
|
||||
def test_create_order_with_credit_card(self):
|
||||
result = handle_create_order(_order_args())
|
||||
assert result["userErrors"] == []
|
||||
order = result["order"]
|
||||
assert order["id"].startswith("gid://shopify/Order/")
|
||||
assert order["financialStatus"] == "PAID"
|
||||
assert order["fulfillmentStatus"] == "UNFULFILLED"
|
||||
assert order["email"] == "alice@example.com"
|
||||
assert order["paymentMethod"]["type"] == "credit_card"
|
||||
assert order["paymentMethod"]["last4"] == "1111"
|
||||
assert order["paymentMethod"]["brand"] == "visa"
|
||||
assert order["shippingAddress"]["address1"] == "123 Main St"
|
||||
assert order["billingAddress"]["city"] == "Springfield"
|
||||
assert order["shippingMethod"]["id"] == "standard"
|
||||
assert order["shippingPrice"]["amount"] == "5.99"
|
||||
assert order["totalPrice"]["amount"] == "55.99" # 50.00 + 5.99 shipping
|
||||
|
||||
def test_create_order_with_paypal(self):
|
||||
result = handle_create_order(_order_args(payment_method=VALID_PAYPAL))
|
||||
order = result["order"]
|
||||
assert order["paymentMethod"]["type"] == "paypal"
|
||||
assert order["paymentMethod"]["email"] == "buyer@example.com"
|
||||
assert order["financialStatus"] == "PAID"
|
||||
|
||||
def test_create_order_with_google_pay(self):
|
||||
result = handle_create_order(_order_args(payment_method=VALID_GPAY))
|
||||
assert result["order"]["paymentMethod"]["type"] == "google_pay"
|
||||
|
||||
def test_create_order_with_apple_pay(self):
|
||||
result = handle_create_order(_order_args(payment_method=VALID_APPLEPAY))
|
||||
assert result["order"]["paymentMethod"]["type"] == "apple_pay"
|
||||
|
||||
def test_mastercard_brand_detection(self):
|
||||
mc = {"type": "credit_card", "card_number": "5500000000000004", "cvv": "123", "expiry": "12/26"}
|
||||
result = handle_create_order(_order_args(payment_method=mc))
|
||||
assert result["order"]["paymentMethod"]["brand"] == "mastercard"
|
||||
|
||||
def test_amex_brand_detection(self):
|
||||
amex = {"type": "credit_card", "card_number": "371449635398431", "cvv": "1234", "expiry": "12/26"}
|
||||
result = handle_create_order(_order_args(payment_method=amex))
|
||||
assert result["order"]["paymentMethod"]["brand"] == "amex"
|
||||
|
||||
def test_card_number_with_spaces(self):
|
||||
spaced = {"type": "credit_card", "card_number": "4111 1111 1111 1111", "cvv": "123", "expiry": "12/26"}
|
||||
result = handle_create_order(_order_args(payment_method=spaced))
|
||||
assert result["order"]["paymentMethod"]["last4"] == "1111"
|
||||
|
||||
def test_card_number_with_dashes(self):
|
||||
dashed = {"type": "credit_card", "card_number": "4111-1111-1111-1111", "cvv": "123", "expiry": "12/26"}
|
||||
result = handle_create_order(_order_args(payment_method=dashed))
|
||||
assert result["order"]["paymentMethod"]["last4"] == "1111"
|
||||
|
||||
def test_invalid_card_number_too_short(self):
|
||||
bad = {"type": "credit_card", "card_number": "411111", "cvv": "123", "expiry": "12/26"}
|
||||
result = handle_create_order(_order_args(payment_method=bad))
|
||||
assert result["order"] is None
|
||||
assert any("card_number" in e["field"] for e in result["userErrors"])
|
||||
|
||||
def test_invalid_cvv(self):
|
||||
bad = {"type": "credit_card", "card_number": "4111111111111111", "cvv": "12", "expiry": "12/26"}
|
||||
result = handle_create_order(_order_args(payment_method=bad))
|
||||
assert result["order"] is None
|
||||
assert any("cvv" in e["field"] for e in result["userErrors"])
|
||||
|
||||
def test_invalid_expiry(self):
|
||||
bad = {"type": "credit_card", "card_number": "4111111111111111", "cvv": "123", "expiry": "2026-12"}
|
||||
result = handle_create_order(_order_args(payment_method=bad))
|
||||
assert result["order"] is None
|
||||
assert any("expiry" in e["field"] for e in result["userErrors"])
|
||||
|
||||
def test_invalid_payment_type(self):
|
||||
with pytest.raises(ValidationError):
|
||||
_order_args(payment_method={"type": "bitcoin"})
|
||||
|
||||
def test_paypal_missing_email(self):
|
||||
with pytest.raises(ValidationError):
|
||||
_order_args(payment_method={"type": "paypal"})
|
||||
|
||||
@pytest.mark.parametrize("email", ["@", "@@", " @", "a@", "buyer@example"])
|
||||
def test_digital_wallet_invalid_email(self, email):
|
||||
with pytest.raises(ValidationError):
|
||||
_order_args(payment_method={"type": "paypal", "email": email})
|
||||
|
||||
def test_missing_shipping_address_field(self):
|
||||
bad_addr = {"firstName": "Alice"} # missing address1, city, countryCode
|
||||
result = handle_create_order(_order_args(shipping_address=bad_addr))
|
||||
assert result["order"] is None
|
||||
assert any("shipping_address" in e["field"] for e in result["userErrors"])
|
||||
|
||||
def test_missing_billing_address_field(self):
|
||||
bad_addr = {"address1": "123 Main St"} # missing city, countryCode
|
||||
result = handle_create_order(_order_args(billing_address=bad_addr))
|
||||
assert result["order"] is None
|
||||
assert any("billing_address" in e["field"] for e in result["userErrors"])
|
||||
|
||||
def test_create_order_empty_cart(self):
|
||||
result = handle_create_order(_order_args(cart_id="cart-empty"))
|
||||
assert result["order"] is None
|
||||
assert "empty" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_create_order_nonexistent_cart(self):
|
||||
result = handle_create_order(_order_args(cart_id="nonexistent"))
|
||||
assert result["order"] is None
|
||||
|
||||
def test_create_order_overrides_buyer_identity(self):
|
||||
result = handle_create_order(_order_args(email="bob@example.com", phone="+9999999999"))
|
||||
order = result["order"]
|
||||
assert order["email"] == "bob@example.com"
|
||||
assert order["phone"] == "+9999999999"
|
||||
|
||||
def test_create_order_with_tags(self):
|
||||
result = handle_create_order(_order_args(tags=["vip", "rush"]))
|
||||
assert result["order"]["tags"] == ["vip", "rush"]
|
||||
|
||||
def test_create_order_generates_line_item_ids(self):
|
||||
result = handle_create_order(_order_args())
|
||||
line = result["order"]["lineItems"][0]
|
||||
assert line["id"].startswith("gid://shopify/OrderLineItem/")
|
||||
|
||||
|
||||
class TestGetOrder:
|
||||
def test_get_existing_order(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
result = handle_get_order(GetOrderArgs(order_id=order_id))
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["id"] == order_id
|
||||
|
||||
def test_get_nonexistent_order(self):
|
||||
result = handle_get_order(GetOrderArgs(order_id="nonexistent"))
|
||||
assert result["order"] is None
|
||||
|
||||
|
||||
class TestListOrders:
|
||||
def test_list_empty(self):
|
||||
result = handle_list_orders(ListOrdersArgs())
|
||||
assert result["totalCount"] == 0
|
||||
|
||||
def test_list_with_orders(self):
|
||||
handle_create_order(_order_args())
|
||||
result = handle_list_orders(ListOrdersArgs())
|
||||
assert result["totalCount"] == 1
|
||||
|
||||
def test_list_filter_by_financial_status(self):
|
||||
handle_create_order(_order_args())
|
||||
result = handle_list_orders(ListOrdersArgs(status="PAID"))
|
||||
assert result["totalCount"] == 1
|
||||
result = handle_list_orders(ListOrdersArgs(status="PENDING"))
|
||||
assert result["totalCount"] == 0
|
||||
|
||||
def test_list_filter_by_fulfillment_status(self):
|
||||
handle_create_order(_order_args())
|
||||
result = handle_list_orders(ListOrdersArgs(status="UNFULFILLED"))
|
||||
assert result["totalCount"] == 1
|
||||
|
||||
def test_list_pagination(self):
|
||||
handle_create_order(_order_args())
|
||||
handle_create_order(_order_args())
|
||||
result = handle_list_orders(ListOrdersArgs(limit=1))
|
||||
assert len(result["orders"]) == 1
|
||||
assert result["totalCount"] == 2
|
||||
assert result["pageInfo"]["hasNextPage"] is True
|
||||
|
||||
|
||||
class TestUpdateOrder:
|
||||
def test_update_financial_status_to_paid(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
shopify_state.get_state().orders[order_id].financialStatus = "PENDING"
|
||||
result = handle_update_order(UpdateOrderArgs(order_id=order_id, financial_status="PAID"))
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["financialStatus"] == "PAID"
|
||||
|
||||
@pytest.mark.parametrize("status", ["VOIDED", "REFUNDED", "PARTIALLY_REFUNDED"])
|
||||
def test_update_financial_status_rejects_side_effect_statuses(self, status):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
result = handle_update_order(UpdateOrderArgs(order_id=order_id, financial_status=status))
|
||||
assert result["order"] is None
|
||||
assert result["userErrors"][0]["field"] == "financial_status"
|
||||
assert shopify_state.get_state().orders[order_id].financialStatus == "PAID"
|
||||
|
||||
@pytest.mark.parametrize("status", ["VOIDED", "REFUNDED", "PARTIALLY_REFUNDED"])
|
||||
def test_update_financial_status_rejects_leaving_side_effect_statuses(self, status):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
shopify_state.get_state().orders[order_id].financialStatus = status
|
||||
result = handle_update_order(UpdateOrderArgs(order_id=order_id, financial_status="PAID"))
|
||||
assert result["order"] is None
|
||||
assert result["userErrors"][0]["field"] == "financial_status"
|
||||
assert shopify_state.get_state().orders[order_id].financialStatus == status
|
||||
|
||||
def test_update_fulfillment_status(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
result = handle_update_order(UpdateOrderArgs(order_id=order_id, fulfillment_status="FULFILLED"))
|
||||
assert result["order"]["fulfillmentStatus"] == "FULFILLED"
|
||||
|
||||
def test_update_invalid_status(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
with pytest.raises(ValidationError):
|
||||
UpdateOrderArgs.model_validate({"order_id": order_id, "financial_status": "INVALID"})
|
||||
|
||||
def test_update_note_and_tags(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
result = handle_update_order(UpdateOrderArgs(order_id=order_id, note="Updated note", tags=["priority"]))
|
||||
assert result["order"]["note"] == "Updated note"
|
||||
assert result["order"]["tags"] == ["priority"]
|
||||
|
||||
def test_update_rejects_customer_email_reassignment(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
result = handle_update_order(UpdateOrderArgs(order_id=order_id, email="new@example.com"))
|
||||
assert result["order"] is None
|
||||
assert result["userErrors"][0]["field"] == "email"
|
||||
assert shopify_state.get_state().orders[order_id].email == "alice@example.com"
|
||||
|
||||
def test_update_rejects_email_reassignment_before_any_mutation(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
order = shopify_state.get_state().orders[order_id]
|
||||
original_updated_at = order.updatedAt
|
||||
|
||||
result = handle_update_order(
|
||||
UpdateOrderArgs(
|
||||
order_id=order_id,
|
||||
financial_status="PAID",
|
||||
fulfillment_status="FULFILLED",
|
||||
note="Updated note",
|
||||
tags=["urgent"],
|
||||
email="new@example.com",
|
||||
)
|
||||
)
|
||||
|
||||
assert result["order"] is None
|
||||
assert result["userErrors"][0]["field"] == "email"
|
||||
assert order.financialStatus == "PAID"
|
||||
assert order.fulfillmentStatus == "UNFULFILLED"
|
||||
assert order.trackingNumber is None
|
||||
assert order.trackingUrl is None
|
||||
assert order.note == "Please gift wrap"
|
||||
assert order.tags == []
|
||||
assert order.email == "alice@example.com"
|
||||
assert order.updatedAt == original_updated_at
|
||||
|
||||
def test_update_nonexistent_order(self):
|
||||
result = handle_update_order(UpdateOrderArgs(order_id="nonexistent"))
|
||||
assert result["order"] is None
|
||||
|
||||
|
||||
class TestCancelOrder:
|
||||
def test_cancel_order(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
result = handle_cancel_order(CancelOrderArgs(order_id=order_id))
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["cancelledAt"] is not None
|
||||
assert result["order"]["financialStatus"] == "REFUNDED"
|
||||
|
||||
def test_cancel_pending_order_voids_payment(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
shopify_state.get_state().orders[order_id].financialStatus = "PENDING"
|
||||
|
||||
result = handle_cancel_order(CancelOrderArgs(order_id=order_id))
|
||||
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["cancelledAt"] is not None
|
||||
assert result["order"]["financialStatus"] == "VOIDED"
|
||||
|
||||
def test_cancel_reverses_checkout_side_effects(self):
|
||||
state = shopify_state.get_state()
|
||||
state.loyalty_program.enabled = True
|
||||
state.customers["cust-1"] = LooseCustomer.model_validate(
|
||||
{
|
||||
"id": "cust-1",
|
||||
"email": "alice@example.com",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"ordersCount": 0,
|
||||
"totalSpent": {"amount": "0.00", "currencyCode": "USD"},
|
||||
"pointsBalance": 1000,
|
||||
"lifetimePoints": 1000,
|
||||
}
|
||||
)
|
||||
handle_update_cart(UpdateCartArgs(cart_id="cart-1", gift_card_codes=["GIFT1234"]))
|
||||
create_result = handle_create_order(
|
||||
CreateOrderArgs.model_validate(
|
||||
{
|
||||
"cart_id": "cart-1",
|
||||
"payment_method": VALID_CC,
|
||||
"shipping_address": VALID_ADDR,
|
||||
"billing_address": VALID_ADDR,
|
||||
"shipping_method_id": "standard",
|
||||
"discount_code": "WIDGET10",
|
||||
"email": "alice@example.com",
|
||||
"redeem_points": 100,
|
||||
}
|
||||
)
|
||||
)
|
||||
order_id = create_result["order"]["id"]
|
||||
customer = state.customers["cust-1"]
|
||||
assert state.discount_codes["dc-3"].usageCount == 1
|
||||
assert state.gift_cards["GIFT1234"].balance.amount == "0.00"
|
||||
assert customer.ordersCount == 1
|
||||
assert customer.totalSpent is not None
|
||||
assert customer.totalSpent.amount == "24.99"
|
||||
assert customer.pointsBalance == 939
|
||||
assert customer.lifetimePoints == 1039
|
||||
|
||||
result = handle_cancel_order(CancelOrderArgs(order_id=order_id))
|
||||
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["sideEffectsReversedAt"] is not None
|
||||
assert state.discount_codes["dc-3"].usageCount == 0
|
||||
assert state.gift_cards["GIFT1234"].balance.amount == "20.00"
|
||||
assert customer.ordersCount == 0
|
||||
assert customer.totalSpent is not None
|
||||
assert customer.totalSpent.amount == "0.00"
|
||||
assert customer.pointsBalance == 1000
|
||||
assert customer.lifetimePoints == 1000
|
||||
|
||||
def test_cancel_with_reason(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
result = handle_cancel_order(CancelOrderArgs(order_id=order_id, reason="Customer changed mind"))
|
||||
assert "Customer changed mind" in result["order"]["note"]
|
||||
|
||||
def test_cancel_already_cancelled(self):
|
||||
create_result = handle_create_order(_order_args())
|
||||
order_id = create_result["order"]["id"]
|
||||
handle_cancel_order(CancelOrderArgs(order_id=order_id))
|
||||
result = handle_cancel_order(CancelOrderArgs(order_id=order_id))
|
||||
assert result["order"] is None
|
||||
assert "already cancelled" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_cancel_nonexistent_order(self):
|
||||
result = handle_cancel_order(CancelOrderArgs(order_id="nonexistent"))
|
||||
assert result["order"] is None
|
||||
|
||||
|
||||
class TestDiscountApplication:
|
||||
def test_percentage_discount(self):
|
||||
# SAVE20 = 20% off subtotal ($50) = $10 off → total = 50 - 10 + 5.99 = 45.99
|
||||
result = handle_create_order(_order_args(discount_code="SAVE20"))
|
||||
order = result["order"]
|
||||
assert order["discount"]["code"] == "SAVE20"
|
||||
assert order["discountAmount"]["amount"] == "10.00"
|
||||
assert order["subtotalPrice"]["amount"] == "50.00"
|
||||
assert order["shippingPrice"]["amount"] == "5.99"
|
||||
assert order["totalPrice"]["amount"] == "45.99"
|
||||
|
||||
def test_free_shipping_discount(self):
|
||||
# FREESHIP removes shipping cost → total = 50 + 0 = 50.00
|
||||
result = handle_create_order(_order_args(discount_code="FREESHIP"))
|
||||
order = result["order"]
|
||||
assert order["shippingPrice"]["amount"] == "0.00"
|
||||
assert order["discountAmount"]["amount"] == "5.99"
|
||||
assert order["totalPrice"]["amount"] == "50.00"
|
||||
|
||||
def test_product_scoped_fixed_discount(self):
|
||||
# WIDGET10 = $10 off product-1 items only → 50 - 10 + 5.99 = 45.99
|
||||
result = handle_create_order(_order_args(discount_code="WIDGET10"))
|
||||
order = result["order"]
|
||||
assert order["discountAmount"]["amount"] == "10.00"
|
||||
assert order["totalPrice"]["amount"] == "45.99"
|
||||
|
||||
def test_discount_increments_usage(self):
|
||||
handle_create_order(_order_args(discount_code="SAVE20"))
|
||||
from shopify.state import get_discount_by_code
|
||||
|
||||
dc = get_discount_by_code("SAVE20")
|
||||
assert dc is not None
|
||||
assert dc["usageCount"] == 1
|
||||
|
||||
def test_usage_limit_enforced(self):
|
||||
# WIDGET10 has usageLimit=1
|
||||
handle_create_order(_order_args(discount_code="WIDGET10"))
|
||||
result = handle_create_order(_order_args(discount_code="WIDGET10"))
|
||||
assert result["order"] is None
|
||||
assert "usage limit" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_inactive_discount_rejected(self):
|
||||
state = shopify_state.get_state()
|
||||
before_order_id = state.counters.order_id
|
||||
before_line_item_id = state.counters.line_item_id
|
||||
|
||||
result = handle_create_order(_order_args(discount_code="INACTIVE"))
|
||||
|
||||
assert result["order"] is None
|
||||
assert "not active" in result["userErrors"][0]["message"]
|
||||
assert state.counters.order_id == before_order_id
|
||||
assert state.counters.line_item_id == before_line_item_id
|
||||
|
||||
def test_nonexistent_discount_rejected(self):
|
||||
result = handle_create_order(_order_args(discount_code="DOESNTEXIST"))
|
||||
assert result["order"] is None
|
||||
assert "not found" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_no_discount_no_discount_info(self):
|
||||
result = handle_create_order(_order_args())
|
||||
assert result["order"]["discount"] is None
|
||||
assert result["order"]["discountAmount"]["amount"] == "0.00"
|
||||
|
||||
def test_cart_discount_applies_when_checkout_has_no_explicit_discount(self):
|
||||
handle_update_cart(UpdateCartArgs(cart_id="cart-1", discount_codes=["SAVE20"]))
|
||||
|
||||
result = handle_create_order(_order_args())
|
||||
|
||||
order = result["order"]
|
||||
assert order["discount"]["code"] == "SAVE20"
|
||||
assert order["discountAmount"]["amount"] == "10.00"
|
||||
assert order["totalPrice"]["amount"] == "45.99"
|
||||
|
||||
def test_cart_marks_non_combinable_second_discount_not_applicable(self):
|
||||
result = handle_update_cart(UpdateCartArgs(cart_id="cart-1", discount_codes=["SAVE20", "FREESHIP"]))
|
||||
|
||||
assert result["cart"]["discountCodes"][0]["applicable"] is True
|
||||
assert result["cart"]["discountCodes"][1]["applicable"] is False
|
||||
assert "cannot be combined" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_cart_applies_multiple_combinable_discounts_at_checkout(self):
|
||||
state = shopify_state.get_state()
|
||||
state.discount_codes["dc-1"].combinesWith.shippingDiscounts = True
|
||||
state.discount_codes["dc-2"].combinesWith.orderDiscounts = True
|
||||
|
||||
cart_result = handle_update_cart(UpdateCartArgs(cart_id="cart-1", discount_codes=["SAVE20", "FREESHIP"]))
|
||||
result = handle_create_order(_order_args())
|
||||
|
||||
order = result["order"]
|
||||
assert [code["applicable"] for code in cart_result["discountCodes"]] == [True, True]
|
||||
assert order["discount"]["code"] == "SAVE20"
|
||||
assert [discount["code"] for discount in order["discounts"]] == ["SAVE20", "FREESHIP"]
|
||||
assert order["discountAmount"]["amount"] == "15.99"
|
||||
assert order["shippingPrice"]["amount"] == "0.00"
|
||||
assert order["totalPrice"]["amount"] == "40.00"
|
||||
|
||||
def test_update_cart_marks_valid_discount_applicable(self):
|
||||
result = handle_update_cart(UpdateCartArgs(cart_id="cart-1", discount_codes=["SAVE20"]))
|
||||
|
||||
assert result["discountCodes"][0]["code"] == "SAVE20"
|
||||
assert result["discountCodes"][0]["applicable"] is True
|
||||
|
||||
def test_update_cart_marks_nonexistent_discount_not_applicable(self):
|
||||
result = handle_update_cart(UpdateCartArgs(cart_id="cart-1", discount_codes=["DOESNTEXIST"]))
|
||||
|
||||
assert result["cart"]["discountCodes"][0]["code"] == "DOESNTEXIST"
|
||||
assert result["cart"]["discountCodes"][0]["applicable"] is False
|
||||
assert "not found" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_update_cart_marks_inactive_discount_not_applicable(self):
|
||||
result = handle_update_cart(UpdateCartArgs(cart_id="cart-1", discount_codes=["INACTIVE"]))
|
||||
|
||||
assert result["cart"]["discountCodes"][0]["applicable"] is False
|
||||
assert "not active" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_update_cart_marks_minimum_purchase_discount_not_applicable(self):
|
||||
discount = shopify_state.get_state().discount_codes["dc-1"]
|
||||
discount.code = "MIN100"
|
||||
discount.minimumPurchase = MoneyV2(amount="100.00", currencyCode="USD")
|
||||
|
||||
result = handle_update_cart(UpdateCartArgs(cart_id="cart-1", discount_codes=["MIN100"]))
|
||||
|
||||
assert result["cart"]["discountCodes"][0]["applicable"] is False
|
||||
assert "Minimum purchase" in result["userErrors"][0]["message"]
|
||||
|
||||
def test_cart_delivery_options_and_gift_cards_are_stored(self):
|
||||
result = handle_update_cart(
|
||||
UpdateCartArgs.model_validate(
|
||||
{
|
||||
"cart_id": "cart-1",
|
||||
"delivery_addresses_to_add": [
|
||||
{
|
||||
"address": {
|
||||
"address1": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"countryCode": "US",
|
||||
}
|
||||
}
|
||||
],
|
||||
"gift_card_codes": ["GIFT1234"],
|
||||
}
|
||||
)
|
||||
)
|
||||
cart = result
|
||||
assert cart["buyerIdentity"]["deliveryAddressPreferences"][0]["address1"] == "123 Main St"
|
||||
assert cart["deliveryGroups"][0]["id"].endswith("/delivery-group/1")
|
||||
assert cart["deliveryGroups"][0]["deliveryOptions"][0]["handle"] == "standard"
|
||||
assert cart["deliveryGroups"][0]["deliveryOptions"][0]["estimatedCost"]["amount"] == "5.99"
|
||||
assert cart["deliveryGroups"][0]["deliveryOptions"][1]["handle"] == "express"
|
||||
assert cart["deliveryGroups"][0]["deliveryOptions"][1]["estimatedCost"]["amount"] == "14.99"
|
||||
assert cart["appliedGiftCards"][0]["code"] == "GIFT1234"
|
||||
assert cart["appliedGiftCards"][0]["lastCharacters"] == "1234"
|
||||
assert cart["appliedGiftCards"][0]["amountUsed"]["amount"] == "20.00"
|
||||
assert cart["appliedGiftCards"][0]["balance"]["amount"] == "0.00"
|
||||
assert cart["cost"]["checkoutChargeAmount"]["amount"] == "30.00"
|
||||
|
||||
selected_cart = handle_update_cart(
|
||||
UpdateCartArgs.model_validate(
|
||||
{
|
||||
"cart_id": "cart-1",
|
||||
"selected_delivery_options": [
|
||||
{
|
||||
"deliveryGroupId": "cart-1/delivery-group/1",
|
||||
"deliveryOptionHandle": "express",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert selected_cart["deliveryGroups"][0]["selectedDeliveryOption"]["handle"] == "express"
|
||||
|
||||
def test_cart_delivery_options_use_configured_active_shipping_methods(self):
|
||||
state = shopify_state.get_state()
|
||||
state.shipping_methods["standard"].active = False
|
||||
state.shipping_methods["local-bike"] = LooseShippingMethod.model_validate(
|
||||
{
|
||||
"id": "local-bike",
|
||||
"title": "Local Bike Courier",
|
||||
"price": {"amount": "3.50", "currencyCode": "USD"},
|
||||
"estimatedDays": "Same day",
|
||||
"active": True,
|
||||
}
|
||||
)
|
||||
|
||||
result = handle_update_cart(
|
||||
UpdateCartArgs.model_validate(
|
||||
{
|
||||
"cart_id": "cart-1",
|
||||
"delivery_addresses_to_add": [
|
||||
{
|
||||
"address": {
|
||||
"address1": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"countryCode": "US",
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
options = result["deliveryGroups"][0]["deliveryOptions"]
|
||||
assert [option["handle"] for option in options] == ["express", "local-bike"]
|
||||
assert options[0]["estimatedCost"]["amount"] == "14.99"
|
||||
assert options[1]["estimatedCost"]["amount"] == "3.50"
|
||||
|
||||
def test_create_order_uses_selected_cart_delivery_option_when_shipping_omitted(self):
|
||||
handle_update_cart(
|
||||
UpdateCartArgs.model_validate(
|
||||
{
|
||||
"cart_id": "cart-1",
|
||||
"delivery_addresses_to_add": [
|
||||
{
|
||||
"address": {
|
||||
"address1": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"countryCode": "US",
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
handle_update_cart(
|
||||
UpdateCartArgs.model_validate(
|
||||
{
|
||||
"cart_id": "cart-1",
|
||||
"selected_delivery_options": [
|
||||
{
|
||||
"deliveryGroupId": "cart-1/delivery-group/1",
|
||||
"deliveryOptionHandle": "express",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = handle_create_order(
|
||||
CreateOrderArgs.model_validate(
|
||||
{
|
||||
"cart_id": "cart-1",
|
||||
"payment_method": VALID_CC,
|
||||
"shipping_address": VALID_ADDR,
|
||||
"billing_address": VALID_ADDR,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["shippingMethod"]["id"] == "express"
|
||||
assert result["order"]["shippingPrice"]["amount"] == "14.99"
|
||||
|
||||
def test_cart_rejects_unknown_and_empty_gift_cards(self):
|
||||
result = handle_update_cart(UpdateCartArgs(cart_id="cart-1", gift_card_codes=["NOPE", "EMPTYCARD"]))
|
||||
|
||||
assert result["cart"]["appliedGiftCards"] == []
|
||||
assert len(result["userErrors"]) == 2
|
||||
assert "not found" in result["userErrors"][0]["message"]
|
||||
assert "no remaining balance" in result["userErrors"][1]["message"]
|
||||
|
||||
def test_create_order_applies_gift_card_balance(self):
|
||||
handle_update_cart(UpdateCartArgs(cart_id="cart-1", gift_card_codes=["GIFT1234"]))
|
||||
|
||||
result = handle_create_order(_order_args())
|
||||
|
||||
order = result["order"]
|
||||
assert order["giftCardAmount"]["amount"] == "20.00"
|
||||
assert order["appliedGiftCards"][0]["code"] == "GIFT1234"
|
||||
assert order["appliedGiftCards"][0]["amountUsed"]["amount"] == "20.00"
|
||||
assert order["totalPrice"]["amount"] == "35.99"
|
||||
assert shopify_state.get_state().gift_cards["GIFT1234"].balance.amount == "0.00"
|
||||
|
||||
def test_create_order_applies_gift_card_code_with_slash(self):
|
||||
state = shopify_state.get_state()
|
||||
state.gift_cards["GIFT/2024"] = LooseGiftCard.model_validate(
|
||||
{
|
||||
"id": "gid://shopify/GiftCard/GIFT/2024",
|
||||
"code": "GIFT/2024",
|
||||
"balance": {"amount": "10.00", "currencyCode": "USD"},
|
||||
"initialValue": {"amount": "10.00", "currencyCode": "USD"},
|
||||
"active": True,
|
||||
}
|
||||
)
|
||||
handle_update_cart(UpdateCartArgs(cart_id="cart-1", gift_card_codes=["GIFT/2024"]))
|
||||
|
||||
result = handle_create_order(_order_args())
|
||||
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["appliedGiftCards"][0]["code"] == "GIFT/2024"
|
||||
assert result["order"]["giftCardAmount"]["amount"] == "10.00"
|
||||
assert state.gift_cards["GIFT/2024"].balance.amount == "0.00"
|
||||
|
||||
def test_create_order_validates_all_gift_cards_before_mutating(self):
|
||||
state = shopify_state.get_state()
|
||||
handle_update_cart(UpdateCartArgs(cart_id="cart-1", gift_card_codes=["GIFT1234"]))
|
||||
state.gift_cards["STALE"] = LooseGiftCard.model_validate(
|
||||
{
|
||||
"id": "gid://shopify/GiftCard/STALE",
|
||||
"code": "STALE",
|
||||
"balance": {"amount": "10.00", "currencyCode": "USD"},
|
||||
"active": False,
|
||||
}
|
||||
)
|
||||
state.carts["cart-1"].appliedGiftCards.append(
|
||||
AppliedGiftCard.model_validate(
|
||||
{
|
||||
"id": "gid://shopify/AppliedGiftCard/STALE",
|
||||
"code": "STALE",
|
||||
"lastCharacters": "TALE",
|
||||
"amountUsed": {"amount": "10.00", "currencyCode": "USD"},
|
||||
"balance": {"amount": "0.00", "currencyCode": "USD"},
|
||||
"presentmentAmountUsed": {"amount": "10.00", "currencyCode": "USD"},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = handle_create_order(_order_args(discount_code="WIDGET10"))
|
||||
|
||||
assert result["order"] is None
|
||||
assert "not active" in result["userErrors"][0]["message"]
|
||||
assert state.gift_cards["GIFT1234"].balance.amount == "20.00"
|
||||
assert state.discount_codes["dc-3"].usageCount == 0
|
||||
assert state.counters.order_id == 2000
|
||||
assert state.counters.line_item_id == 3000
|
||||
|
||||
def test_order_without_discount_code(self):
|
||||
# Verify orders still work fine without any discount
|
||||
result = handle_create_order(_order_args())
|
||||
assert result["userErrors"] == []
|
||||
assert result["order"]["totalPrice"]["amount"] == "55.99" # 50 + 5.99
|
||||
Reference in New Issue
Block a user