Add Handbook.md benchmark tasks
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user