Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 72f9a65917
1800 changed files with 323589 additions and 0 deletions
@@ -0,0 +1,260 @@
"""Tests for the nested bundle-input directory layout (PR #205).
Each service resolves its bundle seed state from
``<BUNDLEDIR>/services/<name>/state.json`` (preferred), else the first
``*.json`` in that subdir, else falls back to ``<INPUTDIR>/*.json``.
"""
import json
import pytest
from shopify import state as shopify_state
# A minimal but non-trivial valid seed: one product with one variant. Both the
# bundle path and the INPUTDIR path are seeded with this identical payload so a
# round-trip equivalence comparison is meaningful.
_SEED = {
"products": {
"product-1": {
"id": "product-1",
"title": "Test Product",
"variants": [
{
"id": "variant-1",
"title": "Default",
"price": {"amount": "10.00", "currencyCode": "USD"},
"availableForSale": True,
}
],
}
}
}
def _reset_store_registry(monkeypatch):
"""Clear the in-memory store registry and disable the workspace state file.
Patching ``_STATE_FILE`` to None ensures ``_get_state_file()`` returns None
so the agent-workspace branch in ``load_state()`` never interferes with the
bundle/INPUTDIR precedence under test.
"""
monkeypatch.setattr(shopify_state, "_STATE_FILE", None)
shopify_state._stores.clear()
shopify_state._current_state = None
shopify_state._active_store_id = "default"
def test_resolve_bundle_state_path_prefers_state_json(tmp_path, monkeypatch):
service_dir = tmp_path / "services" / "shopify"
service_dir.mkdir(parents=True)
state_json = service_dir / "state.json"
state_json.write_text(json.dumps(_SEED))
(service_dir / "products.json").write_text(json.dumps(_SEED))
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert shopify_state.resolve_bundle_state_path() == state_json
def test_resolve_bundle_state_path_globs_when_no_state_json(tmp_path, monkeypatch):
service_dir = tmp_path / "services" / "shopify"
service_dir.mkdir(parents=True)
a_json = service_dir / "a.json"
a_json.write_text(json.dumps(_SEED))
(service_dir / "b.json").write_text(json.dumps(_SEED))
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert shopify_state.resolve_bundle_state_path() == a_json
def test_resolve_bundle_state_path_missing_subdir(tmp_path, monkeypatch):
# services/ exists but no services/shopify subdir.
(tmp_path / "services").mkdir()
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert shopify_state.resolve_bundle_state_path() is None
# BUNDLEDIR unset entirely.
monkeypatch.delenv("BUNDLEDIR", raising=False)
assert shopify_state.resolve_bundle_state_path() is None
def test_resolve_bundle_output_path(tmp_path, monkeypatch):
output_dir = tmp_path / "services" / "shopify"
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", str(output_dir))
assert shopify_state.resolve_bundle_output_path() == output_dir / "state.json"
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
assert shopify_state.resolve_bundle_output_path() is None
def test_bundle_state_json_matches_inputdir(tmp_path, monkeypatch):
# Write the identical seed to both the nested bundle state.json and the
# legacy INPUTDIR glob target.
bundle_dir = tmp_path / "bundle"
bundle_service_dir = bundle_dir / "services" / "shopify"
bundle_service_dir.mkdir(parents=True)
(bundle_service_dir / "state.json").write_text(json.dumps(_SEED))
inputdir = tmp_path / "inputdir"
inputdir.mkdir()
(inputdir / "shopify.json").write_text(json.dumps(_SEED))
# Keep snapshot writes from touching the filesystem during load.
monkeypatch.delenv("OUTPUTDIR", raising=False)
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
# --- Load via BUNDLEDIR only ---
_reset_store_registry(monkeypatch)
monkeypatch.setenv("BUNDLEDIR", str(bundle_dir))
monkeypatch.delenv("INPUTDIR", raising=False)
shopify_state.load_state()
bundle_json = shopify_state.state_to_json()
# --- Load via INPUTDIR only ---
_reset_store_registry(monkeypatch)
monkeypatch.delenv("BUNDLEDIR", raising=False)
monkeypatch.setenv("INPUTDIR", str(inputdir))
shopify_state.load_state()
inputdir_json = shopify_state.state_to_json()
assert bundle_json == inputdir_json
# Sanity: the seed actually loaded (non-empty product map).
assert bundle_json["products"]
# Two distinguishable single-store seeds (different product) so a coalesced
# multi-store load is observably the union of both.
_STORE_A = {
"products": {
"product-a": {
"id": "product-a",
"title": "Store A Widget",
"variants": [
{
"id": "variant-a",
"title": "Default",
"price": {"amount": "10.00", "currencyCode": "USD"},
"availableForSale": True,
}
],
}
}
}
_STORE_B = {
"products": {
"product-b": {
"id": "product-b",
"title": "Store B Gadget",
"variants": [
{
"id": "variant-b",
"title": "Default",
"price": {"amount": "20.00", "currencyCode": "USD"},
"availableForSale": True,
}
],
}
}
}
def test_resolve_bundle_state_paths_returns_whole_folder(tmp_path, monkeypatch):
service_dir = tmp_path / "services" / "shopify"
service_dir.mkdir(parents=True)
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
# No state.json → all *.json, sorted.
b_json = service_dir / "b.json"
b_json.write_text(json.dumps(_STORE_B))
a_json = service_dir / "a.json"
a_json.write_text(json.dumps(_STORE_A))
assert shopify_state.resolve_bundle_state_paths() == [a_json, b_json]
# state.json present → just [state.json], ignoring the others.
state_json = service_dir / "state.json"
state_json.write_text(json.dumps(_SEED))
assert shopify_state.resolve_bundle_state_paths() == [state_json]
def test_bundle_multifile_folder_matches_consolidated_state(tmp_path, monkeypatch):
# (a) A single consolidated state.json holding both stores explicitly.
consolidated_dir = tmp_path / "consolidated"
consolidated_service = consolidated_dir / "services" / "shopify"
consolidated_service.mkdir(parents=True)
(consolidated_service / "state.json").write_text(json.dumps({"stores": {"default": _STORE_A, "second": _STORE_B}}))
# (b) The same two stores split across {stores} wrapper files (no
# state.json) — wrappers are how a world declares distinct named stores
# across files (vs. the flat-file case, which merges into one store).
split_dir = tmp_path / "split"
split_service = split_dir / "services" / "shopify"
split_service.mkdir(parents=True)
(split_service / "a.json").write_text(json.dumps({"stores": {"default": _STORE_A}}))
(split_service / "b.json").write_text(json.dumps({"stores": {"second": _STORE_B}}))
# Keep snapshot writes from touching the filesystem during load.
monkeypatch.delenv("OUTPUTDIR", raising=False)
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
monkeypatch.delenv("INPUTDIR", raising=False)
# --- Load the consolidated single-file bundle ---
_reset_store_registry(monkeypatch)
monkeypatch.setenv("BUNDLEDIR", str(consolidated_dir))
shopify_state.load_state()
consolidated_json = shopify_state.state_to_json()
# --- Load the multi-file bundle folder ---
_reset_store_registry(monkeypatch)
monkeypatch.setenv("BUNDLEDIR", str(split_dir))
shopify_state.load_state()
split_json = shopify_state.state_to_json()
assert split_json == consolidated_json
# Sanity: both stores actually present in the coalesced load.
assert set(split_json["stores"]) == {"default", "second"}
def test_bundle_flat_files_merge_into_one_store(tmp_path, monkeypatch):
"""the raw entities layout splits ONE store across per-entity files (no
{stores} wrapper). Flat files must merge into a single default store, not
fragment into a phantom store per file the server never activates."""
for var in ("BUNDLEDIR", "INPUTDIR", "OUTPUTDIR", "BUNDLE_OUTPUT_DIR"):
monkeypatch.delenv(var, raising=False)
product_a = {
"id": "product-a",
"title": "Product A",
"variants": [{"id": "va", "title": "Default", "price": {"amount": "1.00", "currencyCode": "USD"}}],
}
product_b = {
"id": "product-b",
"title": "Product B",
"variants": [{"id": "vb", "title": "Default", "price": {"amount": "2.00", "currencyCode": "USD"}}],
}
service_dir = tmp_path / "bundle" / "services" / "shopify"
service_dir.mkdir(parents=True)
(service_dir / "a_products.json").write_text(json.dumps({"products": {"product-a": product_a}}))
(service_dir / "b_products.json").write_text(json.dumps({"products": {"product-b": product_b}}))
_reset_store_registry(monkeypatch)
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "bundle"))
shopify_state.load_state()
merged = shopify_state.state_to_json()
# One store (flat single-store shape), with BOTH products merged in.
assert "stores" not in merged, "flat per-entity files must merge into ONE store"
assert set(merged["products"]) == {"product-a", "product-b"}
@pytest.fixture(autouse=True)
def _restore_globals(monkeypatch):
"""Leave the shared state module globals clean for other tests."""
yield
shopify_state._stores.clear()
shopify_state._current_state = None
shopify_state._active_store_id = "default"
shopify_state._STATE_FILE = None
@@ -0,0 +1,266 @@
"""Tests for customer management tools."""
import json
import pytest
from shopify import state as shopify_state
from shopify.models import (
CreateCustomerArgs,
GetCustomerArgs,
ListCustomersArgs,
SearchCustomersArgs,
UpdateCustomerArgs,
)
from shopify.tools.customers import (
handle_create_customer,
handle_get_customer,
handle_list_customers,
handle_search_customers,
handle_update_customer,
)
@pytest.fixture
def shopify_data(tmp_path):
"""Seed state with existing customers."""
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {},
"carts": {},
"orders": {},
"customers": {
"cust-1": {
"id": "cust-1",
"firstName": "Alice",
"lastName": "Smith",
"email": "alice@example.com",
"phone": "+1111111111",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"defaultAddress": {"address1": "123 Main St", "city": "Springfield"},
"addresses": [{"address1": "123 Main St", "city": "Springfield"}],
"ordersCount": 5,
"totalSpent": {"amount": "250.00", "currencyCode": "USD"},
"tags": ["vip", "wholesale"],
"note": None,
"acceptsMarketing": True,
"state": "ENABLED",
},
"cust-2": {
"id": "cust-2",
"firstName": "Bob",
"lastName": "Jones",
"email": "bob@example.com",
"phone": "+2222222222",
"createdAt": "2024-02-01T00:00:00Z",
"updatedAt": "2024-02-01T00:00:00Z",
"defaultAddress": None,
"addresses": [],
"ordersCount": 0,
"totalSpent": {"amount": "0.00", "currencyCode": "USD"},
"tags": [],
"note": None,
"acceptsMarketing": False,
"state": "ENABLED",
},
},
"policies": [],
"counters": {
"cart_id": 1000,
"line_id": 1000,
"order_id": 2000,
"line_item_id": 3000,
"customer_id": 4000,
},
}
)
)
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 TestCreateCustomer:
def test_create_customer(self):
result = handle_create_customer(
CreateCustomerArgs(email="carol@example.com", first_name="Carol", last_name="White")
)
assert result["userErrors"] == []
c = result["customer"]
assert c["id"].startswith("gid://shopify/Customer/")
assert c["email"] == "carol@example.com"
assert c["firstName"] == "Carol"
assert c["state"] == "ENABLED"
assert c["ordersCount"] == 0
def test_create_customer_with_address(self):
addr = {"firstName": "Carol", "address1": "456 Oak Ave", "city": "Portland"}
result = handle_create_customer(
CreateCustomerArgs.model_validate({"email": "carol@example.com", "address": addr})
)
c = result["customer"]
assert c["defaultAddress"]["address1"] == "456 Oak Ave"
assert len(c["addresses"]) == 1
def test_create_customer_with_tags(self):
result = handle_create_customer(CreateCustomerArgs(email="carol@example.com", tags=["new", "referral"]))
assert result["customer"]["tags"] == ["new", "referral"]
def test_create_customer_duplicate_email(self):
result = handle_create_customer(CreateCustomerArgs(email="alice@example.com"))
assert result["customer"] is None
assert len(result["userErrors"]) == 1
assert "already exists" in result["userErrors"][0]["message"]
class TestGetCustomer:
def test_get_existing(self):
result = handle_get_customer(GetCustomerArgs(customer_id="cust-1"))
assert result["userErrors"] == []
assert result["customer"]["email"] == "alice@example.com"
def test_get_nonexistent(self):
result = handle_get_customer(GetCustomerArgs(customer_id="nonexistent"))
assert result["customer"] is None
assert len(result["userErrors"]) == 1
class TestListCustomers:
def test_list_all(self):
result = handle_list_customers(ListCustomersArgs())
assert result["totalCount"] == 2
def test_list_with_query(self):
result = handle_list_customers(ListCustomersArgs(query="alice"))
assert result["totalCount"] == 1
assert result["customers"][0]["email"] == "alice@example.com"
def test_list_with_tag(self):
result = handle_list_customers(ListCustomersArgs(tag="vip"))
assert result["totalCount"] == 1
assert result["customers"][0]["id"] == "cust-1"
def test_list_no_match(self):
result = handle_list_customers(ListCustomersArgs(query="zzz"))
assert result["totalCount"] == 0
def test_list_pagination(self):
result = handle_list_customers(ListCustomersArgs(limit=1))
assert len(result["customers"]) == 1
assert result["totalCount"] == 2
assert result["pageInfo"]["hasNextPage"] is True
class TestUpdateCustomer:
def test_update_name(self):
result = handle_update_customer(UpdateCustomerArgs(customer_id="cust-1", first_name="Alicia"))
assert result["customer"]["firstName"] == "Alicia"
assert result["customer"]["lastName"] == "Smith" # unchanged
def test_update_tags(self):
result = handle_update_customer(UpdateCustomerArgs(customer_id="cust-2", tags=["new-tag"]))
assert result["customer"]["tags"] == ["new-tag"]
def test_update_email_rejects_duplicate(self):
result = handle_update_customer(UpdateCustomerArgs(customer_id="cust-2", email="alice@example.com"))
assert result["customer"] is None
assert result["userErrors"][0]["field"] == "email"
assert "already exists" in result["userErrors"][0]["message"]
assert shopify_state.get_state().customers["cust-2"].email == "bob@example.com"
def test_update_email_rejects_duplicate_before_any_mutation(self):
result = handle_update_customer(
UpdateCustomerArgs(
customer_id="cust-2",
first_name="Robert",
last_name="Changed",
email="alice@example.com",
tags=["mutated"],
note="should not stick",
accepts_marketing=True,
)
)
customer = shopify_state.get_state().customers["cust-2"]
assert result["customer"] is None
assert result["userErrors"][0]["field"] == "email"
assert customer.firstName == "Bob"
assert customer.lastName == "Jones"
assert customer.email == "bob@example.com"
assert customer.tags == []
assert customer.note is None
assert customer.acceptsMarketing is False
def test_update_address(self):
addr = {"address1": "789 Elm St", "city": "Denver"}
result = handle_update_customer(UpdateCustomerArgs.model_validate({"customer_id": "cust-2", "address": addr}))
assert result["customer"]["defaultAddress"]["address1"] == "789 Elm St"
assert len(result["customer"]["addresses"]) == 1
def test_update_address_dedupes_existing_default_address(self):
addr = {"address1": "789 Elm St", "city": "Denver"}
args = UpdateCustomerArgs.model_validate({"customer_id": "cust-2", "address": addr})
handle_update_customer(args)
result = handle_update_customer(args)
assert result["customer"]["defaultAddress"]["address1"] == "789 Elm St"
assert len(result["customer"]["addresses"]) == 1
def test_update_nonexistent(self):
result = handle_update_customer(UpdateCustomerArgs(customer_id="nonexistent", first_name="X"))
assert result["customer"] is None
assert len(result["userErrors"]) == 1
class TestSearchCustomers:
def test_search_by_name(self):
result = handle_search_customers(SearchCustomersArgs(query="alice"))
assert result["totalCount"] == 1
assert result["customers"][0]["firstName"] == "Alice"
def test_search_by_email(self):
result = handle_search_customers(SearchCustomersArgs(query="bob@example"))
assert result["totalCount"] == 1
assert result["customers"][0]["firstName"] == "Bob"
def test_search_by_phone(self):
result = handle_search_customers(SearchCustomersArgs(query="+1111"))
assert result["totalCount"] == 1
def test_search_no_match(self):
result = handle_search_customers(SearchCustomersArgs(query="zzzzz"))
assert result["totalCount"] == 0
def test_search_case_insensitive(self):
result = handle_search_customers(SearchCustomersArgs(query="ALICE"))
assert result["totalCount"] == 1
def test_search_word_and_first_and_last(self):
# Multi-word query — all words must appear; "Alice Smith" matches.
result = handle_search_customers(SearchCustomersArgs(query="alice smith"))
assert result["totalCount"] == 1
assert result["customers"][0]["firstName"] == "Alice"
def test_search_misses_when_one_word_absent(self):
# "alice jones" — neither customer has both words present in their record.
result = handle_search_customers(SearchCustomersArgs(query="alice jones"))
assert result["totalCount"] == 0
def test_search_quoted_phrase_requires_adjacency(self):
# Full adjacent phrase hits; reversed phrase misses.
hit = handle_search_customers(SearchCustomersArgs(query='"alice smith"'))
assert hit["totalCount"] == 1
miss = handle_search_customers(SearchCustomersArgs(query='"smith alice"'))
assert miss["totalCount"] == 0
@@ -0,0 +1,225 @@
"""Tests for discount code tools."""
import json
import pytest
from pydantic import ValidationError
from shopify import state as shopify_state
from shopify.models import (
CreateDiscountCodeArgs,
DeleteDiscountCodeArgs,
GetDiscountCodeArgs,
ListDiscountCodesArgs,
UpdateDiscountCodeArgs,
)
from shopify.tools.discounts import (
handle_create_discount_code,
handle_delete_discount_code,
handle_get_discount_code,
handle_list_discount_codes,
handle_update_discount_code,
)
@pytest.fixture
def shopify_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {},
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"discount_codes": {},
"loyalty_program": {
"enabled": True,
"tiers": [
{"name": "Bronze", "min_lifetime_points": 0, "discount_percent": 5},
{"name": "Gold", "min_lifetime_points": 5000, "discount_percent": 15},
],
},
"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,
},
}
)
)
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 TestCreateDiscountCode:
def test_create_percentage(self):
result = handle_create_discount_code(
CreateDiscountCodeArgs(code="SUMMER20", value="20", discount_type="PERCENTAGE")
)
assert result["userErrors"] == []
dc = result["discountCode"]
assert dc["code"] == "SUMMER20"
assert dc["discountType"] == "PERCENTAGE"
assert dc["value"] == "20"
assert dc["active"] is True
assert dc["usageCount"] == 0
def test_create_fixed_amount(self):
result = handle_create_discount_code(
CreateDiscountCodeArgs(code="SAVE10", value="10.00", discount_type="FIXED_AMOUNT")
)
assert result["discountCode"]["discountType"] == "FIXED_AMOUNT"
def test_create_free_shipping(self):
result = handle_create_discount_code(
CreateDiscountCodeArgs(code="FREESHIP", value="0", discount_type="FREE_SHIPPING")
)
assert result["discountCode"]["discountType"] == "FREE_SHIPPING"
def test_create_with_minimum_purchase(self):
result = handle_create_discount_code(CreateDiscountCodeArgs(code="BIG20", value="20", minimum_purchase=50.0))
assert result["discountCode"]["minimumPurchase"]["amount"] == "50.00"
def test_create_with_usage_limit(self):
result = handle_create_discount_code(CreateDiscountCodeArgs(code="LIMITED", value="10", usage_limit=100))
assert result["discountCode"]["usageLimit"] == 100
def test_create_duplicate(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="DUP", value="10"))
result = handle_create_discount_code(CreateDiscountCodeArgs(code="DUP", value="20"))
assert result["discountCode"] is None
assert "already exists" in result["userErrors"][0]["message"]
def test_create_invalid_type(self):
with pytest.raises(ValidationError):
CreateDiscountCodeArgs.model_validate({"code": "BAD", "value": "10", "discount_type": "INVALID"})
def test_code_uppercased(self):
result = handle_create_discount_code(CreateDiscountCodeArgs(code="summer20", value="20"))
assert result["discountCode"]["code"] == "SUMMER20"
def test_create_rejects_missing_product_restriction(self):
result = handle_create_discount_code(CreateDiscountCodeArgs(code="BADPRODUCT", value="10", product_ids=["p1"]))
assert result["discountCode"] is None
assert "Product not found: p1" in result["userErrors"][0]["message"]
def test_create_rejects_missing_minimum_tier(self):
result = handle_create_discount_code(CreateDiscountCodeArgs(code="VIP", value="10", minimum_tier="Platinum"))
assert result["discountCode"] is None
assert result["userErrors"][0]["field"] == "minimum_tier"
def test_create_accepts_configured_minimum_tier(self):
result = handle_create_discount_code(CreateDiscountCodeArgs(code="GOLD", value="10", minimum_tier="Gold"))
assert result["userErrors"] == []
assert result["discountCode"]["minimumTier"] == "Gold"
class TestGetDiscountCode:
def test_get_existing(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST10", value="10"))
result = handle_get_discount_code(GetDiscountCodeArgs(code="TEST10"))
assert result["discountCode"]["code"] == "TEST10"
def test_get_case_insensitive(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST10", value="10"))
result = handle_get_discount_code(GetDiscountCodeArgs(code="test10"))
assert result["discountCode"]["code"] == "TEST10"
def test_get_nonexistent(self):
result = handle_get_discount_code(GetDiscountCodeArgs(code="NOPE"))
assert result["discountCode"] is None
class TestListDiscountCodes:
def test_list_empty(self):
result = handle_list_discount_codes(ListDiscountCodesArgs())
assert result["totalCount"] == 0
def test_list_all(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="A10", value="10"))
handle_create_discount_code(CreateDiscountCodeArgs(code="B20", value="20"))
result = handle_list_discount_codes(ListDiscountCodesArgs())
assert result["totalCount"] == 2
def test_list_active_only(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="ACTIVE", value="10"))
handle_create_discount_code(CreateDiscountCodeArgs(code="INACTIVE", value="20"))
handle_update_discount_code(UpdateDiscountCodeArgs(code="INACTIVE", active=False))
result = handle_list_discount_codes(ListDiscountCodesArgs(active_only=True))
assert result["totalCount"] == 1
assert result["discountCodes"][0]["code"] == "ACTIVE"
class TestUpdateDiscountCode:
def test_deactivate(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST", value="10"))
result = handle_update_discount_code(UpdateDiscountCodeArgs(code="TEST", active=False))
assert result["discountCode"]["active"] is False
def test_update_value(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST", value="10"))
result = handle_update_discount_code(UpdateDiscountCodeArgs(code="TEST", value="25"))
assert result["discountCode"]["value"] == "25"
def test_update_nonexistent(self):
result = handle_update_discount_code(UpdateDiscountCodeArgs(code="NOPE", active=False))
assert result["discountCode"] is None
def test_update_rejects_missing_product_restriction(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST", value="10"))
result = handle_update_discount_code(UpdateDiscountCodeArgs(code="TEST", product_ids=["p1"]))
assert result["discountCode"] is None
assert "Product not found: p1" in result["userErrors"][0]["message"]
def test_update_rejects_missing_minimum_tier(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST", value="10"))
result = handle_update_discount_code(UpdateDiscountCodeArgs(code="TEST", minimum_tier="Platinum"))
assert result["discountCode"] is None
assert result["userErrors"][0]["field"] == "minimum_tier"
def test_update_accepts_configured_minimum_tier(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST", value="10"))
result = handle_update_discount_code(UpdateDiscountCodeArgs(code="TEST", minimum_tier="Gold"))
assert result["userErrors"] == []
assert result["discountCode"]["minimumTier"] == "Gold"
def test_update_clears_minimum_tier_with_empty_string(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TEST", value="10", minimum_tier="Gold"))
result = handle_update_discount_code(UpdateDiscountCodeArgs(code="TEST", minimum_tier=""))
assert result["userErrors"] == []
assert result["discountCode"]["minimumTier"] is None
class TestDeleteDiscountCode:
def test_delete(self):
handle_create_discount_code(CreateDiscountCodeArgs(code="TODELETE", value="10"))
result = handle_delete_discount_code(DeleteDiscountCodeArgs(code="TODELETE"))
assert result["deletedCode"] == "TODELETE"
# Verify gone
get_result = handle_get_discount_code(GetDiscountCodeArgs(code="TODELETE"))
assert get_result["discountCode"] is None
def test_delete_nonexistent(self):
result = handle_delete_discount_code(DeleteDiscountCodeArgs(code="NOPE"))
assert result["deletedCode"] is None
@@ -0,0 +1,352 @@
"""Tests for inventory management and collection tools."""
import json
import pytest
from shopify import state as shopify_state
from shopify.models import (
AddToCollectionArgs,
CreateCollectionArgs,
CreateDiscountCodeArgs,
CreateShippingMethodArgs,
DeleteProductArgs,
GetCollectionArgs,
GetInventoryArgs,
ListCollectionsArgs,
RemoveFromCollectionArgs,
UpdateInventoryArgs,
)
from shopify.tools.catalog import handle_delete_product
from shopify.tools.discounts import handle_create_discount_code
from shopify.tools.inventory_collections import (
handle_add_to_collection,
handle_create_collection,
handle_get_collection,
handle_get_inventory,
handle_list_collections,
handle_remove_from_collection,
handle_update_inventory,
)
from shopify.tools.shipping import handle_create_shipping_method
@pytest.fixture
def shopify_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {
"product-1": {
"id": "product-1",
"title": "Widget",
"handle": "widget",
"availableForSale": True,
"totalInventory": 60,
"variants": [
{
"id": "variant-1a",
"title": "Small",
"price": {"amount": "10.00", "currencyCode": "USD"},
"sku": "WIDGET-S",
"quantityAvailable": 50,
"currentlyNotInStock": False,
"availableForSale": True,
},
{
"id": "variant-1b",
"title": "Large",
"price": {"amount": "20.00", "currencyCode": "USD"},
"sku": "WIDGET-L",
"quantityAvailable": 10,
"currentlyNotInStock": False,
"availableForSale": True,
},
],
},
"product-2": {
"id": "product-2",
"title": "Gadget",
"handle": "gadget",
"availableForSale": True,
"totalInventory": 3,
"variants": [
{
"id": "variant-2a",
"title": "Default",
"price": {"amount": "30.00", "currencyCode": "USD"},
"sku": "GADGET-1",
"quantityAvailable": 3,
"currentlyNotInStock": False,
"availableForSale": True,
},
],
},
},
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"policies": [],
"counters": {
"cart_id": 1000,
"line_id": 1000,
"order_id": 2000,
"line_item_id": 3000,
"customer_id": 4000,
"collection_id": 5000,
},
}
)
)
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()
# ============================================
# INVENTORY TESTS
# ============================================
class TestGetInventory:
def test_get_all_inventory(self):
result = handle_get_inventory(GetInventoryArgs())
assert result["totalCount"] == 3 # 2 variants for product-1, 1 for product-2
def test_get_inventory_by_product(self):
result = handle_get_inventory(GetInventoryArgs(product_id="product-1"))
assert result["totalCount"] == 2
assert all(v["productId"] == "product-1" for v in result["inventory"])
def test_get_inventory_low_stock(self):
result = handle_get_inventory(GetInventoryArgs(low_stock_threshold=5))
assert result["totalCount"] == 1
assert result["inventory"][0]["sku"] == "GADGET-1"
def test_get_inventory_nonexistent_product(self):
result = handle_get_inventory(GetInventoryArgs(product_id="nonexistent"))
assert result["totalCount"] == 0
assert len(result["userErrors"]) == 1
class TestUpdateInventory:
def test_update_quantity(self):
result = handle_update_inventory(UpdateInventoryArgs(variant_id="variant-1a", quantity=100))
assert result["userErrors"] == []
item = result["inventoryItem"]
assert item["previousQuantity"] == 50
assert item["newQuantity"] == 100
def test_update_to_zero_marks_out_of_stock(self):
handle_update_inventory(UpdateInventoryArgs(variant_id="variant-2a", quantity=0))
state = shopify_state.get_state()
variant = state.products["product-2"]["variants"][0]
assert variant["quantityAvailable"] == 0
assert variant["currentlyNotInStock"] is True
assert variant["availableForSale"] is False
def test_update_updates_product_totals(self):
handle_update_inventory(UpdateInventoryArgs(variant_id="variant-1a", quantity=200))
state = shopify_state.get_state()
product = state.products["product-1"]
assert product["totalInventory"] == 210 # 200 + 10
def test_update_nonexistent_variant(self):
result = handle_update_inventory(UpdateInventoryArgs(variant_id="nonexistent", quantity=10))
assert result["inventoryItem"] is None
assert len(result["userErrors"]) == 1
# ============================================
# COLLECTION TESTS
# ============================================
class TestCreateCollection:
def test_create_collection(self):
result = handle_create_collection(CreateCollectionArgs(title="Summer Sale"))
assert result["userErrors"] == []
c = result["collection"]
assert c["id"].startswith("gid://shopify/Collection/")
assert c["title"] == "Summer Sale"
assert c["handle"] == "summer-sale"
assert c["productIds"] == []
def test_create_with_products(self):
result = handle_create_collection(
CreateCollectionArgs(title="Featured", product_ids=["product-1", "product-2"])
)
assert result["collection"]["productIds"] == ["product-1", "product-2"]
def test_create_rejects_title_with_empty_handle(self):
before_counter = shopify_state.get_state().counters.collection_id
result = handle_create_collection(CreateCollectionArgs(title="!!!"))
assert result["collection"] is None
assert result["userErrors"][0]["field"] == "title"
assert shopify_state.get_state().counters.collection_id == before_counter
def test_create_rejects_invalid_products(self):
before_counter = shopify_state.get_state().counters.collection_id
result = handle_create_collection(CreateCollectionArgs(title="Test", product_ids=["product-1", "nonexistent"]))
assert result["collection"] is None
assert "Product not found: nonexistent" in result["userErrors"][0]["message"]
assert shopify_state.get_state().counters.collection_id == before_counter
def test_create_duplicate_title(self):
handle_create_collection(CreateCollectionArgs(title="Sale"))
result = handle_create_collection(CreateCollectionArgs(title="Sale"))
assert result["collection"] is None
assert len(result["userErrors"]) == 1
class TestGetCollection:
def test_get_existing(self):
create_result = handle_create_collection(CreateCollectionArgs(title="Test", product_ids=["product-1"]))
coll_id = create_result["collection"]["id"]
result = handle_get_collection(GetCollectionArgs(collection_id=coll_id))
assert result["userErrors"] == []
assert result["collection"]["title"] == "Test"
assert result["productCount"] == 1
assert result["products"][0]["title"] == "Widget"
def test_get_nonexistent(self):
result = handle_get_collection(GetCollectionArgs(collection_id="nonexistent"))
assert result["collection"] is None
class TestListCollections:
def test_list_empty(self):
result = handle_list_collections(ListCollectionsArgs())
assert result["totalCount"] == 0
def test_list_with_collections(self):
handle_create_collection(CreateCollectionArgs(title="A Collection"))
handle_create_collection(CreateCollectionArgs(title="B Collection"))
result = handle_list_collections(ListCollectionsArgs())
assert result["totalCount"] == 2
# Sorted by title
assert result["collections"][0]["title"] == "A Collection"
def test_list_pagination(self):
handle_create_collection(CreateCollectionArgs(title="One"))
handle_create_collection(CreateCollectionArgs(title="Two"))
result = handle_list_collections(ListCollectionsArgs(limit=1))
assert len(result["collections"]) == 1
assert result["pageInfo"]["hasNextPage"] is True
class TestAddToCollection:
def test_add_products(self):
create_result = handle_create_collection(CreateCollectionArgs(title="Test"))
coll_id = create_result["collection"]["id"]
result = handle_add_to_collection(
AddToCollectionArgs(collection_id=coll_id, product_ids=["product-1", "product-2"])
)
assert result["added"] == ["product-1", "product-2"]
assert len(result["collection"]["productIds"]) == 2
def test_add_already_present(self):
create_result = handle_create_collection(CreateCollectionArgs(title="Test", product_ids=["product-1"]))
coll_id = create_result["collection"]["id"]
result = handle_add_to_collection(AddToCollectionArgs(collection_id=coll_id, product_ids=["product-1"]))
assert result["added"] == []
assert result["alreadyInCollection"] == ["product-1"]
def test_add_nonexistent_product(self):
create_result = handle_create_collection(CreateCollectionArgs(title="Test"))
coll_id = create_result["collection"]["id"]
result = handle_add_to_collection(AddToCollectionArgs(collection_id=coll_id, product_ids=["nonexistent"]))
assert result["added"] == []
assert len(result["userErrors"]) == 1
def test_add_mixed_products_applies_valid_additions_after_scan(self):
create_result = handle_create_collection(CreateCollectionArgs(title="Test"))
coll_id = create_result["collection"]["id"]
result = handle_add_to_collection(
AddToCollectionArgs(collection_id=coll_id, product_ids=["product-1", "nonexistent", "product-2"])
)
assert result["added"] == ["product-1", "product-2"]
assert len(result["userErrors"]) == 1
assert result["collection"]["productIds"] == ["product-1", "product-2"]
def test_add_to_nonexistent_collection(self):
result = handle_add_to_collection(AddToCollectionArgs(collection_id="nonexistent", product_ids=["product-1"]))
assert result["collection"] is None
class TestRemoveFromCollection:
def test_remove_products(self):
create_result = handle_create_collection(
CreateCollectionArgs(title="Test", product_ids=["product-1", "product-2"])
)
coll_id = create_result["collection"]["id"]
result = handle_remove_from_collection(
RemoveFromCollectionArgs(collection_id=coll_id, product_ids=["product-1"])
)
assert result["removed"] == ["product-1"]
assert result["collection"]["productIds"] == ["product-2"]
def test_remove_not_in_collection(self):
create_result = handle_create_collection(CreateCollectionArgs(title="Test"))
coll_id = create_result["collection"]["id"]
result = handle_remove_from_collection(
RemoveFromCollectionArgs(collection_id=coll_id, product_ids=["product-1"])
)
assert result["removed"] == []
assert result["notInCollection"] == ["product-1"]
def test_remove_from_nonexistent_collection(self):
result = handle_remove_from_collection(
RemoveFromCollectionArgs(collection_id="nonexistent", product_ids=["product-1"])
)
assert result["collection"] is None
class TestDeleteProductReferences:
def test_delete_product_cleans_collection_and_discount_references(self):
collection = handle_create_collection(CreateCollectionArgs(title="Test", product_ids=["product-1"]))[
"collection"
]
handle_create_discount_code(
CreateDiscountCodeArgs(
code="PRODUCT10", value="10", discount_type="FIXED_AMOUNT", product_ids=["product-1"]
)
)
state = shopify_state.get_state()
state.collections[collection["id"]].products.append("product-1")
result = handle_delete_product(DeleteProductArgs(product_id="product-1"))
assert result["userErrors"] == []
assert "product-1" not in state.products
assert state.collections[collection["id"]].productIds == []
assert state.collections[collection["id"]].products == []
assert state.discount_codes["gid://shopify/DiscountCode/10001"].productIds is None
class TestShippingMethods:
def test_create_shipping_method_rejects_title_with_empty_id(self):
result = handle_create_shipping_method(CreateShippingMethodArgs(title="!!!", price="5.00"))
assert result["shippingMethod"] is None
assert result["userErrors"][0]["field"] == "title"
@@ -0,0 +1,348 @@
"""Tests for inventory reduction on order creation and restoration on cancel/refund."""
import json
from typing import Any
import pytest
from shopify import state as shopify_state
from shopify.models import (
CancelOrderArgs,
CreateOrderArgs,
CreateReturnArgs,
CreateReturnLineItemInput,
LooseCart,
UpdateOrderArgs,
UpdateReturnArgs,
)
from shopify.tools.orders import handle_cancel_order, handle_create_order, handle_update_order
from shopify.tools.reviews_returns import handle_create_return, handle_update_return
VALID_CC = {"type": "credit_card", "card_number": "4111111111111111", "cvv": "123", "expiry": "12/26"}
VALID_ADDR = {"address1": "1 Main St", "city": "Portland", "countryCode": "US"}
def _order_args(**overrides: Any) -> CreateOrderArgs:
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"),
"email": overrides.get("email"),
}
)
@pytest.fixture
def shopify_data(tmp_path):
"""Seed state with products having tracked inventory and a cart."""
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {
"product-1": {
"id": "product-1",
"title": "Widget",
"handle": "widget",
"availableForSale": True,
"totalInventory": 10,
"variants": [
{
"id": "variant-1",
"title": "Default",
"price": {"amount": "20.00", "currencyCode": "USD"},
"availableForSale": True,
"quantityAvailable": 10,
"currentlyNotInStock": False,
}
],
}
},
"carts": {
"cart-1": {
"id": "cart-1",
"lines": [
{
"id": "line-1",
"quantity": 3,
"merchandise": {
"id": "variant-1",
"title": "Default",
"product": {"id": "product-1", "title": "Widget"},
"price": {"amount": "20.00", "currencyCode": "USD"},
},
"cost": {
"amountPerQuantity": {"amount": "20.00", "currencyCode": "USD"},
"subtotalAmount": {"amount": "60.00", "currencyCode": "USD"},
"totalAmount": {"amount": "60.00", "currencyCode": "USD"},
},
}
],
}
},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"discount_codes": {},
"shipping_methods": {
"standard": {
"id": "standard",
"title": "Standard",
"price": {"amount": "5.00", "currencyCode": "USD"},
"active": True,
}
},
"policies": [],
"counters": {
"cart_id": 1001,
"line_id": 1001,
"order_id": 2001,
"line_item_id": 3001,
"customer_id": 4001,
"return_id": 7001,
},
}
)
)
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()
def _get_variant() -> Any:
_, variant = shopify_state.get_variant_by_id("variant-1")
assert variant is not None
return variant
def _get_product() -> Any:
state = shopify_state.get_state()
return state.products["product-1"]
class TestStockReductionOnOrder:
def test_order_reduces_variant_stock(self):
assert _get_variant()["quantityAvailable"] == 10
result = handle_create_order(_order_args())
assert result["userErrors"] == []
assert _get_variant()["quantityAvailable"] == 7
def test_order_updates_product_total_inventory(self):
handle_create_order(_order_args())
assert _get_product()["totalInventory"] == 7
def test_order_rejects_insufficient_inventory(self):
# Preset stock lower than the 3-unit order
shopify_state.adjust_variant_stock("variant-1", -8) # 10 - 8 = 2
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()) # orders 3 more
assert result["order"] is None
assert "Insufficient inventory" in result["userErrors"][0]["message"]
assert _get_variant()["quantityAvailable"] == 2
assert _get_variant()["currentlyNotInStock"] is False
assert _get_variant()["availableForSale"] is True
state = shopify_state.get_state()
assert state.counters.order_id == before_order_id
assert state.counters.line_item_id == before_line_item_id
def test_failed_order_does_not_reduce_stock(self):
result = handle_create_order(_order_args(cart_id="nonexistent"))
assert result["order"] is None
assert _get_variant()["quantityAvailable"] == 10
class TestStockRestoredOnCancelBeforeShip:
def test_cancel_unfulfilled_order_restores_stock(self):
order = handle_create_order(_order_args())["order"]
assert _get_variant()["quantityAvailable"] == 7
handle_cancel_order(CancelOrderArgs(order_id=order["id"]))
assert _get_variant()["quantityAvailable"] == 10
def test_cancel_fulfilled_order_does_not_restore(self):
order = handle_create_order(_order_args())["order"]
handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="FULFILLED"))
handle_cancel_order(CancelOrderArgs(order_id=order["id"]))
# Stock stays reduced — physical product is out in the wild
assert _get_variant()["quantityAvailable"] == 7
def test_cancel_partially_fulfilled_does_not_restore(self):
order = handle_create_order(_order_args())["order"]
handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="PARTIALLY_FULFILLED"))
handle_cancel_order(CancelOrderArgs(order_id=order["id"]))
assert _get_variant()["quantityAvailable"] == 7
def test_cancel_after_partial_refund_only_restores_unreturned_quantity(self):
order = handle_create_order(_order_args())["order"]
order_line_id = order["lineItems"][0]["id"]
ret = handle_create_return(
CreateReturnArgs(
order_id=order["id"],
line_items=[
CreateReturnLineItemInput(
orderLineItemId=order_line_id,
quantity=1,
reason="changed mind",
)
],
)
)["return"]
handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REFUNDED"))
assert _get_variant()["quantityAvailable"] == 8
handle_cancel_order(CancelOrderArgs(order_id=order["id"]))
assert _get_variant()["quantityAvailable"] == 10
class TestStockRestoredOnRefundBeforeShip:
def test_refund_on_unfulfilled_restores_stock(self):
order = handle_create_order(_order_args())["order"]
assert _get_variant()["quantityAvailable"] == 7
# Create return for 2 of the 3 units
order_line_id = order["lineItems"][0]["id"]
ret = handle_create_return(
CreateReturnArgs(
order_id=order["id"],
line_items=[
CreateReturnLineItemInput(
orderLineItemId=order_line_id,
quantity=2,
reason="changed mind",
)
],
)
)["return"]
# No stock change yet — status is still REQUESTED
assert _get_variant()["quantityAvailable"] == 7
handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REFUNDED"))
assert _get_variant()["quantityAvailable"] == 9
def test_refund_on_fulfilled_does_not_restore(self):
order = handle_create_order(_order_args())["order"]
handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="FULFILLED"))
order_line_id = order["lineItems"][0]["id"]
ret = handle_create_return(
CreateReturnArgs(
order_id=order["id"],
line_items=[
CreateReturnLineItemInput(
orderLineItemId=order_line_id,
quantity=2,
reason="defective",
)
],
)
)["return"]
handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REFUNDED"))
# Stock stays reduced — physical return needed, out of mock scope
assert _get_variant()["quantityAvailable"] == 7
def test_rejected_return_does_not_restore(self):
order = handle_create_order(_order_args())["order"]
order_line_id = order["lineItems"][0]["id"]
ret = handle_create_return(
CreateReturnArgs(
order_id=order["id"],
line_items=[
CreateReturnLineItemInput(
orderLineItemId=order_line_id,
quantity=1,
reason="wrong color",
)
],
)
)["return"]
handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REJECTED"))
assert _get_variant()["quantityAvailable"] == 7
def test_double_refund_restores_only_once(self):
order = handle_create_order(_order_args())["order"]
order_line_id = order["lineItems"][0]["id"]
ret = handle_create_return(
CreateReturnArgs(
order_id=order["id"],
line_items=[
CreateReturnLineItemInput(
orderLineItemId=order_line_id,
quantity=2,
reason="changed mind",
)
],
)
)["return"]
handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REFUNDED"))
handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REFUNDED"))
assert _get_variant()["quantityAvailable"] == 9
class TestTrackingNumber:
def test_new_order_has_no_tracking(self):
order = handle_create_order(_order_args())["order"]
assert order["trackingNumber"] is None
assert order["trackingUrl"] is None
def test_fulfillment_assigns_tracking(self):
order = handle_create_order(_order_args())["order"]
result = handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="FULFILLED"))
updated = result["order"]
assert updated["trackingNumber"] is not None
assert updated["trackingNumber"].startswith("TRK-")
assert updated["trackingUrl"] == f"https://track.example.com/{updated['trackingNumber']}"
def test_partial_fulfillment_assigns_tracking(self):
order = handle_create_order(_order_args())["order"]
result = handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="PARTIALLY_FULFILLED"))
assert result["order"]["trackingNumber"] is not None
def test_tracking_is_stable_after_assignment(self):
order = handle_create_order(_order_args())["order"]
first = handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="PARTIALLY_FULFILLED"))[
"order"
]["trackingNumber"]
# Flipping to FULFILLED after already being (partially) fulfilled keeps the same tracking
second = handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="FULFILLED"))["order"][
"trackingNumber"
]
assert first == second
def test_unfulfilled_status_does_not_assign_tracking(self):
order = handle_create_order(_order_args())["order"]
result = handle_update_order(UpdateOrderArgs(order_id=order["id"], fulfillment_status="UNFULFILLED"))
assert result["order"]["trackingNumber"] is None
def test_tracking_numbers_are_unique_across_orders(self):
# Seed a second cart so we can create two orders
state = shopify_state.get_state()
state.carts["cart-2"] = LooseCart.model_validate(
{
"id": "cart-2",
"lines": state.carts["cart-1"]["lines"],
}
)
shopify_state.save_state()
o1 = handle_create_order(_order_args())["order"]
o2 = handle_create_order(_order_args(cart_id="cart-2"))["order"]
handle_update_order(UpdateOrderArgs(order_id=o1["id"], fulfillment_status="FULFILLED"))
handle_update_order(UpdateOrderArgs(order_id=o2["id"], fulfillment_status="FULFILLED"))
state = shopify_state.get_state()
t1 = state.orders[o1["id"]]["trackingNumber"]
t2 = state.orders[o2["id"]]["trackingNumber"]
assert t1 != t2
@@ -0,0 +1,330 @@
"""LooseProduct / LooseProductOption tolerate minimal synthetic payloads."""
import json
import pytest
from shopify import state as shopify_state
from shopify.models import LooseCart, LooseProduct, LooseProductOption, ShopifyStateModel
def test_loose_product_accepts_minimal_payload_without_handle_or_price_range():
product = LooseProduct.model_validate(
{
"id": "gid://shopify/Product/1",
"title": "Minimal",
"variants": [],
}
)
assert product.handle is None
assert product.priceRange is None
def test_loose_product_option_without_id():
option = LooseProductOption.model_validate({"name": "Color", "values": ["Red", "Blue"]})
assert option.id is None
def test_shopify_state_round_trips_minimal_product():
state = ShopifyStateModel.model_validate(
{
"products": {
"gid://shopify/Product/1": {
"id": "gid://shopify/Product/1",
"title": "Minimal",
"variants": [],
}
}
}
)
reloaded = ShopifyStateModel.model_validate(state.model_dump(mode="json", exclude_none=True))
assert reloaded == state
def test_state_rejects_product_key_mismatch():
with pytest.raises(ValueError, match="products key"):
ShopifyStateModel.model_validate(
{
"products": {
"gid://shopify/Product/wrong": {
"id": "gid://shopify/Product/1",
"title": "Minimal",
"variants": [],
}
}
}
)
def test_state_rejects_collection_missing_product_reference():
with pytest.raises(ValueError, match="references missing product"):
ShopifyStateModel.model_validate(
{
"collections": {
"gid://shopify/Collection/1": {
"id": "gid://shopify/Collection/1",
"title": "Featured",
"productIds": ["gid://shopify/Product/missing"],
}
}
}
)
def test_save_state_rolls_back_invalid_in_memory_state(tmp_path, monkeypatch):
data_file = tmp_path / "shopify_data.json"
monkeypatch.setattr(shopify_state, "_STATE_FILE", data_file)
shopify_state._stores.clear()
shopify_state._current_state = None
shopify_state._last_valid_state_snapshot = None
shopify_state._active_store_id = "default"
shopify_state.state_from_json(
{
"products": {
"gid://shopify/Product/1": {
"id": "gid://shopify/Product/1",
"title": "Valid",
"variants": [],
}
}
}
)
state = shopify_state.get_state()
state.products["gid://shopify/Product/1"].id = "gid://shopify/Product/wrong"
with pytest.raises(ValueError, match="products key"):
shopify_state.save_state()
restored = shopify_state.get_state()
assert restored.products["gid://shopify/Product/1"].id == "gid://shopify/Product/1"
persisted = json.loads(data_file.read_text())
assert persisted["products"]["gid://shopify/Product/1"]["id"] == "gid://shopify/Product/1"
def test_create_cart_validates_before_advancing_counter(tmp_path, monkeypatch):
data_file = tmp_path / "shopify_data.json"
monkeypatch.setattr(shopify_state, "_STATE_FILE", data_file)
shopify_state._stores.clear()
shopify_state._current_state = None
shopify_state._last_valid_state_snapshot = None
shopify_state._active_store_id = "default"
shopify_state.state_from_json({"counters": {"cart_id": 1000}})
def reject_cart(_data):
raise ValueError("invalid cart")
monkeypatch.setattr(LooseCart, "model_validate", reject_cart)
with pytest.raises(ValueError, match="invalid cart"):
shopify_state.create_cart()
state = shopify_state.get_state()
assert state.counters.cart_id == 1000
assert state.carts == {}
def test_state_recovers_counters_from_seeded_ids(tmp_path, monkeypatch):
data_file = tmp_path / "shopify_data.json"
monkeypatch.setattr(shopify_state, "_STATE_FILE", data_file)
shopify_state._stores.clear()
shopify_state._current_state = None
shopify_state._last_valid_state_snapshot = None
shopify_state._active_store_id = "default"
product_id = "gid://shopify/Product/8500"
variant_id = "gid://shopify/ProductVariant/9050-default"
cart_id = "gid://shopify/Cart/c1010"
cart_line_id = "gid://shopify/CartLine/1025"
order_id = "gid://shopify/Order/o2020"
order_line_id = "gid://shopify/OrderLineItem/3030"
customer_id = "gid://shopify/Customer/4040"
collection_id = "gid://shopify/Collection/5050"
review_id = "gid://shopify/Review/6060"
return_id = "gid://shopify/Return/7070"
discount_id = "gid://shopify/DiscountCode/10050"
policy_id = "gid://shopify/ShopPolicy/11050"
money = {"amount": "10.00", "currencyCode": "USD"}
shopify_state.state_from_json(
{
"products": {
product_id: {
"id": product_id,
"title": "Seed Product",
"variants": [
{
"id": variant_id,
"title": "Default",
"price": money,
"availableForSale": True,
}
],
}
},
"carts": {
cart_id: {
"id": cart_id,
"lines": [
{
"id": cart_line_id,
"quantity": 1,
"merchandise": {
"id": variant_id,
"title": "Default",
"price": money,
"product": {"id": product_id, "title": "Seed Product"},
},
"cost": {
"amountPerQuantity": money,
"subtotalAmount": money,
"totalAmount": money,
},
}
],
}
},
"orders": {
order_id: {
"id": order_id,
"name": "#2020",
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"financialStatus": "PAID",
"fulfillmentStatus": "UNFULFILLED",
"lineItems": [
{
"id": order_line_id,
"title": "Seed Product",
"quantity": 1,
"variantId": variant_id,
"productId": product_id,
"price": money,
"totalPrice": money,
}
],
"subtotalPrice": money,
"totalPrice": money,
}
},
"customers": {
customer_id: {
"id": customer_id,
"email": "seed@example.com",
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
}
},
"collections": {
collection_id: {
"id": collection_id,
"title": "Seed Collection",
"productIds": [product_id],
}
},
"reviews": {
review_id: {
"id": review_id,
"productId": product_id,
"rating": 5,
"author": "Seed Reviewer",
}
},
"returns": {
return_id: {
"id": return_id,
"orderId": order_id,
"status": "REQUESTED",
"lineItems": [{"orderLineItemId": order_line_id, "quantity": 1}],
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
}
},
"discount_codes": {
"SAVE10": {
"id": discount_id,
"code": "SAVE10",
"discountType": "FIXED_AMOUNT",
"value": "10.00",
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
}
},
"policies": [{"id": policy_id, "title": "Return Policy", "body": "<p>Returns accepted.</p>"}],
"counters": {
"cart_id": 1000,
"line_id": 1000,
"product_id": 8000,
"variant_id": 9000,
"order_id": 2000,
"line_item_id": 3000,
"customer_id": 4000,
"collection_id": 5000,
"review_id": 6000,
"return_id": 7000,
"discount_id": 10000,
"policy_id": 11000,
},
}
)
counters = shopify_state.get_state().counters
assert counters.cart_id == 1010
assert counters.line_id == 1025
assert counters.product_id == 8500
assert counters.variant_id == 9050
assert counters.order_id == 2020
assert counters.line_item_id == 3030
assert counters.customer_id == 4040
assert counters.collection_id == 5050
assert counters.review_id == 6060
assert counters.return_id == 7070
assert counters.discount_id == 10050
assert counters.policy_id == 11050
def test_state_rejects_duplicate_discount_codes():
discount = {
"discountType": "FIXED_AMOUNT",
"value": "10.00",
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
}
with pytest.raises(ValueError, match="duplicated"):
ShopifyStateModel.model_validate(
{
"discount_codes": {
"gid://shopify/DiscountCode/1": {
**discount,
"id": "gid://shopify/DiscountCode/1",
"code": "SALE",
},
"sale": {
**discount,
"id": "gid://shopify/DiscountCode/2",
"code": "sale",
},
}
}
)
def test_state_rejects_duplicate_gift_card_codes():
with pytest.raises(ValueError, match="duplicated"):
ShopifyStateModel.model_validate(
{
"gift_cards": {
"gid://shopify/GiftCard/1": {
"id": "gid://shopify/GiftCard/1",
"code": "GIFT",
"balance": {"amount": "10.00", "currencyCode": "USD"},
},
"gift": {
"id": "gid://shopify/GiftCard/2",
"code": "gift",
"balance": {"amount": "20.00", "currencyCode": "USD"},
},
}
}
)
@@ -0,0 +1,538 @@
"""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"])
@@ -0,0 +1,262 @@
"""Tests for multi-store support."""
import json
import pytest
from shopify import state as shopify_state
from shopify.models import GetProductDetailsArgs, SearchShopCatalogArgs
from shopify.state import get_all_stores, get_state, set_active_store
from shopify.tools.catalog import handle_get_product_details, handle_search_shop_catalog
@pytest.fixture
def multi_store_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"stores": {
"jacks-hardware": {
"products": {
"prod-h1": {
"id": "prod-h1",
"title": "Hammer",
"description": "A sturdy hammer",
"handle": "hammer",
"productType": "Tools",
"vendor": "Jacks",
"tags": ["tools", "hardware"],
"availableForSale": True,
"priceRange": {
"minVariantPrice": {"amount": "15.00", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "15.00", "currencyCode": "USD"},
},
"variants": [
{
"id": "var-h1",
"title": "Default",
"price": {"amount": "15.00", "currencyCode": "USD"},
"availableForSale": True,
}
],
}
},
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"discount_codes": {},
"shipping_methods": {},
"policies": [],
"counters": {"cart_id": 1000, "line_id": 1000},
},
"jims-tools": {
"products": {
"prod-t1": {
"id": "prod-t1",
"title": "Drill",
"description": "A power drill",
"handle": "drill",
"productType": "Power Tools",
"vendor": "Jims",
"tags": ["tools", "power"],
"availableForSale": True,
"priceRange": {
"minVariantPrice": {"amount": "89.00", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "89.00", "currencyCode": "USD"},
},
"variants": [
{
"id": "var-t1",
"title": "Default",
"price": {"amount": "89.00", "currencyCode": "USD"},
"availableForSale": True,
}
],
}
},
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"discount_codes": {},
"shipping_methods": {},
"policies": [],
"counters": {"cart_id": 1000, "line_id": 1000},
},
}
}
)
)
return data_file
@pytest.fixture(autouse=True)
def _patch_state(multi_store_data, monkeypatch):
monkeypatch.setattr(shopify_state, "_STATE_FILE", multi_store_data)
shopify_state._current_state = None
shopify_state._stores.clear()
shopify_state.load_state()
class TestListStores:
def test_discovers_all_stores(self):
stores = get_all_stores()
assert len(stores) == 2
assert "jacks-hardware" in stores
assert "jims-tools" in stores
class TestStoreIsolation:
def test_products_isolated(self):
set_active_store("jacks-hardware")
jacks = get_state()
assert "prod-h1" in jacks.products
assert "prod-t1" not in jacks.products
set_active_store("jims-tools")
jims = get_state()
assert "prod-t1" in jims.products
assert "prod-h1" not in jims.products
def test_search_scoped_to_store(self):
set_active_store("jacks-hardware")
result = handle_search_shop_catalog(SearchShopCatalogArgs(query="hammer", context="browsing"))
assert result["totalCount"] == 1
assert result["nodes"][0]["title"] == "Hammer"
def test_search_other_store(self):
set_active_store("jims-tools")
result = handle_search_shop_catalog(SearchShopCatalogArgs(query="hammer", context="browsing"))
assert result["totalCount"] == 0
def test_get_product_from_correct_store(self):
set_active_store("jacks-hardware")
result = handle_get_product_details(GetProductDetailsArgs(product_id="prod-h1"))
# The handler returns the product dict or a wrapped response
product = result.get("product", result)
assert product.get("title") == "Hammer" or result.get("id") == "prod-h1"
def test_invalid_store(self):
with pytest.raises(ValueError, match="not found"):
set_active_store("nonexistent")
class TestStoreIdInToolSchemas:
"""Every MCP tool wrapped with ``@_with_store`` must declare ``store_id``
in its signature so FastMCP exposes the parameter in the tool's input
schema. Without that, the agent has no way to pass ``store_id`` and the
wrapper falls back to ``"default"`` — which throws in worlds whose only
stores are named (e.g. jacks-hardware / jims-tools)."""
def test_every_with_store_tool_advertises_store_id(self):
import asyncio
from shopify.server import mcp
tools = asyncio.run(mcp.list_tools())
# Every tool registered against the multi-store helper must expose
# store_id; otherwise an agent in a multi-store world has no way to
# target the right tenant.
missing = []
for tool in tools:
params = (getattr(tool, "parameters", None) or {}).get("properties", {})
# Tools that legitimately operate above the per-store layer:
# list_stores enumerates stores; export/import_state round-trip the
# whole multi-store snapshot. Everything else routes through
# @_with_store and therefore needs store_id exposed.
if tool.name in {"list_stores", "export_state", "import_state"}:
continue
if "store_id" not in params:
missing.append(tool.name)
assert missing == [], f"Tools missing store_id in input schema: {missing}"
class TestWorkspaceSwitch:
"""``set_agent_workspace`` must drop cached stores so the new workspace
actually loads — not just reset the legacy ``_current_state`` ref."""
def test_switching_workspace_loads_new_state(self, tmp_path):
# set_agent_workspace(ws) puts state at ws.parent / "external_services" /
# "shopify_data.json", so each workspace's parent gets its own
# external_services dir.
def _make_ws(name: str, products: dict) -> str:
base = tmp_path / name
ws_dir = base / "agent_workspace"
ws_dir.mkdir(parents=True)
ext = base / "external_services"
ext.mkdir(parents=True)
(ext / "shopify_data.json").write_text(
json.dumps(
{
"products": products,
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"discount_codes": {},
"shipping_methods": {},
"policies": [],
"counters": {"cart_id": 1000, "line_id": 1000},
}
)
)
return str(ws_dir)
ws_a = _make_ws("a", {"prod-a": {"id": "prod-a", "title": "From A"}})
ws_b = _make_ws("b", {"prod-b": {"id": "prod-b", "title": "From B"}})
# Reset so the autouse fixture's load doesn't shadow.
shopify_state._stores.clear()
shopify_state._current_state = None
shopify_state.set_agent_workspace(ws_a)
shopify_state.load_state()
assert "prod-a" in shopify_state.get_state().products
shopify_state.set_agent_workspace(ws_b)
shopify_state.load_state()
# Without clearing _stores in set_agent_workspace, load_state's
# `if _stores: return` early-exit would still serve workspace A's data.
loaded = shopify_state.get_state().products
assert "prod-b" in loaded
assert "prod-a" not in loaded
class TestBackwardCompat:
def test_single_store_flat_format(self, tmp_path, monkeypatch):
"""Single-store flat format should load as 'default'."""
flat_file = tmp_path / "flat.json"
flat_file.write_text(
json.dumps(
{
"products": {"p1": {"id": "p1", "title": "Widget", "variants": []}},
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"discount_codes": {},
"shipping_methods": {},
"policies": [],
"counters": {"cart_id": 1000},
}
)
)
monkeypatch.setattr(shopify_state, "_STATE_FILE", flat_file)
shopify_state._current_state = None
shopify_state._stores.clear()
shopify_state.load_state()
stores = get_all_stores()
assert "default" in stores
assert len(stores) == 1
assert "p1" in stores["default"].products
@@ -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
@@ -0,0 +1,124 @@
"""Tests for store policy tools."""
import json
import pytest
from shopify import state as shopify_state
from shopify.models import (
CreatePolicyArgs,
DeletePolicyArgs,
ListPoliciesArgs,
UpdatePolicyArgs,
)
from shopify.tools.policies import (
handle_create_policy,
handle_delete_policy,
handle_list_policies,
handle_update_policy,
)
@pytest.fixture
def shopify_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {},
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"discount_codes": {},
"policies": [
{
"id": "gid://shopify/ShopPolicy/1",
"title": "Return Policy",
"body": "<p>30-day returns on all items.</p>",
"url": "https://shop.example.com/policies/1",
}
],
"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):
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 TestCreatePolicy:
def test_create(self):
result = handle_create_policy(CreatePolicyArgs(title="Shipping Policy", body="<p>Free shipping over $50.</p>"))
assert result["userErrors"] == []
p = result["policy"]
assert p["title"] == "Shipping Policy"
assert "Free shipping" in p["body"]
assert p["id"].startswith("gid://shopify/ShopPolicy/")
def test_create_adds_to_state(self):
handle_create_policy(CreatePolicyArgs(title="Privacy Policy", body="<p>We respect privacy.</p>"))
state = shopify_state.get_state()
assert len(state.policies) == 2
class TestListPolicies:
def test_list(self):
result = handle_list_policies(ListPoliciesArgs())
assert result["totalCount"] == 1
assert result["policies"][0]["title"] == "Return Policy"
class TestUpdatePolicy:
def test_update_title(self):
result = handle_update_policy(
UpdatePolicyArgs(policy_id="gid://shopify/ShopPolicy/1", title="Updated Return Policy")
)
assert result["userErrors"] == []
assert result["policy"]["title"] == "Updated Return Policy"
def test_update_body(self):
result = handle_update_policy(
UpdatePolicyArgs(policy_id="gid://shopify/ShopPolicy/1", body="<p>60-day returns.</p>")
)
assert "60-day" in result["policy"]["body"]
def test_update_nonexistent(self):
result = handle_update_policy(UpdatePolicyArgs(policy_id="nonexistent", title="X"))
assert result["policy"] is None
assert len(result["userErrors"]) == 1
class TestDeletePolicy:
def test_delete(self):
result = handle_delete_policy(DeletePolicyArgs(policy_id="gid://shopify/ShopPolicy/1"))
assert result["deletedPolicyId"] == "gid://shopify/ShopPolicy/1"
state = shopify_state.get_state()
assert len(state.policies) == 0
def test_delete_nonexistent(self):
result = handle_delete_policy(DeletePolicyArgs(policy_id="nonexistent"))
assert result["deletedPolicyId"] is None
@@ -0,0 +1,259 @@
"""Tests for product management tools."""
import json
import pytest
from shopify import state as shopify_state
from shopify.models import (
CreateProductArgs,
DeleteProductArgs,
GetProductDetailsArgs,
LooseCart,
UpdateProductArgs,
)
from shopify.tools.catalog import (
handle_create_product,
handle_delete_product,
handle_get_product_details,
handle_update_product,
)
@pytest.fixture
def shopify_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {
"product-1": {
"id": "product-1",
"title": "Existing Widget",
"handle": "existing-widget",
"description": "A widget",
"productType": "Gadgets",
"vendor": "WidgetCo",
"tags": ["widget"],
"availableForSale": True,
"priceRange": {
"minVariantPrice": {"amount": "10.00", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "10.00", "currencyCode": "USD"},
},
"variants": [
{
"id": "variant-1",
"title": "Default",
"price": {"amount": "10.00", "currencyCode": "USD"},
"availableForSale": True,
"quantityAvailable": 50,
}
],
}
},
"carts": {},
"orders": {},
"customers": {},
"collections": {
"coll-1": {
"id": "coll-1",
"title": "Featured",
"productIds": ["product-1"],
}
},
"reviews": {
"rev-1": {
"id": "rev-1",
"productId": "product-1",
"rating": 5,
"author": "Alice",
}
},
"returns": {},
"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,
},
}
)
)
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 TestCreateProduct:
def test_create_basic_product(self):
result = handle_create_product(
CreateProductArgs(title="New Gadget", description="A new gadget", vendor="GadgetCo")
)
assert result["userErrors"] == []
p = result["product"]
assert p["id"].startswith("gid://shopify/Product/")
assert p["title"] == "New Gadget"
assert p["handle"] == "new-gadget"
assert p["vendor"] == "GadgetCo"
assert len(p["variants"]) == 1 # default variant
def test_create_with_variants(self):
result = handle_create_product(
CreateProductArgs.model_validate(
{
"title": "T-Shirt",
"variants": [
{"title": "Small", "price": "25.00", "sku": "TS-S", "quantityAvailable": 100},
{"title": "Large", "price": "25.00", "sku": "TS-L", "quantityAvailable": 50},
],
}
)
)
p = result["product"]
assert len(p["variants"]) == 2
assert p["variants"][0]["sku"] == "TS-S"
assert p["priceRange"]["minVariantPrice"]["amount"] == "25.00"
assert p["totalInventory"] == 150
def test_create_with_tags(self):
result = handle_create_product(CreateProductArgs(title="Tagged Item", tags=["sale", "new-arrival"]))
assert result["product"]["tags"] == ["sale", "new-arrival"]
def test_create_rejects_title_with_empty_handle(self):
result = handle_create_product(CreateProductArgs(title="!!!"))
assert result["product"] is None
assert result["userErrors"][0]["field"] == "title"
def test_create_rejects_duplicate_handle(self):
result = handle_create_product(CreateProductArgs(title="Existing Widget"))
assert result["product"] is None
assert result["userErrors"][0]["field"] == "title"
assert "already exists" in result["userErrors"][0]["message"]
def test_create_product_in_state(self):
result = handle_create_product(CreateProductArgs(title="Stored"))
pid = result["product"]["id"]
state = shopify_state.get_state()
assert pid in state.products
class TestUpdateProduct:
def test_update_title(self):
result = handle_update_product(UpdateProductArgs(product_id="product-1", title="Updated Widget"))
assert result["userErrors"] == []
assert result["product"]["title"] == "Updated Widget"
assert result["product"]["handle"] == "updated-widget"
def test_update_rejects_title_with_empty_handle(self):
result = handle_update_product(UpdateProductArgs(product_id="product-1", title="!!!"))
assert result["product"] is None
assert result["userErrors"][0]["field"] == "title"
assert shopify_state.get_state().products["product-1"].title == "Existing Widget"
def test_update_rejects_duplicate_handle(self):
handle_create_product(CreateProductArgs(title="New Gadget"))
result = handle_update_product(UpdateProductArgs(product_id="product-1", title="New Gadget"))
assert result["product"] is None
assert result["userErrors"][0]["field"] == "title"
assert "already exists" in result["userErrors"][0]["message"]
assert shopify_state.get_state().products["product-1"].handle == "existing-widget"
def test_update_description(self):
result = handle_update_product(UpdateProductArgs(product_id="product-1", description="New description"))
assert result["product"]["description"] == "New description"
def test_update_tags(self):
result = handle_update_product(UpdateProductArgs(product_id="product-1", tags=["new-tag", "updated"]))
assert result["product"]["tags"] == ["new-tag", "updated"]
def test_update_nonexistent(self):
result = handle_update_product(UpdateProductArgs(product_id="nonexistent", title="X"))
assert result["product"] is None
assert len(result["userErrors"]) == 1
class TestGetProductDetails:
def test_country_and_language_are_reported_as_noop_hints(self):
result = handle_get_product_details(GetProductDetailsArgs(product_id="product-1", country="US", language="EN"))
assert result["product"] is not None
assert result["localization"] == {"country": "US", "language": "EN", "applied": False}
class TestDeleteProduct:
def test_delete_product(self):
result = handle_delete_product(DeleteProductArgs(product_id="product-1"))
assert result["deletedProductId"] == "product-1"
state = shopify_state.get_state()
assert "product-1" not in state.products
def test_delete_removes_from_collections(self):
handle_delete_product(DeleteProductArgs(product_id="product-1"))
state = shopify_state.get_state()
assert "product-1" not in state.collections["coll-1"]["productIds"]
def test_delete_removes_reviews(self):
handle_delete_product(DeleteProductArgs(product_id="product-1"))
state = shopify_state.get_state()
assert "rev-1" not in state.reviews
def test_delete_removes_deleted_variant_from_open_carts(self):
state = shopify_state.get_state()
state.carts["cart-1"] = LooseCart.model_validate(
{
"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": "Default",
"product": {"id": "product-1", "title": "Existing Widget"},
"price": {"amount": "10.00", "currencyCode": "USD"},
},
"cost": {
"amountPerQuantity": {"amount": "10.00", "currencyCode": "USD"},
"subtotalAmount": {"amount": "20.00", "currencyCode": "USD"},
"totalAmount": {"amount": "20.00", "currencyCode": "USD"},
},
}
],
"cost": {
"subtotalAmount": {"amount": "20.00", "currencyCode": "USD"},
"totalAmount": {"amount": "20.00", "currencyCode": "USD"},
"checkoutChargeAmount": {"amount": "20.00", "currencyCode": "USD"},
},
"buyerIdentity": {},
"totalQuantity": 2,
}
)
handle_delete_product(DeleteProductArgs(product_id="product-1"))
cart = shopify_state.get_state().carts["cart-1"]
assert cart.lines == []
assert cart.totalQuantity == 0
assert cart.cost is not None
assert cart.cost.totalAmount.amount == "0.00"
def test_delete_nonexistent(self):
result = handle_delete_product(DeleteProductArgs(product_id="nonexistent"))
assert result["deletedProductId"] is None
assert len(result["userErrors"]) == 1
@@ -0,0 +1,591 @@
"""Tests for returns and refunds tools."""
import json
import pytest
from pydantic import ValidationError
from shopify import state as shopify_state
from shopify.models import (
CreateOrderArgs,
CreateReturnArgs,
GetReturnArgs,
ListReturnsArgs,
LooseCustomer,
LooseDiscountCode,
LooseGiftCard,
MoneyV2,
UpdateCartArgs,
UpdateReturnArgs,
)
from shopify.tools.cart import handle_update_cart
from shopify.tools.orders import handle_create_order
from shopify.tools.reviews_returns import (
handle_create_return,
handle_get_return,
handle_list_returns,
handle_update_return,
)
def _return_args(**data: object) -> CreateReturnArgs:
return CreateReturnArgs.model_validate(data)
@pytest.fixture
def shopify_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {
"product-1": {
"id": "product-1",
"title": "Widget",
"variants": [
{
"id": "variant-1",
"title": "Default",
"price": {"amount": "25.00", "currencyCode": "USD"},
"availableForSale": True,
}
],
},
},
"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": 3,
"merchandise": {
"id": "variant-1",
"title": "Default",
"product": {"id": "product-1", "title": "Widget"},
"price": {"amount": "25.00", "currencyCode": "USD"},
},
"cost": {
"amountPerQuantity": {"amount": "25.00", "currencyCode": "USD"},
"subtotalAmount": {"amount": "75.00", "currencyCode": "USD"},
"totalAmount": {"amount": "75.00", "currencyCode": "USD"},
},
"attributes": [],
"discountAllocations": [],
}
],
"cost": {
"subtotalAmount": {"amount": "75.00", "currencyCode": "USD"},
"totalAmount": {"amount": "75.00", "currencyCode": "USD"},
"checkoutChargeAmount": {"amount": "75.00", "currencyCode": "USD"},
},
"buyerIdentity": {"email": "alice@example.com"},
"totalQuantity": 3,
},
},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"returns": {},
"shipping_methods": {
"standard": {
"id": "standard",
"title": "Standard Shipping",
"price": {"amount": "5.99", "currencyCode": "USD"},
"estimatedDays": "5-7 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,
},
}
)
)
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()
@pytest.fixture
def order_with_items():
"""Create an order to test returns against."""
result = handle_create_order(
CreateOrderArgs.model_validate(
{
"cart_id": "cart-1",
"payment_method": {
"type": "credit_card",
"card_number": "4111111111111111",
"cvv": "123",
"expiry": "12/26",
},
"shipping_address": {"address1": "123 Main St", "city": "Springfield", "countryCode": "US"},
"billing_address": {"address1": "123 Main St", "city": "Springfield", "countryCode": "US"},
"shipping_method_id": "standard",
}
)
)
return result["order"]
class TestCreateReturn:
def test_create_return(self, order_with_items):
order = order_with_items
line_item_id = order["lineItems"][0]["id"]
result = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 1, "reason": "defective"}],
reason="Item was defective",
)
)
assert result["userErrors"] == []
ret = result["return"]
assert ret["id"].startswith("gid://shopify/Return/")
assert ret["status"] == "REQUESTED"
assert ret["orderId"] == order["id"]
assert len(ret["lineItems"]) == 1
assert ret["lineItems"][0]["quantity"] == 1
assert ret["refundAmount"]["amount"] == "25.00"
def test_create_partial_return(self, order_with_items):
order = order_with_items
line_item_id = order["lineItems"][0]["id"]
result = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 2}],
)
)
ret = result["return"]
assert ret["refundAmount"]["amount"] == "50.00" # 2 * 25.00
def test_create_return_uses_discounted_effective_line_price(self, order_with_items):
order = order_with_items
state = shopify_state.get_state()
state.orders[order["id"]].totalPrice = MoneyV2(amount="60.74", currencyCode="USD")
line_item_id = order["lineItems"][0]["id"]
result = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 1}],
)
)
assert result["return"]["refundAmount"]["amount"] == "18.25"
def test_create_return_nonexistent_order(self):
result = handle_create_return(
_return_args(
order_id="nonexistent",
line_items=[{"orderLineItemId": "x", "quantity": 1}],
)
)
assert result["return"] is None
assert len(result["userErrors"]) == 1
def test_create_return_cancelled_order(self, order_with_items):
order = order_with_items
# Cancel the order first
from shopify.models import CancelOrderArgs
from shopify.tools.orders import handle_cancel_order
handle_cancel_order(CancelOrderArgs(order_id=order["id"]))
result = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)
assert result["return"] is None
assert "cancelled" in result["userErrors"][0]["message"]
def test_create_return_invalid_line_item(self, order_with_items):
result = handle_create_return(
_return_args(
order_id=order_with_items["id"],
line_items=[{"orderLineItemId": "nonexistent", "quantity": 1}],
)
)
assert result["return"] is None
assert len(result["userErrors"]) == 1
def test_create_return_rejects_quantity_above_ordered(self, order_with_items):
order = order_with_items
line_item_id = order["lineItems"][0]["id"]
result = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 4}],
)
)
assert result["return"] is None
assert "remaining returnable quantity" in result["userErrors"][0]["message"]
def test_create_return_rejects_cumulative_over_return(self, order_with_items):
order = order_with_items
line_item_id = order["lineItems"][0]["id"]
handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 2}],
)
)
result = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 2}],
)
)
assert result["return"] is None
assert "remaining returnable quantity 1" in result["userErrors"][0]["message"]
def test_rejected_returns_do_not_consume_returnable_quantity(self, order_with_items):
order = order_with_items
line_item_id = order["lineItems"][0]["id"]
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 3}],
)
)
handle_update_return(UpdateReturnArgs(return_id=create["return"]["id"], status="REJECTED"))
result = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": line_item_id, "quantity": 3}],
)
)
assert result["userErrors"] == []
class TestGetReturn:
def test_get_existing(self, order_with_items):
order = order_with_items
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)
return_id = create["return"]["id"]
result = handle_get_return(GetReturnArgs(return_id=return_id))
assert result["userErrors"] == []
assert result["return"]["id"] == return_id
def test_get_nonexistent(self):
result = handle_get_return(GetReturnArgs(return_id="nonexistent"))
assert result["return"] is None
class TestListReturns:
def test_list_empty(self):
result = handle_list_returns(ListReturnsArgs())
assert result["totalCount"] == 0
def test_list_with_returns(self, order_with_items):
order = order_with_items
li_id = order["lineItems"][0]["id"]
handle_create_return(_return_args(order_id=order["id"], line_items=[{"orderLineItemId": li_id, "quantity": 1}]))
result = handle_list_returns(ListReturnsArgs())
assert result["totalCount"] == 1
def test_list_filter_by_order(self, order_with_items):
order = order_with_items
li_id = order["lineItems"][0]["id"]
handle_create_return(_return_args(order_id=order["id"], line_items=[{"orderLineItemId": li_id, "quantity": 1}]))
result = handle_list_returns(ListReturnsArgs(order_id=order["id"]))
assert result["totalCount"] == 1
result = handle_list_returns(ListReturnsArgs(order_id="other-order"))
assert result["totalCount"] == 0
def test_list_filter_by_status(self, order_with_items):
order = order_with_items
li_id = order["lineItems"][0]["id"]
handle_create_return(_return_args(order_id=order["id"], line_items=[{"orderLineItemId": li_id, "quantity": 1}]))
result = handle_list_returns(ListReturnsArgs(status="REQUESTED"))
assert result["totalCount"] == 1
result = handle_list_returns(ListReturnsArgs(status="APPROVED"))
assert result["totalCount"] == 0
class TestUpdateReturn:
def test_update_status(self, order_with_items):
order = order_with_items
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)
return_id = create["return"]["id"]
result = handle_update_return(UpdateReturnArgs(return_id=return_id, status="APPROVED"))
assert result["return"]["status"] == "APPROVED"
def test_update_to_refunded_updates_order(self, order_with_items):
order = order_with_items
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)
return_id = create["return"]["id"]
# Partial return (25 of 75) → PARTIALLY_REFUNDED
handle_update_return(UpdateReturnArgs(return_id=return_id, status="REFUNDED"))
from shopify.models import GetOrderArgs
from shopify.tools.orders import handle_get_order
order_result = handle_get_order(GetOrderArgs(order_id=order["id"]))
assert order_result["order"]["financialStatus"] == "PARTIALLY_REFUNDED"
def test_partial_refund_reverses_proportional_checkout_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,
}
)
state.gift_cards["GIFT1234"] = LooseGiftCard.model_validate(
{
"id": "gid://shopify/GiftCard/GIFT1234",
"code": "GIFT1234",
"balance": {"amount": "20.00", "currencyCode": "USD"},
"initialValue": {"amount": "20.00", "currencyCode": "USD"},
"active": True,
}
)
handle_update_cart(UpdateCartArgs(cart_id="cart-1", gift_card_codes=["GIFT1234"]))
order = handle_create_order(
CreateOrderArgs.model_validate(
{
"cart_id": "cart-1",
"payment_method": {
"type": "credit_card",
"card_number": "4111111111111111",
"cvv": "123",
"expiry": "12/26",
},
"shipping_address": {"address1": "123 Main St", "city": "Springfield", "countryCode": "US"},
"billing_address": {"address1": "123 Main St", "city": "Springfield", "countryCode": "US"},
"shipping_method_id": "standard",
"email": "alice@example.com",
"redeem_points": 100,
}
)
)["order"]
ret = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)["return"]
result = handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REFUNDED"))
customer = state.customers["cust-1"]
assert result["userErrors"] == []
assert result["return"]["reversedEffects"]["giftCardAmount"] == "6.17"
assert result["return"]["reversedEffects"]["customerSpendAmount"] == "18.50"
assert state.gift_cards["GIFT1234"].balance.amount == "6.17"
assert customer.totalSpent is not None
assert customer.totalSpent.amount == "41.49"
assert customer.ordersCount == 1
assert customer.pointsBalance == 983
assert customer.lifetimePoints == 1050
def test_full_refund_updates_order(self, order_with_items):
order = order_with_items
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 3}],
)
)
return_id = create["return"]["id"]
# Full item return (75 of 80.99 total including shipping) → PARTIALLY_REFUNDED
# Returning all items doesn't refund shipping cost
handle_update_return(UpdateReturnArgs(return_id=return_id, status="REFUNDED"))
from shopify.models import GetOrderArgs
from shopify.tools.orders import handle_get_order
order_result = handle_get_order(GetOrderArgs(order_id=order["id"]))
assert order_result["order"]["financialStatus"] == "PARTIALLY_REFUNDED"
def test_cumulative_partial_refunds_can_fully_refund_order(self, order_with_items):
order = order_with_items
line_item_id = order["lineItems"][0]["id"]
first = handle_create_return(
_return_args(order_id=order["id"], line_items=[{"orderLineItemId": line_item_id, "quantity": 1}])
)["return"]
second = handle_create_return(
_return_args(order_id=order["id"], line_items=[{"orderLineItemId": line_item_id, "quantity": 2}])
)["return"]
state = shopify_state.get_state()
state.returns[first["id"]].refundAmount = MoneyV2(amount="40.00", currencyCode="USD")
state.returns[second["id"]].refundAmount = MoneyV2(amount="40.99", currencyCode="USD")
handle_update_return(UpdateReturnArgs(return_id=first["id"], status="REFUNDED"))
handle_update_return(UpdateReturnArgs(return_id=second["id"], status="REFUNDED"))
from shopify.models import GetOrderArgs
from shopify.tools.orders import handle_get_order
order_result = handle_get_order(GetOrderArgs(order_id=order["id"]))
assert order_result["order"]["financialStatus"] == "REFUNDED"
def test_full_refund_reverses_checkout_side_effects(self):
state = shopify_state.get_state()
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"},
}
)
state.discount_codes["SAVE5"] = LooseDiscountCode.model_validate(
{
"id": "SAVE5",
"code": "SAVE5",
"discountType": "FIXED_AMOUNT",
"value": "5.00",
"usageCount": 0,
"active": True,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
)
order = handle_create_order(
CreateOrderArgs.model_validate(
{
"cart_id": "cart-1",
"payment_method": {
"type": "credit_card",
"card_number": "4111111111111111",
"cvv": "123",
"expiry": "12/26",
},
"shipping_address": {"address1": "123 Main St", "city": "Springfield", "countryCode": "US"},
"billing_address": {"address1": "123 Main St", "city": "Springfield", "countryCode": "US"},
"shipping_method_id": "standard",
"discount_code": "SAVE5",
"email": "alice@example.com",
}
)
)["order"]
line_item_id = order["lineItems"][0]["id"]
ret = handle_create_return(
_return_args(order_id=order["id"], line_items=[{"orderLineItemId": line_item_id, "quantity": 3}])
)["return"]
state.returns[ret["id"]].refundAmount = MoneyV2(
amount=order["totalPrice"]["amount"],
currencyCode=order["totalPrice"]["currencyCode"],
)
result = handle_update_return(UpdateReturnArgs(return_id=ret["id"], status="REFUNDED"))
customer = state.customers["cust-1"]
assert result["userErrors"] == []
assert state.orders[order["id"]]["sideEffectsReversedAt"] is not None
assert state.discount_codes["SAVE5"].usageCount == 0
assert customer.ordersCount == 0
assert customer.totalSpent is not None
assert customer.totalSpent.amount == "0.00"
def test_update_invalid_status(self, order_with_items):
order = order_with_items
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)
return_id = create["return"]["id"]
with pytest.raises(ValidationError):
UpdateReturnArgs.model_validate({"return_id": return_id, "status": "INVALID"})
def test_update_nonexistent(self):
result = handle_update_return(UpdateReturnArgs(return_id="nonexistent", status="APPROVED"))
assert result["return"] is None
def test_update_note(self, order_with_items):
order = order_with_items
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)
return_id = create["return"]["id"]
result = handle_update_return(UpdateReturnArgs(return_id=return_id, note="Customer called"))
assert result["return"]["note"] == "Customer called"
def test_refunded_return_is_terminal(self, order_with_items):
order = order_with_items
create = handle_create_return(
_return_args(
order_id=order["id"],
line_items=[{"orderLineItemId": order["lineItems"][0]["id"], "quantity": 1}],
)
)
return_id = create["return"]["id"]
handle_update_return(UpdateReturnArgs(return_id=return_id, status="REFUNDED"))
result = handle_update_return(UpdateReturnArgs(return_id=return_id, status="APPROVED"))
assert result["return"] is None
assert "Cannot transition" in result["userErrors"][0]["message"]
@@ -0,0 +1,165 @@
"""Tests for product review tools."""
import json
import pytest
from pydantic import ValidationError
from shopify import state as shopify_state
from shopify.models import (
CreateReviewArgs,
DeleteReviewArgs,
GetProductReviewsArgs,
UpdateReviewArgs,
)
from shopify.tools.reviews_returns import (
handle_create_review,
handle_delete_review,
handle_get_product_reviews,
handle_update_review,
)
@pytest.fixture
def shopify_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {
"product-1": {
"id": "product-1",
"title": "Widget",
"variants": [],
},
},
"carts": {},
"orders": {},
"customers": {},
"collections": {},
"reviews": {},
"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 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 TestCreateReview:
def test_create_review(self):
result = handle_create_review(
CreateReviewArgs(product_id="product-1", rating=5, author="Alice", title="Great!", body="Love it")
)
assert result["userErrors"] == []
r = result["review"]
assert r["id"].startswith("gid://shopify/Review/")
assert r["rating"] == 5
assert r["author"] == "Alice"
assert r["status"] == "PUBLISHED"
def test_create_review_nonexistent_product(self):
result = handle_create_review(CreateReviewArgs(product_id="nonexistent", rating=3, author="Bob"))
assert result["review"] is None
assert len(result["userErrors"]) == 1
def test_create_multiple_reviews(self):
handle_create_review(CreateReviewArgs(product_id="product-1", rating=5, author="Alice"))
handle_create_review(CreateReviewArgs(product_id="product-1", rating=3, author="Bob"))
reviews = handle_get_product_reviews(GetProductReviewsArgs(product_id="product-1"))
assert reviews["totalCount"] == 2
class TestGetProductReviews:
def test_get_reviews_empty(self):
result = handle_get_product_reviews(GetProductReviewsArgs(product_id="product-1"))
assert result["totalCount"] == 0
assert result["averageRating"] is None
def test_get_reviews_with_average(self):
handle_create_review(CreateReviewArgs(product_id="product-1", rating=5, author="A"))
handle_create_review(CreateReviewArgs(product_id="product-1", rating=3, author="B"))
result = handle_get_product_reviews(GetProductReviewsArgs(product_id="product-1"))
assert result["totalCount"] == 2
assert result["averageRating"] == 4.0
def test_get_reviews_filter_by_status(self):
r1 = handle_create_review(CreateReviewArgs(product_id="product-1", rating=5, author="A"))
handle_update_review(UpdateReviewArgs(review_id=r1["review"]["id"], status="HIDDEN"))
handle_create_review(CreateReviewArgs(product_id="product-1", rating=3, author="B"))
result = handle_get_product_reviews(GetProductReviewsArgs(product_id="product-1", status="PUBLISHED"))
assert result["totalCount"] == 1
def test_get_reviews_nonexistent_product(self):
result = handle_get_product_reviews(GetProductReviewsArgs(product_id="nonexistent"))
assert len(result["userErrors"]) == 1
def test_get_reviews_pagination(self):
handle_create_review(CreateReviewArgs(product_id="product-1", rating=5, author="A"))
handle_create_review(CreateReviewArgs(product_id="product-1", rating=4, author="B"))
result = handle_get_product_reviews(GetProductReviewsArgs(product_id="product-1", limit=1))
assert len(result["reviews"]) == 1
assert result["pageInfo"]["hasNextPage"] is True
class TestUpdateReview:
def test_update_status(self):
create = handle_create_review(CreateReviewArgs(product_id="product-1", rating=5, author="A"))
rid = create["review"]["id"]
result = handle_update_review(UpdateReviewArgs(review_id=rid, status="HIDDEN"))
assert result["review"]["status"] == "HIDDEN"
def test_update_rating(self):
create = handle_create_review(CreateReviewArgs(product_id="product-1", rating=3, author="A"))
rid = create["review"]["id"]
result = handle_update_review(UpdateReviewArgs(review_id=rid, rating=5))
assert result["review"]["rating"] == 5
def test_update_invalid_status(self):
create = handle_create_review(CreateReviewArgs(product_id="product-1", rating=5, author="A"))
rid = create["review"]["id"]
with pytest.raises(ValidationError):
UpdateReviewArgs.model_validate({"review_id": rid, "status": "INVALID"})
def test_update_nonexistent(self):
result = handle_update_review(UpdateReviewArgs(review_id="nonexistent"))
assert result["review"] is None
class TestDeleteReview:
def test_delete_review(self):
create = handle_create_review(CreateReviewArgs(product_id="product-1", rating=5, author="A"))
rid = create["review"]["id"]
result = handle_delete_review(DeleteReviewArgs(review_id=rid))
assert result["deletedReviewId"] == rid
reviews = handle_get_product_reviews(GetProductReviewsArgs(product_id="product-1"))
assert reviews["totalCount"] == 0
def test_delete_nonexistent(self):
result = handle_delete_review(DeleteReviewArgs(review_id="nonexistent"))
assert result["deletedReviewId"] is None
assert len(result["userErrors"]) == 1
@@ -0,0 +1,424 @@
"""Tests for word-AND + quoted-phrase search semantics across Shopify search tools."""
import json
import pytest
from pydantic import ValidationError
from shopify import state as shopify_state
from shopify.models import (
CategoryFilter,
PriceFilter,
SearchFilter,
SearchShopCatalogArgs,
SearchShopPoliciesAndFaqsArgs,
VariantOptionFilter,
)
from shopify.state import search_faqs, search_policies, search_products
from shopify.tools.catalog import handle_search_shop_catalog, handle_search_shop_policies_and_faqs
@pytest.fixture
def shopify_data(tmp_path):
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {
"p1": {
"id": "p1",
"title": "Wireless Headphones",
"description": "Premium noise-cancelling over-ear model.",
"handle": "wireless-headphones",
"productType": "Electronics",
"vendor": "AudioTech",
"tags": ["wireless", "audio"],
"category": {"id": "cat-audio", "name": "Audio"},
"availableForSale": True,
"options": [{"name": "Color", "values": ["Black"]}],
"priceRange": {
"minVariantPrice": {"amount": "89.99", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "89.99", "currencyCode": "USD"},
},
"variants": [
{
"id": "v1",
"title": "Black",
"price": {"amount": "89.99", "currencyCode": "USD"},
"sku": "AT-101-BLK",
"availableForSale": True,
"selectedOptions": [{"name": "Color", "value": "Black"}],
}
],
},
"p2": {
"id": "p2",
"title": "Bluetooth Speaker",
"description": "Portable wireless bluetooth speaker.",
"handle": "bluetooth-speaker",
"productType": "Electronics",
"vendor": "AudioTech",
"tags": ["wireless", "audio", "bluetooth"],
"availableForSale": True,
"options": [{"name": "Color", "values": ["Blue"]}],
"priceRange": {
"minVariantPrice": {"amount": "49.99", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "49.99", "currencyCode": "USD"},
},
"variants": [
{
"id": "v2",
"title": "Default",
"price": {"amount": "49.99", "currencyCode": "USD"},
"sku": "AT-202",
"availableForSale": True,
"selectedOptions": [{"name": "Color", "value": "Blue"}],
}
],
},
"p3": {
"id": "p3",
"title": "Studio Microphone",
"description": "Cardioid vocal microphone.",
"handle": "studio-microphone",
"productType": "Electronics",
"vendor": "SoundWorks",
"tags": ["audio", "recording"],
"availableForSale": False,
"priceRange": {
"minVariantPrice": {"amount": "129.99", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "129.99", "currencyCode": "USD"},
},
"variants": [
{
"id": "v3",
"title": "Default",
"price": {"amount": "129.99", "currencyCode": "USD"},
"sku": "SW-MIC-1",
"availableForSale": False,
}
],
},
"p4": {
"id": "p4",
"title": "Travel Earbuds",
"description": "Compact wireless earbuds for commuting.",
"handle": "travel-earbuds",
"productType": "Electronics",
"vendor": "AudioTech",
"tags": ["wireless", "audio", "travel"],
"priceRange": {
"minVariantPrice": {"amount": "99.99", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "99.99", "currencyCode": "USD"},
},
"variants": [
{
"id": "v4",
"title": "Green",
"price": {"amount": "99.99", "currencyCode": "USD"},
"sku": "AT-EARBUD-GRN",
"availableForSale": True,
"selectedOptions": [{"name": "Color", "value": "Green"}],
}
],
},
"p5": {
"id": "p5",
"title": "Clearance Speaker Dock",
"description": "Legacy speaker dock for office desks.",
"handle": "clearance-speaker-dock",
"productType": "Electronics",
"vendor": "SoundWorks",
"tags": ["audio", "clearance"],
"priceRange": {
"minVariantPrice": {"amount": "39.99", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "39.99", "currencyCode": "USD"},
},
"variants": [
{
"id": "v5",
"title": "Default",
"price": {"amount": "39.99", "currencyCode": "USD"},
"sku": "SW-DOCK-1",
"availableForSale": False,
}
],
},
},
"carts": {},
"orders": {},
"customers": {},
"collections": {
"coll-audio": {
"id": "coll-audio",
"title": "Audio Gear",
"handle": "audio-gear",
"productIds": ["P1", "p2"],
}
},
"reviews": {},
"returns": {},
"discount_codes": {},
"shipping_methods": {},
"policies": [
{
"id": "pol1",
"type": "fixture-policy-type",
"title": "Return Policy",
"body": "<p>30-day returns on all items including headphones and speakers.</p>",
"url": "https://shop.example.com/policies/pol1",
},
{
"id": "pol2",
"title": "Shipping Policy",
"body": "<p>Free shipping over $50.</p>",
"url": "https://shop.example.com/policies/pol2",
},
],
"faqs": [
{
"type": "fixture-faq-type",
"question": "What are your business hours?",
"answer": "Our support team is available Monday-Friday 9am-5pm EST.",
},
{
"question": "Do headphones include a warranty?",
"answer": "Audio products include a one-year warranty.",
},
],
"counters": {"cart_id": 1000},
}
)
)
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 TestSearchProductsWordAnd:
def test_all_words_must_appear(self):
# "wireless headphones" hits only p1 — p2 has "wireless" in tags but not "headphones"
products, *_ = search_products("wireless headphones")
ids = [p["id"] for p in products]
assert ids == ["p1"]
def test_order_does_not_matter(self):
products, *_ = search_products("headphones wireless")
assert [p["id"] for p in products] == ["p1"]
def test_misses_when_one_word_absent(self):
products, *_ = search_products("wireless espresso")
assert products == []
def test_quoted_phrase_requires_adjacency(self):
# "Wireless Headphones" is adjacent in the title.
adjacent, *_ = search_products('"wireless headphones"')
assert [p["id"] for p in adjacent] == ["p1"]
# "headphones wireless" is not adjacent anywhere.
non_adjacent, *_ = search_products('"headphones wireless"')
assert non_adjacent == []
def test_sku_still_matches_as_single_token(self):
# Structured SKU with hyphens is one token (no whitespace).
products, *_ = search_products("AT-101-BLK")
assert [p["id"] for p in products] == ["p1"]
def test_mixed_sku_and_word(self):
# SKU + prose word both must appear in the concatenated haystack.
products, *_ = search_products("AT-202 speaker")
assert [p["id"] for p in products] == ["p2"]
class TestSearchCatalogTool:
def test_empty_query_returns_all_products_without_default_availability_filter(self):
result = handle_search_shop_catalog(SearchShopCatalogArgs(query="", context="browsing"))
assert result["totalCount"] == 5
assert {p["id"] for p in result["nodes"]} == {"p1", "p2", "p3", "p4", "p5"}
def test_multi_word_query_hits(self):
result = handle_search_shop_catalog(SearchShopCatalogArgs(query="wireless headphones", context="buying"))
assert result["totalCount"] == 1
def test_multi_word_query_miss(self):
result = handle_search_shop_catalog(SearchShopCatalogArgs(query="wireless grandfatherclock", context="buying"))
assert result["totalCount"] == 0
def test_filter_only_search_returns_matching_products(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(query="", context="browsing", filters=[SearchFilter(productVendor="AudioTech")])
)
assert result["totalCount"] == 3
assert {p["id"] for p in result["nodes"]} == {"p1", "p2", "p4"}
def test_query_and_filter_narrows_results(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(query="wireless", context="browsing", filters=[SearchFilter(tag="bluetooth")])
)
assert result["totalCount"] == 1
assert result["nodes"][0]["id"] == "p2"
def test_variant_option_filter(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(
query="",
context="browsing",
filters=[SearchFilter(variantOption=VariantOptionFilter(name="Color", value="Black"))],
)
)
assert result["totalCount"] == 1
assert result["nodes"][0]["id"] == "p1"
def test_variant_option_filter_matches_selected_options_without_top_level_options(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(
query="",
context="browsing",
filters=[SearchFilter(variantOption=VariantOptionFilter(name="Color", value="Green"))],
)
)
assert result["totalCount"] == 1
assert result["nodes"][0]["id"] == "p4"
def test_available_filter(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(query="", context="browsing", filters=[SearchFilter(available=False)])
)
assert result["totalCount"] == 2
assert {p["id"] for p in result["nodes"]} == {"p3", "p5"}
def test_multi_filter_combination_and_price_boundary(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(
query="",
context="browsing",
filters=[
SearchFilter(tag="travel"),
SearchFilter(available=True),
SearchFilter(price=PriceFilter(min=99.99, max=99.99)),
SearchFilter(variantOption=VariantOptionFilter(name="Color", value="Green")),
],
)
)
assert result["totalCount"] == 1
assert result["nodes"][0]["id"] == "p4"
def test_exact_price_boundary_matches(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(
query="",
context="browsing",
filters=[SearchFilter(price=PriceFilter(min=49.99, max=49.99))],
)
)
assert result["totalCount"] == 1
assert result["nodes"][0]["id"] == "p2"
def test_category_filter_matches_collection_membership(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(
query="",
context="browsing",
filters=[SearchFilter(category=CategoryFilter(id="coll-audio"))],
)
)
assert result["totalCount"] == 2
assert {p["id"] for p in result["nodes"]} == {"p1", "p2"}
def test_filter_only_no_match_is_empty(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(query="", context="browsing", filters=[SearchFilter(productType="Furniture")])
)
assert result["totalCount"] == 0
assert result["nodes"] == []
def test_country_and_language_are_reported_as_noop_hints(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(query="bluetooth", context="browsing", country="US", language="EN")
)
assert result["totalCount"] == 1
assert result["localization"] == {"country": "US", "language": "EN", "applied": False}
def test_unsupported_filter_field_reports_warning(self):
search_filter = SearchFilter.model_validate({"giftCard": True})
result = handle_search_shop_catalog(
SearchShopCatalogArgs(query="", context="browsing", filters=[search_filter])
)
assert result["totalCount"] == 5
assert result["warnings"] == ["Unsupported catalog filter field 'giftCard' was ignored."]
def test_malformed_price_filter_reports_warning(self):
result = handle_search_shop_catalog(
SearchShopCatalogArgs(
query="",
context="browsing",
filters=[SearchFilter(price=PriceFilter(min=100.0, max=50.0))],
)
)
assert result["totalCount"] == 0
assert result["warnings"] == ["Price filter min is greater than max; no products can match that filter."]
def test_unsafe_pagination_inputs_report_warnings(self):
with pytest.raises(ValidationError):
SearchShopCatalogArgs(query="audio", context="browsing", limit=-1)
capped = handle_search_shop_catalog(SearchShopCatalogArgs(query="audio", context="browsing", limit=999))
assert len(capped["nodes"]) == 5
assert capped["warnings"] == ["limit exceeds the maximum of 250; using 250."]
malformed_cursor = handle_search_shop_catalog(
SearchShopCatalogArgs(query="audio", context="browsing", after="not-a-cursor")
)
assert malformed_cursor["pageInfo"]["hasPreviousPage"] is False
assert malformed_cursor["warnings"] == ["Invalid after cursor 'not-a-cursor'; using the first page."]
class TestSearchPoliciesWordAnd:
def test_all_words_must_appear(self):
# "return headphones" both appear in Return Policy's title + body.
results = search_policies("return headphones")
assert len(results) == 1
assert results[0]["title"] == "Return Policy"
def test_misses_when_one_word_absent(self):
results = search_policies("return xyzunknown")
assert results == []
def test_quoted_phrase_requires_adjacency(self):
# "Free shipping" is literal text in pol2.
hit = search_policies('"free shipping"')
assert [p["id"] for p in hit] == ["pol2"]
miss = search_policies('"shipping free"')
assert miss == []
class TestSearchFaqs:
def test_faq_question_answer_search(self):
results = search_faqs("business hours")
assert len(results) == 1
assert results[0]["question"] == "What are your business hours?"
def test_policy_and_faq_tool_returns_typed_results(self):
policy = handle_search_shop_policies_and_faqs(SearchShopPoliciesAndFaqsArgs(query="return headphones"))
assert policy["results"][0]["type"] == "policy"
assert "30-day returns" in policy["answer"]
faq = handle_search_shop_policies_and_faqs(SearchShopPoliciesAndFaqsArgs(query="business hours"))
assert faq["results"][0]["type"] == "faq"
assert "Monday-Friday" in faq["answer"]
def test_state_round_trip_preserves_faqs(self):
exported = shopify_state.state_to_json()
assert exported["faqs"][0]["question"] == "What are your business hours?"
shopify_state.state_from_json(exported)
round_tripped = shopify_state.state_to_json()
assert round_tripped["faqs"] == exported["faqs"]
@@ -0,0 +1,373 @@
"""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
@@ -0,0 +1,223 @@
"""Tests for the _snapshot_on_write decorator — final.json written after every write tool call."""
import json
import pytest
@pytest.fixture
def shopify_data(tmp_path):
"""Seed minimal Shopify state."""
data_file = tmp_path / "shopify_data.json"
data_file.write_text(
json.dumps(
{
"products": {
"product-1": {
"id": "product-1",
"title": "Test Product",
"variants": [
{
"id": "variant-1",
"title": "Default",
"price": {"amount": "10.00", "currencyCode": "USD"},
"availableForSale": True,
}
],
}
},
"carts": {},
"shipping_methods": {
"standard": {
"id": "standard",
"title": "Standard",
"price": {"amount": "5.99", "currencyCode": "USD"},
"active": True,
},
},
"policies": [],
"faqs": [],
}
)
)
return data_file
@pytest.fixture
def outputdir(tmp_path):
out = tmp_path / "output" / "shopify"
out.mkdir(parents=True)
return out
@pytest.fixture(autouse=True)
def _patch_globals(shopify_data, outputdir, monkeypatch):
"""Patch Shopify state globals for isolated testing."""
from shopify import state as shopify_state
# Reset state and load from our test data
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.set_snapshot_paths(final_path=None, bundle_state_path=None)
shopify_state.load_state()
shopify_state.set_snapshot_paths(final_path=outputdir / "final.json")
yield
shopify_state.set_snapshot_paths(final_path=None, bundle_state_path=None)
@pytest.mark.asyncio
async def test_update_cart_writes_final_json(outputdir):
from shopify.server import update_cart
final = outputdir / "final.json"
assert not final.exists()
result = await update_cart(
add_items=[{"merchandiseId": "variant-1", "quantity": 1}],
)
assert "id" in result # cart was created
assert final.exists(), "final.json must be written after update_cart"
snapshot = json.loads(final.read_text())
assert len(snapshot.get("carts", {})) > 0
@pytest.mark.asyncio
async def test_search_does_not_write_final_json(outputdir):
from shopify.server import search_shop_catalog
final = outputdir / "final.json"
await search_shop_catalog(query="test", context="browsing")
assert not final.exists(), "final.json must NOT be written after a read-only tool"
@pytest.mark.asyncio
async def test_create_order_writes_final_json(outputdir):
from shopify.server import create_order, update_cart
final = outputdir / "final.json"
# First create a cart with items
cart = await update_cart(add_items=[{"merchandiseId": "variant-1", "quantity": 1}])
# Clear final from cart creation
if final.exists():
final.unlink()
result = await create_order(
cart_id=cart["id"],
payment_method={"type": "credit_card", "card_number": "4111111111111111", "cvv": "123", "expiry": "12/26"},
shipping_address={"address1": "123 Main St", "city": "Test", "countryCode": "US"},
billing_address={"address1": "123 Main St", "city": "Test", "countryCode": "US"},
shipping_method_id="standard",
)
assert result.get("order") is not None
assert final.exists(), "final.json must be written after create_order"
@pytest.mark.asyncio
async def test_get_order_does_not_write_final_json(outputdir):
from shopify.server import get_order
final = outputdir / "final.json"
await get_order(order_id="nonexistent")
assert not final.exists(), "final.json must NOT be written after a read-only tool"
@pytest.mark.asyncio
async def test_no_final_path_skips_snapshot(outputdir):
from shopify import state as shopify_state
from shopify.server import update_cart
shopify_state.set_snapshot_paths(final_path=None)
result = await update_cart(
add_items=[{"merchandiseId": "variant-1", "quantity": 1}],
)
assert "id" in result
final = outputdir / "final.json"
assert not final.exists()
def test_init_state_writes_initial_and_bundle_not_final(shopify_data, tmp_path, monkeypatch):
from shopify import state as shopify_state
outputdir = tmp_path / "output"
bundledir = tmp_path / "bundle"
monkeypatch.setenv("OUTPUTDIR", str(outputdir))
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", str(bundledir))
shopify_state._stores.clear()
shopify_state._current_state = None
shopify_state._active_store_id = "default"
monkeypatch.setattr(shopify_state, "_STATE_FILE", shopify_data)
shopify_state.set_snapshot_paths(final_path=None, bundle_state_path=None)
shopify_state.init_state()
assert (outputdir / "initial.json").exists()
assert not (outputdir / "final.json").exists()
assert (bundledir / "state.json").exists()
@pytest.mark.asyncio
async def test_snapshot_on_write_restores_state_when_tool_raises(shopify_data, outputdir):
from shopify import server as srv
from shopify import state as shopify_state
@srv._snapshot_on_write
def mutates_then_raises():
shopify_state.get_state().products["product-1"].title = "Mutated"
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
await mutates_then_raises()
assert shopify_state.get_state().products["product-1"].title == "Test Product"
persisted = json.loads(shopify_data.read_text())
assert persisted["products"]["product-1"]["title"] == "Test Product"
assert not (outputdir / "final.json").exists()
@pytest.mark.asyncio
async def test_snapshot_on_write_restores_state_on_hard_error_result(shopify_data, outputdir):
from shopify import server as srv
from shopify import state as shopify_state
@srv._snapshot_on_write
def mutates_then_returns_hard_error():
shopify_state.get_state().products["product-1"].title = "Mutated"
return {"product": None, "userErrors": [{"field": "title", "message": "invalid"}]}
result = await mutates_then_returns_hard_error()
assert result["product"] is None
assert shopify_state.get_state().products["product-1"].title == "Test Product"
persisted = json.loads(shopify_data.read_text())
assert persisted["products"]["product-1"]["title"] == "Test Product"
assert not (outputdir / "final.json").exists()
@pytest.mark.asyncio
async def test_snapshot_on_write_keeps_partial_success_result(outputdir):
from shopify import server as srv
from shopify import state as shopify_state
@srv._snapshot_on_write
def mutates_then_returns_partial_success():
product = shopify_state.get_state().products["product-1"]
product.title = "Partially Mutated"
return {
"product": product,
"userErrors": [{"field": "optional_field", "message": "ignored"}],
}
result = await mutates_then_returns_partial_success()
assert result["product"].title == "Partially Mutated"
assert shopify_state.get_state().products["product-1"].title == "Partially Mutated"
assert (outputdir / "final.json").exists()
@@ -0,0 +1,45 @@
"""Tests for Shopify type definitions and utility functions."""
from pathlib import Path
from shopify.models import Image, MoneyV2, PageInfo, SelectedOption
from shopify.utils import get_shopify_state_path
class TestGetShopifyStatePath:
def test_computes_external_services_path(self):
result = get_shopify_state_path("/workspace/dumps/workspace")
assert result == Path("/workspace/dumps/external_services/shopify_data.json")
def test_path_is_sibling_to_workspace(self):
result = get_shopify_state_path("/a/b/workspace")
assert result.parent == Path("/a/b/external_services")
class TestMoneyV2:
def test_creates_money(self):
m = MoneyV2(amount="19.99", currencyCode="USD")
assert m.amount == "19.99"
assert m.currencyCode == "USD"
class TestImage:
def test_creates_image_minimal(self):
img = Image(url="https://example.com/img.png")
assert img.url == "https://example.com/img.png"
assert img.altText is None
class TestSelectedOption:
def test_creates_option(self):
opt = SelectedOption(name="Size", value="Large")
assert opt.name == "Size"
assert opt.value == "Large"
class TestPageInfo:
def test_defaults(self):
pi = PageInfo()
assert pi.hasNextPage is False
assert pi.hasPreviousPage is False
assert pi.startCursor is None
@@ -0,0 +1,71 @@
from __future__ import annotations
from starlette.testclient import TestClient
from shopify import state as shopify_state
from shopify.viewer import create_shopify_viewer_app
def test_products_api_handles_strict_state_models_with_omitted_optional_fields() -> None:
shopify_state.state_from_json(
{
"products": {
"product-1": {
"id": "product-1",
"title": "Test Product",
"variants": [
{
"id": "variant-1",
"title": "Default",
"price": {"amount": "10.00", "currencyCode": "USD"},
}
],
}
},
"carts": {},
"policies": [],
"faqs": [],
}
)
client = TestClient(create_shopify_viewer_app(), raise_server_exceptions=False)
response = client.get("/api/products")
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1
assert payload["products"][0]["id"] == "product-1"
assert payload["products"][0]["price"] is None
def test_policies_and_cart_detail_api_serialize_strict_state_models() -> None:
shopify_state.state_from_json(
{
"products": {},
"carts": {
"cart-1": {
"id": "cart-1",
"totalQuantity": 0,
"note": "Viewer smoke cart",
}
},
"policies": [
{
"type": "REFUND_POLICY",
"title": "Refund Policy",
"body": "Returns accepted within 30 days.",
}
],
"faqs": [],
}
)
client = TestClient(create_shopify_viewer_app(), raise_server_exceptions=False)
policies_response = client.get("/api/policies")
assert policies_response.status_code == 200
assert policies_response.json()["policies"][0]["title"] == "Refund Policy"
cart_response = client.get("/api/carts/cart-1")
assert cart_response.status_code == 200
assert cart_response.json()["cart"]["note"] == "Viewer smoke cart"