Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 25c9eda5ca
1800 changed files with 323575 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# Shopify Capabilities
A mock Shopify store covering the full e-commerce lifecycle: product management, cart management, checkout, orders, customers, inventory, collections, reviews, returns, discounts, shipping, policies, and a loyalty program. Supports multi-store worlds (agent switches between stores via `set_active_store`) and customer-mode worlds (agent acts as a specific end customer rather than a merchant).
## What the agent can do
**Manage products.** Create new products with title, description, vendor, type, tags, and variants (each with price, SKU, and inventory). Update product details. Delete products (cleans up associated collection memberships and reviews). Search the catalog by keyword with filters (vendor, product type, price range, tags, availability). View detailed product information including variants, pricing, images, and options.
Catalog search uses case-insensitive word-AND matching. Empty queries intentionally search all products before filters apply, and an unset `available` filter returns both available and unavailable products. Multiple filters are ANDed; OR, negation, and exclude filters are not supported. The mock `category` filter is intentionally broad: it matches either product category fields (`category`, `categoryId`, `productCategory`) or collection membership by collection id, title, or handle. `country` and `language` inputs are accepted as compatibility hints but do not localize the mock catalog.
**Shopping cart.** Create carts, add items (by product variant), update quantities, remove items, set buyer identity (email, phone), add delivery addresses, select shipping options, apply discount codes and gift cards, and add order notes.
**Place and manage orders.** Convert a cart into an order (capturing line items, buyer info, addresses). View individual orders or list all orders with filtering by financial status (pending, paid, refunded) or fulfillment status. Update order status and details. Cancel orders with an optional reason.
**Customer management.** Create customer profiles with name, email, phone, addresses, and tags. Search customers by name, email, or phone. Update profiles and manage marketing consent. List customers with filtering by tag.
**Inventory.** View stock levels across all product variants, with optional filtering by product or low-stock threshold. Update quantities — product-level availability and total inventory recalculate automatically.
**Collections.** Create curated collections, add and remove products from collections, list all collections with product counts, and view collection details with full product data.
**Reviews.** Create reviews with ratings (1-5), title, body, and author. View reviews for a product with average rating. Moderate reviews by changing status (published, hidden, pending). Delete reviews.
**Returns and refunds.** Create return requests linked to orders with specific line items and quantities. The system validates line items and calculates refund amounts. Track returns through their lifecycle (requested → approved → received → refunded/rejected). When a return is marked as refunded, the linked order's financial status automatically updates to "refunded" or "partially refunded."
**Discount codes.** Create, update, get, list, and delete discount codes. Codes can be percentage-based or fixed-amount. When applied to a cart, they reduce the order total accordingly.
**Shipping methods.** Create, update, list, and delete shipping methods. Each method has a name, carrier, price, and estimated delivery window. Carts select from available methods.
**Policies.** Create, update, list, and delete shop policies (refund, shipping, terms, etc.). Agents can also search policies and FAQs by keyword. Policy/FAQ search result objects reserve `type` for the normalized result kind (`policy` or `faq`), regardless of any fixture-level policy category field.
**Loyalty program.** Configure a points-based loyalty program with earn/redemption rates and tiers. Award points to customers, check balances and tier status, redeem points for discounts, and list available tiers.
**Customer-mode operations (self).** When the agent represents a specific customer (set via `current_customer_email` in state), a set of `_my_` tools operate on the current customer's data: `get_my_customer`, `get_my_order`, `list_my_orders`, `create_my_return`, `create_my_review`, `update_my_customer`, `get_my_loyalty_balance`, `get_my_loyalty_tier`, `redeem_my_points`. These bypass the need to look up the customer ID each call.
**Multi-store.** When the world defines multiple stores under a `stores` key, the agent can list stores (`list_stores`), switch the active store, and perform all operations scoped to the active store. State is partitioned per store.
## Coverage gaps
- No payment processing or payment method management
- No carrier integration or shipping label generation
- No tax calculation
- No multi-currency support
- No order fulfillment tracking (shipping labels, tracking numbers)
- No webhook or notification system
## Toolsets
66 tools total. Toolsets map to `WORLDBENCH_TOOL_SETS` values (prefixed form — e.g., `shopify_cart`).
| Toolset | Tools | Description |
|---------|-------|-------------|
| `all` / `shopify_all` | 66 | Everything |
| `read` / `shopify_read` | 31 | All read-only tools |
| `write` / `shopify_write` | 35 | All write tools |
| `shopify_catalog` | 6 | Product + FAQ browsing: create/delete/update product, get details, search catalog, search policies |
| `shopify_cart` | 3 | Cart: get, list, update |
| `shopify_orders` | 5 | Order lifecycle: create, get, list, update, cancel |
| `shopify_customers` | 5 | Customer profiles: create, get, list, search, update |
| `shopify_inventory_collections` | 7 | Stock + collections: get/update inventory, create/get/list collections, add/remove products |
| `shopify_reviews_returns` | 8 | Reviews + returns: create/update/delete reviews, get product reviews, create/get/list/update returns |
| `shopify_discounts` | 5 | Discount codes: create, update, get, list, delete |
| `shopify_shipping` | 4 | Shipping methods: create, update, list, delete |
| `shopify_policies` | 5 | Shop policies: create, update, list, delete, search policies & FAQs |
| `shopify_loyalty` | 7 | Loyalty program: configure, award/redeem points, get balance/tier/program, list tiers |
| `shopify_self` | 9 | Customer-mode ops: all `_my_` tools (acts on `current_customer_email`) |
| `shopify_customer` | 17 | Full customer-mode toolset: self ops + catalog browsing + cart/order creation |
| `shopify_business` | 48 | Full merchant-mode toolset: everything except customer-mode `_my_` tools |
| `shopify_core` | 10 | Baseline order flow plus legacy Toolathlon catalog/policy search |
| `shopify_toolathlon_legacy` | 6 | Legacy Toolathlon tool subset (pre-integration) |
| `shopify_state` | 2 | `export_state`, `import_state` for fixture seeding and grading |
**Permission-mode toolsets** (`customer` / `business`) are mutually exclusive — use one when setting up a world that scopes the agent to a specific role. `customer` mode pairs with a state file that sets `current_customer_email`.
**Multi-store** worlds round-trip through `export_state` / `import_state` under a `{"stores": {store_id: {...}}}` wrapper; single-store worlds use the flat shape.
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
{
"cwd": "..",
"run": {"command": "python", "args": ["-m", "shopify"]},
"toolsets": {
"read": [
"get_cart",
"get_collection",
"get_customer",
"get_discount_code",
"get_inventory",
"get_loyalty_balance",
"get_loyalty_program",
"get_loyalty_tier",
"get_my_customer",
"get_my_loyalty_balance",
"get_my_loyalty_tier",
"get_my_order",
"get_order",
"get_product_details",
"get_product_reviews",
"get_return",
"list_carts",
"list_collections",
"list_customers",
"list_discount_codes",
"list_loyalty_tiers",
"list_my_orders",
"list_orders",
"list_policies",
"list_returns",
"list_shipping_methods",
"list_stores",
"search_customers",
"search_shop_catalog",
"search_shop_policies_and_faqs"
],
"write": [
"add_to_collection",
"award_points",
"cancel_order",
"configure_loyalty_program",
"create_collection",
"create_customer",
"create_discount_code",
"create_my_return",
"create_my_review",
"create_order",
"create_policy",
"create_product",
"create_return",
"create_review",
"create_shipping_method",
"delete_discount_code",
"delete_policy",
"delete_product",
"delete_review",
"delete_shipping_method",
"redeem_my_points",
"redeem_points",
"remove_from_collection",
"update_cart",
"update_customer",
"update_discount_code",
"update_inventory",
"update_my_customer",
"update_order",
"update_policy",
"update_product",
"update_return",
"update_review",
"update_shipping_method"
],
"catalog": [
"create_product",
"delete_product",
"get_product_details",
"search_shop_catalog",
"search_shop_policies_and_faqs",
"update_product"
],
"cart": [
"get_cart",
"list_carts",
"update_cart"
],
"orders": [
"cancel_order",
"create_order",
"get_order",
"list_orders",
"update_order"
],
"customers": [
"create_customer",
"get_customer",
"list_customers",
"search_customers",
"update_customer"
],
"inventory_collections": [
"add_to_collection",
"create_collection",
"get_collection",
"get_inventory",
"list_collections",
"remove_from_collection",
"update_inventory"
],
"reviews_returns": [
"create_return",
"create_review",
"delete_review",
"get_product_reviews",
"get_return",
"list_returns",
"update_return",
"update_review"
],
"discounts": [
"create_discount_code",
"delete_discount_code",
"get_discount_code",
"list_discount_codes",
"update_discount_code"
],
"policies": [
"create_policy",
"delete_policy",
"list_policies",
"search_shop_policies_and_faqs",
"update_policy"
],
"shipping": [
"create_shipping_method",
"delete_shipping_method",
"list_shipping_methods",
"update_shipping_method"
],
"loyalty": [
"award_points",
"configure_loyalty_program",
"get_loyalty_balance",
"get_loyalty_program",
"get_loyalty_tier",
"list_loyalty_tiers",
"redeem_points"
],
"self": [
"create_my_return",
"create_my_review",
"get_my_customer",
"get_my_loyalty_balance",
"get_my_loyalty_tier",
"get_my_order",
"list_my_orders",
"redeem_my_points",
"update_my_customer"
],
"customer": [
"create_my_return",
"create_my_review",
"create_order",
"get_cart",
"get_my_customer",
"get_my_loyalty_balance",
"get_my_loyalty_tier",
"get_my_order",
"get_product_details",
"list_loyalty_tiers",
"list_my_orders",
"list_shipping_methods",
"redeem_my_points",
"search_shop_catalog",
"search_shop_policies_and_faqs",
"update_cart",
"update_my_customer"
],
"business": [
"add_to_collection",
"award_points",
"cancel_order",
"configure_loyalty_program",
"create_collection",
"create_customer",
"create_discount_code",
"create_policy",
"create_product",
"create_shipping_method",
"delete_discount_code",
"delete_policy",
"delete_product",
"delete_review",
"delete_shipping_method",
"get_collection",
"get_customer",
"get_discount_code",
"get_inventory",
"get_loyalty_balance",
"get_loyalty_program",
"get_loyalty_tier",
"get_order",
"get_product_reviews",
"get_return",
"list_collections",
"list_customers",
"list_discount_codes",
"list_loyalty_tiers",
"list_orders",
"list_policies",
"list_returns",
"list_shipping_methods",
"list_stores",
"redeem_points",
"remove_from_collection",
"search_customers",
"search_shop_catalog",
"search_shop_policies_and_faqs",
"update_customer",
"update_discount_code",
"update_inventory",
"update_order",
"update_policy",
"update_product",
"update_return",
"update_review",
"update_shipping_method"
],
"core": [
"create_order",
"get_cart",
"get_order",
"get_product_details",
"list_carts",
"list_orders",
"list_shipping_methods",
"search_shop_catalog",
"update_cart",
"search_shop_policies_and_faqs"
],
"toolathlon_legacy": [
"get_cart",
"get_product_details",
"list_carts",
"search_shop_catalog",
"search_shop_policies_and_faqs",
"update_cart"
],
"state": [
"export_state",
"import_state"
]
}
}
+25
View File
@@ -0,0 +1,25 @@
[build-system]
build-backend = "uv_build"
requires = [ "uv-build>=0.11,<0.12" ]
[project]
name = "shopify"
version = "0.1.0"
description = "Shopify mock MCP server"
requires-python = ">=3.13"
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"fastmcp>=3,<4",
"pydantic[email]>=2",
]
[tool.uv]
build-backend.module-root = ""
[tool.pytest]
ini_options.testpaths = [ "tests" ]
ini_options.pythonpath = [ "." ]
@@ -0,0 +1,35 @@
"""Shopify mock MCP server."""
from __future__ import annotations
import argparse
import logging
import os
from .server import mcp
from .state import init_state
__all__ = ["main", "mcp"]
def main() -> None:
parser = argparse.ArgumentParser(description="Shopify Mock MCP Server")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
parser.add_argument("--agent-workspace", help="Agent workspace path used to resolve persistent Shopify state")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
from .async_tool_guard import assert_tools_async
assert_tools_async(mcp)
init_state(args.agent_workspace)
port = os.environ.get("PORT")
if port:
from .viewer import run_http_server
run_http_server(mcp, int(port))
else:
mcp.run()
@@ -0,0 +1,3 @@
from . import main
main()
@@ -0,0 +1,59 @@
"""Boot-time guard: every registered MCP tool must be async.
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
worker threadpool. Under concurrent calls the shared pydantic-core validator is
re-entered across threads and panics with ``pyo3_runtime.PanicException:
dictionary changed size during iteration``, which tears down the
StreamableHTTP task group and turns every later request into a 500. Async
(`async def`) tools validate on the single-threaded event loop and are safe.
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
boots every server): a non-conformant package fails fast instead of shipping a
server that can crash under load. The same check is intentionally duplicated in
each package because the bundled prod images install only that package's own
dependencies, so there is no shared runtime module to import.
"""
from __future__ import annotations
import asyncio
import inspect
from functools import partial
from typing import Any
def _unwrap(fn: Any) -> Any:
while isinstance(fn, partial):
fn = fn.func
return fn
def find_sync_tools(mcp: Any) -> list[str]:
"""Return the names of registered tools whose function is not a coroutine."""
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
manager = getattr(mcp, "_tool_manager", None)
if manager is not None and hasattr(manager, "list_tools"):
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
# fastmcp.FastMCP exposes an async API.
async def _collect() -> list[str]:
sync: list[str] = []
for descriptor in await mcp.list_tools():
tool = await mcp.get_tool(descriptor.name)
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
sync.append(descriptor.name)
return sorted(sync)
return asyncio.run(_collect())
def assert_tools_async(mcp: Any) -> None:
"""Raise if any registered tool is synchronous."""
sync = find_sync_tools(mcp)
if sync:
raise RuntimeError(
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
"validation in a worker threadpool and can trigger a pydantic-core "
"'dictionary changed size during iteration' panic under concurrent calls, "
"which kills the server. Make these tools async: " + ", ".join(sync)
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Simple test client for the Shopify MCP server.
Usage:
uv run python packages/shopify/shopify/test_client.py
"""
import asyncio
import json
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Sample test data to load before testing
SAMPLE_DATA = {
"products": {
"gid://shopify/Product/1001": {
"id": "gid://shopify/Product/1001",
"title": "Hydrating Face Moisturizer",
"description": "A lightweight, hydrating moisturizer for all skin types.",
"descriptionHtml": "<p>A lightweight, hydrating moisturizer for all skin types.</p>",
"handle": "hydrating-face-moisturizer",
"productType": "Skincare",
"vendor": "RhodeSkin",
"tags": ["moisturizer", "hydrating", "face", "skincare"],
"availableForSale": True,
"priceRange": {
"minVariantPrice": {"amount": "29.00", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "29.00", "currencyCode": "USD"},
},
"featuredImage": {
"id": "gid://shopify/Image/1",
"url": "https://example.com/moisturizer.jpg",
"altText": "Hydrating Face Moisturizer",
},
"images": [
{
"id": "gid://shopify/Image/1",
"url": "https://example.com/moisturizer.jpg",
"altText": "Hydrating Face Moisturizer",
}
],
"options": [{"id": "opt1", "name": "Size", "values": ["30ml", "50ml"]}],
"variants": [
{
"id": "gid://shopify/ProductVariant/1001-30",
"title": "30ml",
"price": {"amount": "29.00", "currencyCode": "USD"},
"availableForSale": True,
"sku": "MOIST-30",
"selectedOptions": [{"name": "Size", "value": "30ml"}],
},
{
"id": "gid://shopify/ProductVariant/1001-50",
"title": "50ml",
"price": {"amount": "45.00", "currencyCode": "USD"},
"availableForSale": True,
"sku": "MOIST-50",
"selectedOptions": [{"name": "Size", "value": "50ml"}],
},
],
},
"gid://shopify/Product/1002": {
"id": "gid://shopify/Product/1002",
"title": "Lip Treatment Oil",
"description": "Nourishing lip oil for soft, glossy lips.",
"descriptionHtml": "<p>Nourishing lip oil for soft, glossy lips.</p>",
"handle": "lip-treatment-oil",
"productType": "Lip Care",
"vendor": "RhodeSkin",
"tags": ["lip", "oil", "treatment", "gloss"],
"availableForSale": True,
"priceRange": {
"minVariantPrice": {"amount": "18.00", "currencyCode": "USD"},
"maxVariantPrice": {"amount": "18.00", "currencyCode": "USD"},
},
"featuredImage": {
"id": "gid://shopify/Image/2",
"url": "https://example.com/lipoil.jpg",
"altText": "Lip Treatment Oil",
},
"images": [
{"id": "gid://shopify/Image/2", "url": "https://example.com/lipoil.jpg", "altText": "Lip Treatment Oil"}
],
"options": [{"id": "opt2", "name": "Shade", "values": ["Clear", "Rose", "Berry"]}],
"variants": [
{
"id": "gid://shopify/ProductVariant/1002-clear",
"title": "Clear",
"price": {"amount": "18.00", "currencyCode": "USD"},
"availableForSale": True,
"sku": "LIP-CLEAR",
"selectedOptions": [{"name": "Shade", "value": "Clear"}],
},
{
"id": "gid://shopify/ProductVariant/1002-rose",
"title": "Rose",
"price": {"amount": "18.00", "currencyCode": "USD"},
"availableForSale": True,
"sku": "LIP-ROSE",
"selectedOptions": [{"name": "Shade", "value": "Rose"}],
},
{
"id": "gid://shopify/ProductVariant/1002-berry",
"title": "Berry",
"price": {"amount": "18.00", "currencyCode": "USD"},
"availableForSale": False,
"sku": "LIP-BERRY",
"selectedOptions": [{"name": "Shade", "value": "Berry"}],
},
],
},
},
"carts": {},
"policies": [
{
"id": "gid://shopify/ShopPolicy/1",
"title": "Return Policy",
"body": "We offer a 30-day return policy for all unused products in original packaging. Returns are free for orders within the US.",
"url": "https://shop.example.com/policies/return",
},
{
"id": "gid://shopify/ShopPolicy/2",
"title": "Shipping Policy",
"body": "Free shipping on orders over $50. Standard shipping takes 5-7 business days. Express shipping available for $12.99.",
"url": "https://shop.example.com/policies/shipping",
},
],
"counters": {"cart_id": 1000, "line_id": 1000},
}
def setup_test_data():
"""Write sample data to the state file."""
state_file = Path(__file__).resolve().parents[1] / "shopify_data.json"
with open(state_file, "w") as f:
json.dump(SAMPLE_DATA, f, indent=2)
print(f"Wrote test data to {state_file}")
async def main():
# Setup test data first
setup_test_data()
# Server command - use uv run to execute the server module
server_params = StdioServerParameters(
command="uv",
args=["run", "python", "-m", "shopify.server"],
env=None,
)
print("\nStarting Shopify MCP server via stdio...")
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize the session
await session.initialize()
print("Session initialized!\n")
# List available tools
print("=" * 60)
print("Available Tools:")
print("=" * 60)
tools = await session.list_tools()
for tool in tools.tools:
print(f" - {tool.name}")
print()
# Test 1: search_shop_catalog
print("=" * 60)
print("Test 1: search_shop_catalog - Search for 'moisturizer'")
print("=" * 60)
result = await session.call_tool(
"search_shop_catalog",
arguments={
"query": "moisturizer",
"context": "Looking for face products",
"limit": 5,
},
)
data = json.loads(result.content[0].text) # type: ignore
print(f"Found {data['totalCount']} products")
for product in data.get("nodes", []):
print(f" - {product['title']} (${product['priceRange']['minVariantPrice']['amount']})")
print(f"Available filters: {len(data.get('productFilters', []))}")
print()
# Test 2: get_product_details
print("=" * 60)
print("Test 2: get_product_details - Get moisturizer details")
print("=" * 60)
result = await session.call_tool(
"get_product_details",
arguments={
"product_id": "gid://shopify/Product/1001",
"options": {"Size": "50ml"},
},
)
data = json.loads(result.content[0].text) # type: ignore
if data.get("product"):
print(f"Product: {data['product']['title']}")
print(f"Variants: {len(data['product'].get('variants', []))}")
if data.get("selectedVariant"):
print(
f"Selected variant: {data['selectedVariant']['title']} - ${data['selectedVariant']['price']['amount']}"
)
print()
# Test 3: update_cart - Create new cart and add items
print("=" * 60)
print("Test 3: update_cart - Create cart and add moisturizer")
print("=" * 60)
result = await session.call_tool(
"update_cart",
arguments={
"add_items": [
{"merchandiseId": "gid://shopify/ProductVariant/1001-50", "quantity": 1},
{"merchandiseId": "gid://shopify/ProductVariant/1002-rose", "quantity": 2},
],
"note": "Test order",
},
)
data = json.loads(result.content[0].text) # type: ignore
cart_id = data.get("id")
print(f"Cart created: {cart_id}")
print(f"Lines: {len(data.get('lines', []))}")
print(f"Total: ${data.get('cost', {}).get('totalAmount', {}).get('amount', '0')}")
for line in data.get("lines", []):
print(
f" - {line['merchandise']['title']} x{line['quantity']} = ${line['cost']['totalAmount']['amount']}"
)
print()
# Test 4: get_cart
print("=" * 60)
print("Test 4: get_cart - Retrieve the cart we just created")
print("=" * 60)
result = await session.call_tool(
"get_cart",
arguments={
"cart_id": cart_id,
},
)
data = json.loads(result.content[0].text) # type: ignore
if data.get("id"):
print(f"Cart ID: {data['id']}")
print(f"Checkout URL: {data['checkoutUrl']}")
print(f"Total Quantity: {data['totalQuantity']}")
print(f"Total: ${data['cost']['totalAmount']['amount']}")
else:
print(f"Error: {data}")
print()
# Test 5: update_cart - Update quantity
print("=" * 60)
print("Test 5: update_cart - Update quantity of first item")
print("=" * 60)
first_line_id = data.get("lines", [{}])[0].get("id") if data.get("lines") else None
if first_line_id:
result = await session.call_tool(
"update_cart",
arguments={
"cart_id": cart_id,
"update_items": [{"id": first_line_id, "quantity": 3}],
},
)
data = json.loads(result.content[0].text) # type: ignore
print(f"Updated cart total: ${data.get('cost', {}).get('totalAmount', {}).get('amount', '0')}")
for line in data.get("lines", []):
print(f" - {line['merchandise']['title']} x{line['quantity']}")
print()
# Test 6: search_shop_policies_and_faqs
print("=" * 60)
print("Test 6: search_shop_policies_and_faqs - Return policy")
print("=" * 60)
result = await session.call_tool(
"search_shop_policies_and_faqs",
arguments={
"query": "return policy",
},
)
data = json.loads(result.content[0].text) # type: ignore
print(f"Found {len(data.get('results', []))} policies")
if data.get("answer"):
print(f"Answer: {data['answer'][:100]}...")
print()
print("=" * 60)
print("All tests completed!")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1 @@
"""Shopify MCP tool implementation modules."""
@@ -0,0 +1,507 @@
"""Cart tool handlers."""
from datetime import UTC, datetime
from typing import Any
from shopify.models import (
AppliedGiftCard,
CartBuyerIdentity,
CartDeliveryGroup,
CartDeliveryOption,
CartDiscountCode,
GetCartArgs,
MoneyV2,
UpdateCartArgs,
)
from shopify.state import (
add_line_to_cart,
create_cart,
get_all_carts,
get_all_stores,
get_cart_by_id,
get_customer_by_email,
get_discount_by_code,
get_gift_card_by_code,
get_state,
remove_lines_from_cart,
save_state,
update_line_in_cart,
)
from shopify.tools.loyalty import compute_tier
from shopify.tools.order_effects import applied_gift_card_code
get_cart_tool = {
"name": "get_cart",
"description": "Get the cart including items, shipping options, discount info, and checkout url for a given cart id",
"inputSchema": {
"type": "object",
"properties": {
"cart_id": {
"type": "string",
"description": "Shopify cart id, formatted like: gid://shopify/Cart/c1-66330c6d752c2b242bb8487474949791?key=fa8913e951098d30d68033cf6b7b50f3",
}
},
"required": ["cart_id"],
},
}
def handle_get_cart(args: GetCartArgs) -> Any:
"""
Handle get_cart tool call.
Retrieves a cart by ID from local state.
"""
cart = get_cart_by_id(args.cart_id)
if cart is None:
return {"cart": None, "userErrors": [{"field": ["cart_id"], "message": f"Cart {args.cart_id} not found"}]}
return cart
def handle_list_stores() -> dict:
"""Return summary information for every loaded store."""
stores = get_all_stores()
result = [
{
"store_id": store_id,
"product_count": len(state.products),
"order_count": len(state.orders),
"customer_count": len(state.customers),
}
for store_id, state in stores.items()
]
return {"stores": result, "total": len(result)}
def handle_list_carts() -> dict:
"""Return summary information for all carts in the active store."""
cart_summaries = [
{
"id": cart.id,
"totalQuantity": cart.totalQuantity,
"itemCount": len(cart.lines),
"totalAmount": cart.cost.totalAmount.model_dump(mode="json") if cart.cost else {},
"createdAt": cart.createdAt,
"updatedAt": cart.updatedAt,
"note": cart.note,
}
for cart in get_all_carts()
]
return {"carts": cart_summaries, "totalCount": len(cart_summaries)}
update_cart_tool = {
"name": "update_cart",
"description": "Perform updates to a cart, including adding/removing/updating line items, buyer information, shipping details, discount codes, gift cards and notes in one consolidated call. Shipping options become available after adding items and delivery address. When creating a new cart, only addItems is required.",
"inputSchema": {
"type": "object",
"properties": {
"cart_id": {
"type": "string",
"description": "Identifier for the cart being updated. If not provided, a new cart will be created.",
},
"add_items": {
"type": "array",
"description": "Items to add to the cart. Required when creating a new cart.",
"items": {
"type": "object",
"required": ["product_variant_id", "quantity"],
"properties": {
"product_variant_id": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1},
},
},
},
"update_items": {
"type": "array",
"description": "Existing cart line items to update quantities for. Use quantity 0 to remove an item.",
"items": {
"type": "object",
"required": ["id", "quantity"],
"properties": {"id": {"type": "string"}, "quantity": {"type": "integer", "minimum": 0}},
},
},
"remove_line_ids": {
"type": "array",
"description": "List of line item IDs to remove explicitly.",
"items": {"type": "string"},
},
"buyer_identity": {
"type": "object",
"description": "Information about the buyer including email, phone, and delivery address.",
"properties": {
"email": {"type": "string", "format": "email"},
"phone": {"type": "string"},
"country_code": {"type": "string", "description": "ISO country code, used for regional pricing."},
},
},
"delivery_addresses_to_add": {
"type": "array",
"description": "Information about the delivery addresses to add.",
"items": {
"type": "object",
"properties": {
"selected": {"type": "boolean", "description": "Should this address be selected for delivery."},
"delivery_address": {
"type": "object",
"properties": {
"first_name": {"type": "string"},
"last_name": {"type": "string"},
"phone": {"type": "string"},
"address1": {"type": "string"},
"address2": {"type": "string"},
"city": {"type": "string"},
"province_code": {"type": "string"},
"zip": {"type": "string"},
"country_code": {"type": "string"},
},
},
},
},
},
"delivery_addresses_to_replace": {
"type": "array",
"description": "Delivery addresses to apply to the cart, replaces all existing cart delivery addresses.",
"items": {
"type": "object",
"properties": {
"selected": {"type": "boolean"},
"delivery_address": {
"type": "object",
"properties": {
"first_name": {"type": "string"},
"last_name": {"type": "string"},
"phone": {"type": "string"},
"address1": {"type": "string"},
"address2": {"type": "string"},
"city": {"type": "string"},
"province_code": {"type": "string"},
"zip": {"type": "string"},
"country_code": {"type": "string"},
},
},
},
},
},
"selected_delivery_options": {
"type": "array",
"description": "The delivery options to select for the cart.",
"items": {
"type": "object",
"required": ["group_id", "option_handle"],
"properties": {
"group_id": {"type": "string", "description": "The ID of the delivery group."},
"option_handle": {"type": "string", "description": "The handle of the delivery option."},
},
},
},
"discount_codes": {
"type": "array",
"description": "Discount or promo codes to apply to the cart.",
"items": {"type": "string"},
},
"gift_card_codes": {
"type": "array",
"description": "Gift card codes to apply to the cart.",
"items": {"type": "string"},
},
"note": {"type": "string", "description": "A note or special instructions for the cart."},
},
},
}
def handle_update_cart(args: UpdateCartArgs) -> Any:
"""
Handle update_cart tool call.
Creates or updates a cart with the specified changes.
"""
user_errors = []
# Get or create cart
if args.cart_id:
cart = get_cart_by_id(args.cart_id)
if cart is None:
return {"cart": None, "userErrors": [{"field": ["cart_id"], "message": f"Cart {args.cart_id} not found"}]}
else:
# Create new cart
buyer_identity_dict = None
if args.buyer_identity:
buyer_identity_dict = {
"email": args.buyer_identity.email,
"phone": args.buyer_identity.phone,
"countryCode": args.buyer_identity.countryCode,
}
cart = create_cart(buyer_identity=buyer_identity_dict, note=args.note)
# Add items
if args.add_items:
for item in args.add_items:
merchandise_id = item.merchandiseId
quantity = item.quantity
line = add_line_to_cart(cart, merchandise_id, quantity)
if line is None:
user_errors.append(
{"field": ["add_items", "merchandiseId"], "message": f"Variant {merchandise_id} not found"}
)
_sync_delivery_groups(cart)
# Update items
if args.update_items:
for item in args.update_items:
line_id = item.id
quantity = item.quantity
found = update_line_in_cart(cart, line_id, quantity)
if not found:
user_errors.append({"field": ["update_items", "id"], "message": f"Line {line_id} not found in cart"})
_sync_delivery_groups(cart)
# Remove lines
if args.remove_line_ids:
remove_lines_from_cart(cart, args.remove_line_ids)
_sync_delivery_groups(cart)
# Update buyer identity
if args.buyer_identity and args.cart_id: # Only update if not newly created
cart.buyerIdentity = CartBuyerIdentity(
email=args.buyer_identity.email,
phone=args.buyer_identity.phone,
countryCode=args.buyer_identity.countryCode,
deliveryAddressPreferences=list(args.buyer_identity.deliveryAddressPreferences or []),
)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
if args.delivery_addresses_to_replace is not None:
cart.buyerIdentity.deliveryAddressPreferences = [
address.address.model_dump(mode="json", exclude_none=True) for address in args.delivery_addresses_to_replace
]
_sync_delivery_groups(cart)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
if args.delivery_addresses_to_add:
existing = list(cart.buyerIdentity.deliveryAddressPreferences)
existing.extend(
address.address.model_dump(mode="json", exclude_none=True) for address in args.delivery_addresses_to_add
)
cart.buyerIdentity.deliveryAddressPreferences = existing
_sync_delivery_groups(cart)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
if args.selected_delivery_options:
groups_by_id = {group.id: group for group in cart.deliveryGroups}
for selected in args.selected_delivery_options:
group = groups_by_id.get(selected.deliveryGroupId)
if group is None:
user_errors.append(
{
"field": ["selected_delivery_options", "deliveryGroupId"],
"message": f"Delivery group {selected.deliveryGroupId} not found",
}
)
continue
option = next(
(option for option in group.deliveryOptions if option.handle == selected.deliveryOptionHandle),
None,
)
if option is None:
user_errors.append(
{
"field": ["selected_delivery_options", "deliveryOptionHandle"],
"message": (
f"Delivery option {selected.deliveryOptionHandle} not found "
f"for group {selected.deliveryGroupId}"
),
}
)
continue
group.selectedDeliveryOption = option
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
# Update discount codes
if args.discount_codes is not None:
discount_codes = []
applied_discounts = []
for code in args.discount_codes:
applicable, message = _discount_code_applicability(code, cart)
discount = get_discount_by_code(code)
if applicable and discount is not None:
incompatible = next(
(existing for existing in applied_discounts if not _discounts_can_combine(existing, discount)),
None,
)
if incompatible is not None:
applicable = False
message = f"Discount code '{code}' cannot be combined with '{incompatible.code}'"
else:
applied_discounts.append(discount)
discount_codes.append(CartDiscountCode(code=code, applicable=applicable))
if message is not None:
user_errors.append({"field": ["discount_codes"], "message": message})
cart.discountCodes = discount_codes
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
if args.gift_card_codes is not None:
cart.appliedGiftCards = _apply_gift_cards_to_cart(cart, args.gift_card_codes, user_errors)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
elif cart.appliedGiftCards:
cart.appliedGiftCards = _apply_gift_cards_to_cart(
cart, [applied_gift_card_code(card) for card in cart.appliedGiftCards], user_errors
)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
# Update note
if args.note is not None and args.cart_id: # Only update if not newly created
cart.note = args.note
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
# Return cart with any errors
if user_errors:
return {"cart": cart, "userErrors": user_errors}
return cart
def _cart_subtotal(cart: Any) -> float:
if cart.cost is not None:
return float(cart.cost.subtotalAmount.amount)
return sum(float(line.cost.totalAmount.amount) for line in cart.lines)
def _cart_product_ids(cart: Any) -> set[str]:
product_ids = set()
for line in cart.lines:
product = line.merchandise.product or {}
product_id = product.get("id")
if product_id:
product_ids.add(product_id)
return product_ids
def _discount_code_applicability(code: str, cart: Any) -> tuple[bool, str | None]:
discount = get_discount_by_code(code)
if discount is None:
return False, f"Discount code '{code}' not found"
if not discount.active:
return False, f"Discount code '{code}' is not active"
if discount.usageLimit and discount.usageCount >= discount.usageLimit:
return False, f"Discount code '{code}' has reached its usage limit"
min_purchase = discount.minimumPurchase
if min_purchase and _cart_subtotal(cart) < float(min_purchase.amount):
return False, f"Minimum purchase of ${min_purchase.amount} required for discount code '{code}'"
product_ids = discount.productIds
if product_ids and not _cart_product_ids(cart).intersection(product_ids):
return False, f"Discount code '{code}' does not apply to any products in this cart"
required_tier_name = discount.minimumTier
if required_tier_name:
state = get_state()
required_tier = next((tier for tier in state.loyalty_program.tiers if tier.name == required_tier_name), None)
if required_tier is None:
return False, f"Discount code '{code}' requires tier '{required_tier_name}' which is not configured"
email = cart.buyerIdentity.email
customer = get_customer_by_email(email) if email else None
customer_tier = compute_tier(customer.lifetimePoints, state.loyalty_program.tiers) if customer else None
customer_threshold = customer_tier.min_lifetime_points if customer_tier else -1
if customer_threshold < required_tier.min_lifetime_points:
return False, f"Discount code '{code}' requires '{required_tier_name}' tier or higher"
return True, None
def _discount_combination_class(discount: Any) -> str:
if discount.discountType == "FREE_SHIPPING":
return "shippingDiscounts"
if discount.productIds:
return "productDiscounts"
return "orderDiscounts"
def _discounts_can_combine(first: Any, second: Any) -> bool:
first_allows_second = getattr(first.combinesWith, _discount_combination_class(second))
second_allows_first = getattr(second.combinesWith, _discount_combination_class(first))
return first_allows_second and second_allows_first
def _sync_delivery_groups(cart: Any) -> None:
"""Create one delivery group from the store's active shipping methods."""
if not cart.lines or not cart.buyerIdentity.deliveryAddressPreferences:
cart.deliveryGroups = []
return
selected_option = None
if cart.deliveryGroups:
selected_option = cart.deliveryGroups[0].selectedDeliveryOption
options = [
CartDeliveryOption(
handle=method.id,
title=method.title,
description=method.estimatedDays,
estimatedCost=method.price,
code=method.id,
)
for method in get_state().shipping_methods.values()
if method.active
]
if selected_option is not None and selected_option.handle not in {option.handle for option in options}:
selected_option = None
cart.deliveryGroups = [
CartDeliveryGroup(
id=f"{cart.id}/delivery-group/1",
deliveryOptions=options,
selectedDeliveryOption=selected_option,
cartLines=list(cart.lines),
)
]
def _apply_gift_cards_to_cart(cart: Any, codes: list[str], user_errors: list[dict]) -> list[AppliedGiftCard]:
remaining_total = _cart_subtotal(cart)
applied_cards = []
currency = cart.cost.subtotalAmount.currencyCode if cart.cost is not None else "USD"
for code in codes:
gift_card = get_gift_card_by_code(code)
if gift_card is None:
user_errors.append({"field": ["gift_card_codes"], "message": f"Gift card '{code}' not found"})
continue
if not gift_card.active:
user_errors.append({"field": ["gift_card_codes"], "message": f"Gift card '{code}' is not active"})
continue
balance = float(gift_card.balance.amount)
if balance <= 0:
user_errors.append(
{"field": ["gift_card_codes"], "message": f"Gift card '{code}' has no remaining balance"}
)
continue
amount_used = min(balance, remaining_total)
remaining_total -= amount_used
applied_cards.append(
_gift_card_from_code(code, amount_used, balance - amount_used, gift_card.balance.currencyCode)
)
if remaining_total <= 0:
remaining_total = 0
if cart.cost is not None:
cart.cost.totalAmount = MoneyV2(amount=f"{remaining_total:.2f}", currencyCode=currency)
cart.cost.checkoutChargeAmount = MoneyV2(amount=f"{remaining_total:.2f}", currencyCode=currency)
return applied_cards
def _gift_card_from_code(code: str, amount_used: float, balance: float, currency: str) -> AppliedGiftCard:
last_characters = code[-4:] if len(code) >= 4 else code
return AppliedGiftCard(
id=f"gid://shopify/AppliedGiftCard/{code}",
code=code,
lastCharacters=last_characters,
amountUsed=MoneyV2(amount=f"{amount_used:.2f}", currencyCode=currency),
balance=MoneyV2(amount=f"{balance:.2f}", currencyCode=currency),
presentmentAmountUsed=MoneyV2(amount=f"{amount_used:.2f}", currencyCode=currency),
)
@@ -0,0 +1,551 @@
"""Catalog tool handlers for products, catalog search, and policy/FAQ search."""
import re
from datetime import UTC, datetime
from shopify.models import (
CreateProductArgs,
DeleteProductArgs,
GetProductDetailsArgs,
SearchShopCatalogArgs,
SearchShopPoliciesAndFaqsArgs,
UpdateProductArgs,
)
from shopify.state import (
LooseProduct,
get_next_product_id,
get_next_variant_id,
get_product_by_id,
get_product_filters,
get_state,
save_state,
search_faqs,
search_policies,
search_products,
update_cart_totals,
)
def _product_with_handle_exists(handle: str) -> bool:
return any(product.handle == handle for product in get_state().products.values())
def handle_create_product(args: CreateProductArgs) -> dict:
"""Create a new product with optional variants."""
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
handle = re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
if not handle:
return {
"product": None,
"userErrors": [{"field": "title", "message": "Product title must contain at least one letter or number"}],
}
if _product_with_handle_exists(handle):
return {
"product": None,
"userErrors": [{"field": "title", "message": f"Product handle '{handle}' already exists"}],
}
product_id = get_next_product_id()
product_num = product_id.rsplit("/", 1)[-1]
# Build variants
variants = []
min_price = float("inf")
max_price = 0.0
currency = "USD"
total_inventory = 0
if args.variants:
for v in args.variants:
title = v.title
price_amount = str(v.price)
sku = v.sku
qty = v.quantityAvailable
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "default"
variant_id = get_next_variant_id(product_num, slug)
price_val = float(price_amount)
min_price = min(min_price, price_val)
max_price = max(max_price, price_val)
currency = v.currencyCode
total_inventory += qty or 0
variants.append(
{
"id": variant_id,
"title": title,
"price": {"amount": f"{price_val:.2f}", "currencyCode": currency},
"compareAtPrice": None,
"availableForSale": (qty or 0) > 0,
"sku": sku,
"selectedOptions": [],
"quantityAvailable": qty,
"currentlyNotInStock": (qty or 0) <= 0,
"requiresShipping": True,
"taxable": True,
}
)
else:
# Default single variant
variant_id = get_next_variant_id(product_num, "default")
variants.append(
{
"id": variant_id,
"title": "Default",
"price": {"amount": "0.00", "currencyCode": "USD"},
"compareAtPrice": None,
"availableForSale": False,
"sku": None,
"selectedOptions": [],
"quantityAvailable": 0,
"currentlyNotInStock": True,
"requiresShipping": True,
"taxable": True,
}
)
min_price = 0.0
max_price = 0.0
if min_price == float("inf"):
min_price = 0.0
product = {
"id": product_id,
"title": args.title,
"description": args.description,
"descriptionHtml": f"<p>{args.description}</p>" if args.description else "",
"handle": handle,
"productType": args.product_type,
"vendor": args.vendor,
"tags": args.tags or [],
"availableForSale": any(v.get("availableForSale", False) for v in variants),
"priceRange": {
"minVariantPrice": {"amount": f"{min_price:.2f}", "currencyCode": currency},
"maxVariantPrice": {"amount": f"{max_price:.2f}", "currencyCode": currency},
},
"featuredImage": None,
"images": [],
"options": [],
"variants": variants,
"seo": None,
"onlineStoreUrl": None,
"createdAt": now,
"updatedAt": now,
"publishedAt": now,
"isGiftCard": False,
"totalInventory": total_inventory,
}
state = get_state()
state.products[product_id] = LooseProduct.model_validate(product)
save_state()
return {"product": product, "userErrors": []}
def handle_delete_product(args: DeleteProductArgs) -> dict:
"""Delete a product and clean up references."""
product = get_product_by_id(args.product_id)
if product is None:
return {
"deletedProductId": None,
"userErrors": [{"field": "product_id", "message": f"Product not found: {args.product_id}"}],
}
state = get_state()
variant_ids = {variant.id for variant in product.variants}
# Remove from products
del state.products[args.product_id]
# Remove from any collections
for collection in state.collections.values():
if args.product_id in collection.productIds:
collection.productIds.remove(args.product_id)
if args.product_id in collection.products:
collection.products.remove(args.product_id)
# Remove associated reviews
review_ids_to_delete = [
review_id for review_id, review in state.reviews.items() if review.productId == args.product_id
]
for rid in review_ids_to_delete:
del state.reviews[rid]
for discount_code in state.discount_codes.values():
if discount_code.productIds and args.product_id in discount_code.productIds:
discount_code.productIds = [
product_id for product_id in discount_code.productIds if product_id != args.product_id
]
if not discount_code.productIds:
discount_code.productIds = None
for cart in state.carts.values():
original_count = len(cart.lines)
cart.lines = [line for line in cart.lines if line.merchandise.id not in variant_ids]
if len(cart.lines) != original_count:
update_cart_totals(cart)
cart.deliveryGroups = []
save_state()
return {"deletedProductId": args.product_id, "userErrors": []}
get_product_details_tool = {
"name": "get_product_details",
"description": "Look up a product by ID and optionally specify variant options to select a specific variant.",
"inputSchema": {
"type": "object",
"properties": {
"product_id": {"type": "string", "description": "The product ID, e.g. gid://shopify/Product/123"},
"options": {
"type": "object",
"description": 'Optional variant options to select a specific variant, e.g. {"Size": "10", "Color": "Black"}',
},
"country": {
"type": "string",
"description": "ISO 3166-1 alpha-2 country code for which to return localized results (e.g., 'US', 'CA', 'GB').",
},
"language": {
"type": "string",
"description": "ISO 639-1 language code for which to return localized results (e.g., 'EN', 'FR', 'DE').",
},
},
"required": ["product_id"],
},
}
def handle_get_product_details(args: GetProductDetailsArgs) -> dict:
"""
Handle get_product_details tool call.
Retrieves a product by ID from local state.
Optionally selects a specific variant based on provided options.
"""
product = get_product_by_id(args.product_id)
if product is None:
return {
"product": None,
"userErrors": [{"field": ["product_id"], "message": f"Product {args.product_id} not found"}],
}
# If options provided, find matching variant
selected_variant = None
if args.options and product.variants:
for variant in product.variants:
variant_options = {option.name: option.value for option in variant.selectedOptions}
# Check if all requested options match
if all(variant_options.get(name) == value for name, value in args.options.items()):
selected_variant = variant
break
# Build response
result: dict[str, object] = {
"product": product,
}
if args.options:
if selected_variant:
result["selectedVariant"] = selected_variant
else:
result["selectedVariant"] = None
result["userErrors"] = [
{"field": ["options"], "message": f"No variant found matching options: {args.options}"}
]
if args.country or args.language:
result["localization"] = {"country": args.country, "language": args.language, "applied": False}
return result
search_shop_catalog_tool = {
"name": "search_shop_catalog",
"description": """Search for products from the online store, hosted on Shopify.
This tool can be used to search for products using natural language queries, specific filter criteria, or both.
Mock search behavior:
- An empty query searches all products before filters are applied
- Multiple filters are combined with AND semantics; OR, negation, and exclude filters are not supported
- The category filter matches product category fields or collection membership by collection id, title, or handle
- country and language are accepted for schema compatibility but do not localize catalog data; the response reports them as unapplied hints when provided
- Unsupported filter fields are reported in warnings rather than silently looking like no matches
Best practices:
- Searches return available_filters which can be used for refined follow-up searches
- When filtering, use ONLY the filters from available_filters in follow-up searches
- For specific filter searches (category, variant option, product type, etc.), use simple terms without the filter name (e.g., "red" not "red color")
- For filter-specific searches (e.g., "find burton in snowboards" or "show me all available products in gray / green color"), use a two-step approach:
1. Perform a normal search to discover available filters
2. If relevant filters are returned, do a second search using the proper filter (productType, category, variantOption, etc.) with just the specific search term
- Results are paginated, with initial results limited to improve experience
- Use the after parameter with endCursor to fetch additional pages when users request more results
The response includes product details, available variants, filter options, and pagination info.""",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "A natural language query."},
"filters": {
"type": "array",
"description": "Filters to apply to the search. Only apply filters from the available_filters returned in a previous response.",
"items": {
"type": "object",
"properties": {
"available": {
"type": "boolean",
"description": "Filter on if the product is available for sale",
},
"category": {"type": "object", "properties": {"id": {"type": "string"}}},
"price": {
"type": "object",
"properties": {"min": {"type": "number"}, "max": {"type": "number"}},
},
"productMetafield": {
"type": "object",
"properties": {
"key": {"type": "string"},
"namespace": {"type": "string"},
"value": {"type": "string"},
},
},
"productType": {"type": "string", "description": "Product type to filter by"},
"productVendor": {"type": "string", "description": "Product vendor to filter by"},
"tag": {"type": "string", "description": "Tag to filter by"},
"taxonomyMetafield": {
"type": "object",
"properties": {
"key": {"type": "string"},
"namespace": {"type": "string"},
"value": {"type": "string"},
},
},
"variantMetafield": {
"type": "object",
"properties": {
"key": {"type": "string"},
"namespace": {"type": "string"},
"value": {"type": "string"},
},
},
"variantOption": {
"type": "object",
"properties": {"name": {"type": "string"}, "value": {"type": "string"}},
},
},
},
},
"country": {
"type": "string",
"description": "ISO 3166-1 alpha-2 country code hint. Accepted for compatibility; catalog localization is not available.",
},
"language": {
"type": "string",
"description": "ISO 639-1 language code hint. Accepted for compatibility; catalog localization is not available.",
},
"limit": {
"type": "integer",
"description": "Maximum number of products to return. Defaults to 10, maximum is 250.",
"default": 10,
},
"after": {"type": "string", "description": "Pagination cursor to fetch the next page of results."},
"context": {
"type": "string",
"description": "Additional information about the request such as user demographics, mood, location, or other relevant details.",
},
},
"required": ["query", "context"],
},
}
def _catalog_search_warnings(args: SearchShopCatalogArgs) -> list[str]:
warnings: list[str] = []
def add(message: str) -> None:
if message not in warnings:
warnings.append(message)
extra_args = getattr(args, "model_extra", None) or {}
for field in sorted(extra_args):
add(f"Unsupported catalog search argument '{field}' was ignored.")
for search_filter in args.filters or []:
filter_extra = getattr(search_filter, "model_extra", None) or {}
for field in sorted(filter_extra):
add(f"Unsupported catalog filter field '{field}' was ignored.")
for field in ("productMetafield", "taxonomyMetafield", "variantMetafield"):
if getattr(search_filter, field, None) is not None:
add(f"Catalog filter '{field}' is accepted by the schema but is not available in catalog search.")
if (
search_filter.price
and search_filter.price.min is not None
and search_filter.price.max is not None
and search_filter.price.min > search_filter.price.max
):
add("Price filter min is greater than max; no products can match that filter.")
return warnings
def _normalize_catalog_pagination(args: SearchShopCatalogArgs) -> tuple[int, str | None, list[str]]:
warnings: list[str] = []
limit = args.limit
after = args.after
if limit > 250:
warnings.append("limit exceeds the maximum of 250; using 250.")
limit = 250
if after is not None:
try:
if int(after) < 0:
warnings.append("after cursor must be non-negative; using the first page.")
after = None
except ValueError:
warnings.append(f"Invalid after cursor '{after}'; using the first page.")
after = None
return limit, after, warnings
def handle_search_shop_catalog(args: SearchShopCatalogArgs) -> dict:
"""
Handle search_shop_catalog tool call.
Searches products in the local state by query and filters.
"""
limit, after, pagination_warnings = _normalize_catalog_pagination(args)
# Search products
products, has_next_page, end_cursor, total_count = search_products(
query=args.query,
filters=args.filters,
limit=limit,
after=after,
)
# Get available filters from all products (for filter discovery)
all_products = list(get_state().products.values())
product_filters = get_product_filters(all_products)
# Build response
result = {
"nodes": products,
"pageInfo": {
"hasNextPage": has_next_page,
"hasPreviousPage": after is not None,
"startCursor": "0" if products else None,
"endCursor": end_cursor,
},
"productFilters": product_filters,
"totalCount": total_count,
"warnings": _catalog_search_warnings(args) + pagination_warnings,
}
if args.country or args.language:
result["localization"] = {"country": args.country, "language": args.language, "applied": False}
return result
search_shop_policies_and_faqs_tool = {
"name": "search_shop_policies_and_faqs",
"description": """Used to get facts about the stores policies, products, or services.
Some examples of questions you can ask are:
- What is your return policy?
- What is your shipping policy?
- What is your phone number?
- What are your hours of operation?
Returned result objects use a reserved type field normalized to either "policy" or "faq",
even if fixture data contains its own type value.
""",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "A natural language query."},
"context": {
"type": "string",
"description": "Additional information about the request such as user demographics, mood, location, or other relevant details that could help in tailoring the response appropriately.",
},
},
"required": ["query"],
},
}
def handle_search_shop_policies_and_faqs(args: SearchShopPoliciesAndFaqsArgs) -> dict:
"""
Handle search_shop_policies_and_faqs tool call.
Searches policies in the local state.
"""
matching_policies = search_policies(args.query)
matching_faqs = search_faqs(args.query)
results = [{**policy, "type": "policy"} for policy in matching_policies] + [
{**faq, "type": "faq"} for faq in matching_faqs
]
# Generate a simple answer if policies found
answer = None
if results:
# Use first matching policy body as the answer
first = results[0]
answer = first.get("body") or first.get("answer")
return {
"results": results,
"answer": answer,
}
def _product_handle_exists_for_other(handle: str, *, excluding_product_id: str) -> bool:
return any(
product.id != excluding_product_id and product.handle == handle for product in get_state().products.values()
)
def handle_update_product(args: UpdateProductArgs) -> dict:
"""Update fields on an existing product."""
product = get_product_by_id(args.product_id)
if product is None:
return {
"product": None,
"userErrors": [{"field": "product_id", "message": f"Product not found: {args.product_id}"}],
}
if args.title is not None:
handle = re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
if not handle:
return {
"product": None,
"userErrors": [
{"field": "title", "message": "Product title must contain at least one letter or number"}
],
}
if _product_handle_exists_for_other(handle, excluding_product_id=args.product_id):
return {
"product": None,
"userErrors": [{"field": "title", "message": f"Product handle '{handle}' already exists"}],
}
product.title = args.title
product.handle = handle
if args.description is not None:
product.description = args.description
product.descriptionHtml = f"<p>{args.description}</p>" if args.description else ""
if args.product_type is not None:
product.productType = args.product_type
if args.vendor is not None:
product.vendor = args.vendor
if args.tags is not None:
product.tags = args.tags
product.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {"product": product, "userErrors": []}
@@ -0,0 +1,217 @@
"""Customer tool handlers."""
import contextlib
from datetime import UTC, datetime
from shopify.models import (
CreateCustomerArgs,
GetCustomerArgs,
ListCustomersArgs,
MailingAddress,
SearchCustomersArgs,
UpdateCustomerArgs,
)
from shopify.state import (
LooseCustomer,
_parse_query_tokens,
get_all_customers,
get_customer_by_email,
get_customer_by_id,
get_next_customer_id,
get_state,
save_state,
)
def handle_create_customer(args: CreateCustomerArgs) -> dict:
"""Create a new customer account."""
# Check for duplicate email
existing = get_customer_by_email(args.email)
if existing is not None:
return {
"customer": None,
"userErrors": [{"field": "email", "message": f"Customer with email {args.email} already exists"}],
}
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
customer_id = get_next_customer_id()
address = args.address
addresses = [address] if address else []
customer = {
"id": customer_id,
"firstName": args.first_name,
"lastName": args.last_name,
"email": args.email,
"phone": args.phone,
"createdAt": now,
"updatedAt": now,
"defaultAddress": address,
"addresses": addresses,
"ordersCount": 0,
"totalSpent": {"amount": "0.00", "currencyCode": "USD"},
"tags": args.tags or [],
"note": args.note,
"acceptsMarketing": args.accepts_marketing,
"state": "ENABLED",
}
state = get_state()
state.customers[customer_id] = LooseCustomer.model_validate(customer)
save_state()
return {"customer": customer, "userErrors": []}
def handle_get_customer(args: GetCustomerArgs) -> dict:
"""Retrieve a customer by their ID."""
customer = get_customer_by_id(args.customer_id)
if customer is None:
return {
"customer": None,
"userErrors": [{"field": "customer_id", "message": f"Customer not found: {args.customer_id}"}],
}
return {"customer": customer, "userErrors": []}
def handle_list_customers(args: ListCustomersArgs) -> dict:
"""List customers with optional query and tag filtering."""
customers = get_all_customers()
# Filter by search query (name or email)
if args.query:
query_lower = args.query.lower()
customers = [
customer
for customer in customers
if query_lower in (customer.firstName or "").lower()
or query_lower in (customer.lastName or "").lower()
or query_lower in customer.email.lower()
or query_lower in f"{customer.firstName or ''} {customer.lastName or ''}".lower()
]
# Filter by tag
if args.tag:
tag_lower = args.tag.lower()
customers = [customer for customer in customers if tag_lower in [tag.lower() for tag in customer.tags]]
# Sort by creation date descending
customers.sort(key=lambda customer: customer.createdAt, reverse=True)
total_count = len(customers)
# Pagination
start_idx = 0
if args.after:
with contextlib.suppress(ValueError):
start_idx = int(args.after) + 1
end_idx = start_idx + args.limit
paginated = customers[start_idx:end_idx]
has_next = end_idx < total_count
end_cursor = str(end_idx - 1) if paginated else None
return {
"customers": paginated,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": start_idx > 0,
"startCursor": str(start_idx) if paginated else None,
"endCursor": end_cursor,
},
"totalCount": total_count,
}
def handle_search_customers(args: SearchCustomersArgs) -> dict:
"""Search customers by name, email, or phone.
Whitespace-separated tokens are ANDed against a haystack of
first/last/email/phone; double-quoted segments must appear contiguously.
Structured fields (email, phone) stay in the haystack so single-token
substring matches (e.g. "@example.com") continue to work.
"""
customers = get_all_customers()
tokens = _parse_query_tokens(args.query)
# Preserve original for exact-email relevance bump below.
query_lower = args.query.lower().strip()
matches = []
for customer in customers:
first = customer.firstName or ""
last = customer.lastName or ""
email = customer.email
phone = customer.phone or ""
# Group naturally-composite fields (full name) with spaces so phrases
# like "alice smith" still match, and separate other field groups with
# \n so phrases don't accidentally span into an unrelated field like
# an email address.
full_name = f"{first} {last}".strip()
haystack = f"{full_name}\n{email}\n{phone}".lower()
if tokens and all(t in haystack for t in tokens):
matches.append(customer)
# Sort by relevance (exact email match first, then by name)
matches.sort(
key=lambda c: (
0 if c.email.lower() == query_lower else 1,
(c.lastName or "").lower(),
(c.firstName or "").lower(),
)
)
return {
"customers": matches[: args.limit],
"totalCount": len(matches),
}
def _set_default_address(customer: LooseCustomer, address: MailingAddress) -> None:
"""Set default address and keep one copy of that address in the address book."""
customer.defaultAddress = address
customer.addresses = [existing for existing in customer.addresses if existing != address] + [address]
def handle_update_customer(args: UpdateCustomerArgs) -> dict:
"""Update fields on an existing customer."""
customer = get_customer_by_id(args.customer_id)
if customer is None:
return {
"customer": None,
"userErrors": [{"field": "customer_id", "message": f"Customer not found: {args.customer_id}"}],
}
if args.email is not None:
existing = get_customer_by_email(args.email)
if existing is not None and existing.id != customer.id:
return {
"customer": None,
"userErrors": [{"field": "email", "message": f"Customer with email {args.email} already exists"}],
}
address = MailingAddress.model_validate(args.address) if args.address is not None else None
if args.first_name is not None:
customer.firstName = args.first_name
if args.last_name is not None:
customer.lastName = args.last_name
if args.email is not None:
customer.email = args.email
if args.phone is not None:
customer.phone = args.phone
if args.tags is not None:
customer.tags = args.tags
if args.note is not None:
customer.note = args.note
if args.accepts_marketing is not None:
customer.acceptsMarketing = args.accepts_marketing
if address is not None:
_set_default_address(customer, address)
customer.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {"customer": customer, "userErrors": []}
@@ -0,0 +1,159 @@
"""Discount code tool handlers."""
from datetime import UTC, datetime
from shopify.models import (
CreateDiscountCodeArgs,
DeleteDiscountCodeArgs,
GetDiscountCodeArgs,
ListDiscountCodesArgs,
MoneyV2,
UpdateDiscountCodeArgs,
)
from shopify.state import (
LooseDiscountCode,
get_all_discount_codes,
get_discount_by_code,
get_next_discount_id,
get_state,
save_state,
)
def _missing_product_errors(product_ids: list[str] | None) -> list[dict]:
if product_ids is None:
return []
state = get_state()
seen = set()
errors = []
for product_id in product_ids:
if product_id in seen:
continue
seen.add(product_id)
if product_id not in state.products:
errors.append({"field": "product_ids", "message": f"Product not found: {product_id}"})
return errors
def _minimum_tier_errors(minimum_tier: str | None) -> list[dict]:
if not minimum_tier:
return []
tiers = get_state().loyalty_program.tiers
if any(tier.name == minimum_tier for tier in tiers):
return []
return [{"field": "minimum_tier", "message": f"Loyalty tier not found: {minimum_tier}"}]
def handle_create_discount_code(args: CreateDiscountCodeArgs) -> dict:
"""Create a new discount code."""
# Check for duplicate
existing = get_discount_by_code(args.code)
if existing is not None:
return {
"discountCode": None,
"userErrors": [{"field": "code", "message": f"Discount code '{args.code}' already exists"}],
}
product_errors = _missing_product_errors(args.product_ids)
if product_errors:
return {"discountCode": None, "userErrors": product_errors}
tier_errors = _minimum_tier_errors(args.minimum_tier)
if tier_errors:
return {"discountCode": None, "userErrors": tier_errors}
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
discount_id = get_next_discount_id()
dc = {
"id": discount_id,
"code": args.code.upper(),
"discountType": args.discount_type,
"value": args.value,
"minimumPurchase": {"amount": f"{args.minimum_purchase:.2f}", "currencyCode": "USD"}
if args.minimum_purchase
else None,
"minimumTier": args.minimum_tier,
"usageLimit": args.usage_limit,
"usageCount": 0,
"productIds": list(dict.fromkeys(args.product_ids)) if args.product_ids else None,
"active": True,
"createdAt": now,
"updatedAt": now,
}
state = get_state()
discount = LooseDiscountCode.model_validate(dc)
state.discount_codes[discount_id] = discount
save_state()
return {"discountCode": discount, "userErrors": []}
def handle_get_discount_code(args: GetDiscountCodeArgs) -> dict:
"""Look up a discount code."""
dc = get_discount_by_code(args.code)
if dc is None:
return {
"discountCode": None,
"userErrors": [{"field": "code", "message": f"Discount code '{args.code}' not found"}],
}
return {"discountCode": dc, "userErrors": []}
def handle_list_discount_codes(args: ListDiscountCodesArgs) -> dict:
"""List all discount codes."""
codes = get_all_discount_codes()
if args.active_only:
codes = [code for code in codes if code.active]
codes.sort(key=lambda code: code.code)
return {"discountCodes": codes, "totalCount": len(codes)}
def handle_update_discount_code(args: UpdateDiscountCodeArgs) -> dict:
"""Update a discount code."""
dc = get_discount_by_code(args.code)
if dc is None:
return {
"discountCode": None,
"userErrors": [{"field": "code", "message": f"Discount code '{args.code}' not found"}],
}
product_errors = _missing_product_errors(args.product_ids)
if product_errors:
return {"discountCode": None, "userErrors": product_errors}
tier_errors = _minimum_tier_errors(args.minimum_tier)
if tier_errors:
return {"discountCode": None, "userErrors": tier_errors}
if args.active is not None:
dc.active = args.active
if args.value is not None:
dc.value = args.value
if args.usage_limit is not None:
dc.usageLimit = args.usage_limit
if args.minimum_purchase is not None:
dc.minimumPurchase = MoneyV2(amount=f"{args.minimum_purchase:.2f}", currencyCode="USD")
if args.product_ids is not None:
dc.productIds = list(dict.fromkeys(args.product_ids)) or None
if args.minimum_tier is not None:
# Empty string clears the restriction; any other value sets it
dc.minimumTier = args.minimum_tier or None
dc.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {"discountCode": dc, "userErrors": []}
def handle_delete_discount_code(args: DeleteDiscountCodeArgs) -> dict:
"""Delete a discount code."""
dc = get_discount_by_code(args.code)
if dc is None:
return {
"deletedCode": None,
"userErrors": [{"field": "code", "message": f"Discount code '{args.code}' not found"}],
}
state = get_state()
del state.discount_codes[dc.id]
save_state()
return {"deletedCode": args.code.upper(), "userErrors": []}
@@ -0,0 +1,291 @@
"""Inventory and collection tool handlers."""
import contextlib
import re
from datetime import UTC, datetime
from shopify.models import (
AddToCollectionArgs,
CreateCollectionArgs,
GetCollectionArgs,
GetInventoryArgs,
ListCollectionsArgs,
RemoveFromCollectionArgs,
UpdateInventoryArgs,
)
from shopify.state import (
LooseCollection,
get_all_collections,
get_all_variants_with_inventory,
get_collection_by_id,
get_next_collection_id,
get_state,
get_variant_by_id,
save_state,
)
def handle_add_to_collection(args: AddToCollectionArgs) -> dict:
"""Add products to a collection."""
collection = get_collection_by_id(args.collection_id)
if collection is None:
return {
"collection": None,
"userErrors": [{"field": "collection_id", "message": f"Collection not found: {args.collection_id}"}],
}
state = get_state()
added = []
already_in = []
not_found = []
planned_product_ids = set(collection.productIds)
for pid in args.product_ids:
if pid not in state.products:
not_found.append(pid)
elif pid in planned_product_ids:
already_in.append(pid)
else:
added.append(pid)
planned_product_ids.add(pid)
if added:
collection.productIds.extend(added)
collection.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
errors = []
for pid in not_found:
errors.append({"field": "product_ids", "message": f"Product not found: {pid}"})
return {
"collection": collection,
"added": added,
"alreadyInCollection": already_in,
"userErrors": errors,
}
def handle_create_collection(args: CreateCollectionArgs) -> dict:
"""Create a new collection."""
# Check for duplicate title
for coll in get_all_collections():
if coll.get("title", "").lower() == args.title.lower():
return {
"collection": None,
"userErrors": [{"field": "title", "message": f"Collection '{args.title}' already exists"}],
}
handle = re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
if not handle:
return {
"collection": None,
"userErrors": [
{"field": "title", "message": "Collection title must contain at least one letter or number"}
],
}
# Validate product IDs if provided
product_ids = []
state = get_state()
if args.product_ids:
not_found = [pid for pid in args.product_ids if pid not in state.products]
if not_found:
return {
"collection": None,
"userErrors": [{"field": "product_ids", "message": f"Product not found: {pid}"} for pid in not_found],
}
for pid in args.product_ids:
if pid not in product_ids:
product_ids.append(pid)
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
collection_id = get_next_collection_id()
collection = {
"id": collection_id,
"title": args.title,
"description": args.description,
"handle": handle,
"productIds": product_ids,
"createdAt": now,
"updatedAt": now,
"sortOrder": args.sort_order,
"image": None,
}
state.collections[collection_id] = LooseCollection.model_validate(collection)
save_state()
return {"collection": collection, "userErrors": []}
def handle_get_collection(args: GetCollectionArgs) -> dict:
"""Retrieve a collection with its products."""
collection = get_collection_by_id(args.collection_id)
if collection is None:
return {
"collection": None,
"userErrors": [{"field": "collection_id", "message": f"Collection not found: {args.collection_id}"}],
}
# Resolve product details
state = get_state()
products = []
for product_id in collection.productIds:
product = state.products.get(product_id)
if product:
products.append(product)
return {
"collection": collection,
"products": products,
"productCount": len(products),
"userErrors": [],
}
def handle_get_inventory(args: GetInventoryArgs) -> dict:
"""Get inventory levels, optionally filtered by product or low stock threshold."""
variants = get_all_variants_with_inventory()
# Filter by product
if args.product_id:
variants = [v for v in variants if v["productId"] == args.product_id]
if not variants:
# Check if the product exists at all
state = get_state()
if args.product_id not in state.products:
return {
"inventory": [],
"totalCount": 0,
"userErrors": [{"field": "product_id", "message": f"Product not found: {args.product_id}"}],
}
# Filter by low stock threshold
if args.low_stock_threshold is not None:
variants = [
v
for v in variants
if v["quantityAvailable"] is not None and v["quantityAvailable"] <= args.low_stock_threshold
]
return {
"inventory": variants,
"totalCount": len(variants),
"userErrors": [],
}
def handle_list_collections(args: ListCollectionsArgs) -> dict:
"""List all collections with pagination."""
collections = get_all_collections()
collections.sort(key=lambda collection: collection.title.lower())
total_count = len(collections)
start_idx = 0
if args.after:
with contextlib.suppress(ValueError):
start_idx = int(args.after) + 1
end_idx = start_idx + args.limit
paginated = collections[start_idx:end_idx]
has_next = end_idx < total_count
end_cursor = str(end_idx - 1) if paginated else None
# Add product count to each collection summary
summaries = []
for collection in paginated:
summaries.append(
{
"id": collection.id,
"title": collection.title,
"description": collection.description,
"handle": collection.handle or "",
"productCount": len(collection.productIds),
"sortOrder": collection.sortOrder,
"createdAt": collection.createdAt,
"updatedAt": collection.updatedAt,
}
)
return {
"collections": summaries,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": start_idx > 0,
"startCursor": str(start_idx) if paginated else None,
"endCursor": end_cursor,
},
"totalCount": total_count,
}
def handle_remove_from_collection(args: RemoveFromCollectionArgs) -> dict:
"""Remove products from a collection."""
collection = get_collection_by_id(args.collection_id)
if collection is None:
return {
"collection": None,
"userErrors": [{"field": "collection_id", "message": f"Collection not found: {args.collection_id}"}],
}
removed = []
not_in = []
remaining_product_ids = list(collection.productIds)
for pid in args.product_ids:
if pid in remaining_product_ids:
remaining_product_ids.remove(pid)
removed.append(pid)
else:
not_in.append(pid)
if removed:
collection.productIds = remaining_product_ids
collection.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {
"collection": collection,
"removed": removed,
"notInCollection": not_in,
"userErrors": [],
}
def handle_update_inventory(args: UpdateInventoryArgs) -> dict:
"""Update the quantity available for a product variant."""
product, variant = get_variant_by_id(args.variant_id)
if variant is None:
return {
"inventoryItem": None,
"userErrors": [{"field": "variant_id", "message": f"Variant not found: {args.variant_id}"}],
}
old_quantity = variant.quantityAvailable
variant.quantityAvailable = args.quantity
variant.currentlyNotInStock = args.quantity <= 0
variant.availableForSale = args.quantity > 0
# Update product-level totalInventory
if product:
total = sum(product_variant.quantityAvailable or 0 for product_variant in product.variants)
product.totalInventory = total
# Update product availableForSale based on any variant being available
product.availableForSale = any(product_variant.availableForSale for product_variant in product.variants)
save_state()
return {
"inventoryItem": {
"variantId": args.variant_id,
"previousQuantity": old_quantity,
"newQuantity": args.quantity,
"productId": product.id if product else None,
"sku": variant.sku,
},
"userErrors": [],
}
@@ -0,0 +1,214 @@
"""Loyalty program tools — points, tiers, redemption."""
from datetime import UTC, datetime
from shopify.models import (
AwardPointsArgs,
ConfigureLoyaltyProgramArgs,
GetLoyaltyBalanceArgs,
GetLoyaltyProgramArgs,
GetLoyaltyTierArgs,
ListLoyaltyTiersArgs,
LoyaltyTier,
RedeemPointsArgs,
)
from shopify.state import (
LooseCustomer,
LoyaltyProgram,
get_customer_by_id,
get_state,
save_state,
)
def compute_tier(lifetime_points: int, tiers: list[LoyaltyTier]) -> LoyaltyTier | None:
"""Return the highest tier whose threshold is met, or None if no tier qualifies."""
eligible = [tier for tier in tiers if lifetime_points >= tier.min_lifetime_points]
if not eligible:
return None
return max(eligible, key=lambda tier: tier.min_lifetime_points)
def _ensure_loyalty_fields(customer: LooseCustomer, now: str) -> None:
"""Backfill loyalty fields on customers created before the program existed."""
if customer.loyaltyJoinedAt is None:
customer.loyaltyJoinedAt = now
def _sync_tier(customer: LooseCustomer, program: LoyaltyProgram) -> None:
"""Recompute and store the customer's tier based on current lifetime points."""
tier_obj = compute_tier(customer.lifetimePoints, program.tiers)
customer.tier = tier_obj.name if tier_obj else None
def handle_configure_loyalty_program(args: ConfigureLoyaltyProgramArgs) -> dict:
"""Configure the store's loyalty program. Passes any non-None fields through."""
state = get_state()
program = state.loyalty_program
if args.earn_rate is not None and args.earn_rate < 0:
return {"program": None, "userErrors": [{"field": "earn_rate", "message": "earn_rate must be >= 0"}]}
if args.redemption_rate is not None and args.redemption_rate <= 0:
return {
"program": None,
"userErrors": [{"field": "redemption_rate", "message": "redemption_rate must be > 0"}],
}
if args.max_redemption_percent is not None and not (0 <= args.max_redemption_percent <= 100):
return {
"program": None,
"userErrors": [
{"field": "max_redemption_percent", "message": "max_redemption_percent must be between 0 and 100"}
],
}
if args.enabled is not None:
program.enabled = args.enabled
if args.earn_rate is not None:
program.earn_rate = args.earn_rate
if args.redemption_rate is not None:
program.redemption_rate = args.redemption_rate
if args.max_redemption_percent is not None:
program.max_redemption_percent = args.max_redemption_percent
if args.tiers is not None:
program.tiers = args.tiers
# Any customer's tier may have shifted if tier config changed
for customer in state.customers.values():
if customer.loyaltyJoinedAt:
_sync_tier(customer, program)
save_state()
return {"program": program, "userErrors": []}
def handle_get_loyalty_program(args: GetLoyaltyProgramArgs) -> dict:
"""Read the current loyalty program configuration."""
state = get_state()
return {"program": state.loyalty_program, "userErrors": []}
def handle_list_loyalty_tiers(args: ListLoyaltyTiersArgs) -> dict:
"""List all loyalty tiers sorted by threshold ascending."""
state = get_state()
tiers = sorted(state.loyalty_program.tiers, key=lambda tier: tier.min_lifetime_points)
return {"tiers": tiers, "totalCount": len(tiers)}
def handle_get_loyalty_balance(args: GetLoyaltyBalanceArgs) -> dict:
"""Get a customer's loyalty balance, lifetime points, and current tier."""
customer = get_customer_by_id(args.customer_id)
if customer is None:
return {
"balance": None,
"userErrors": [{"field": "customer_id", "message": f"Customer not found: {args.customer_id}"}],
}
return {
"balance": {
"customerId": customer.id,
"pointsBalance": customer.pointsBalance,
"lifetimePoints": customer.lifetimePoints,
"tier": customer.tier,
"loyaltyJoinedAt": customer.loyaltyJoinedAt,
},
"userErrors": [],
}
def handle_get_loyalty_tier(args: GetLoyaltyTierArgs) -> dict:
"""Get the full tier object for a customer's current tier (or null)."""
customer = get_customer_by_id(args.customer_id)
if customer is None:
return {
"tier": None,
"userErrors": [{"field": "customer_id", "message": f"Customer not found: {args.customer_id}"}],
}
state = get_state()
tier_obj = compute_tier(customer.lifetimePoints, state.loyalty_program.tiers)
return {"tier": tier_obj, "userErrors": []}
def handle_award_points(args: AwardPointsArgs) -> dict:
"""Award loyalty points to a customer. Grows both balance and lifetime points."""
if args.points <= 0:
return {"balance": None, "userErrors": [{"field": "points", "message": "points must be positive"}]}
customer = get_customer_by_id(args.customer_id)
if customer is None:
return {
"balance": None,
"userErrors": [{"field": "customer_id", "message": f"Customer not found: {args.customer_id}"}],
}
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
_ensure_loyalty_fields(customer, now)
customer.pointsBalance += args.points
customer.lifetimePoints += args.points
customer.updatedAt = now
state = get_state()
_sync_tier(customer, state.loyalty_program)
save_state()
return {
"balance": {
"customerId": customer.id,
"pointsBalance": customer.pointsBalance,
"lifetimePoints": customer.lifetimePoints,
"tier": customer.tier,
"pointsAwarded": args.points,
"reason": args.reason,
},
"userErrors": [],
}
def handle_redeem_points(args: RedeemPointsArgs) -> dict:
"""Redeem loyalty points for dollar value. Deducts from balance; lifetime points unchanged."""
if args.points <= 0:
return {
"redemption": None,
"userErrors": [{"field": "points", "message": "points must be positive"}],
}
customer = get_customer_by_id(args.customer_id)
if customer is None:
return {
"redemption": None,
"userErrors": [{"field": "customer_id", "message": f"Customer not found: {args.customer_id}"}],
}
balance = customer.pointsBalance
if args.points > balance:
return {
"redemption": None,
"userErrors": [
{
"field": "points",
"message": f"Insufficient balance: requested {args.points}, available {balance}",
}
],
}
state = get_state()
redemption_rate = state.loyalty_program.redemption_rate
if redemption_rate <= 0:
return {
"redemption": None,
"userErrors": [{"field": "redemption_rate", "message": "Loyalty program misconfigured"}],
}
dollar_value = args.points / redemption_rate
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
customer.pointsBalance = balance - args.points
customer.updatedAt = now
save_state()
return {
"redemption": {
"customerId": customer.id,
"pointsRedeemed": args.points,
"dollarValue": {"amount": f"{dollar_value:.2f}", "currencyCode": "USD"},
"pointsBalance": customer.pointsBalance,
},
"userErrors": [],
}
@@ -0,0 +1,231 @@
"""Helpers for reversing side effects created during checkout."""
from typing import Any
from shopify.models import MoneyV2
from shopify.state import get_customer_by_email, get_discount_by_code, get_gift_card_by_code, get_state
from shopify.tools.loyalty import compute_tier
def _value_get(value: Any, key: str, default: Any = None) -> Any:
if isinstance(value, dict):
return value.get(key, default)
if hasattr(value, "get"):
return value.get(key, default)
return getattr(value, key, default)
def _money_amount(value: Any) -> float:
if isinstance(value, MoneyV2):
return float(value.amount)
if isinstance(value, dict):
return float(value.get("amount", "0") or 0)
return float(getattr(value, "amount", "0") or 0)
def applied_gift_card_code(applied_card: Any) -> str:
"""Return the backing gift-card code for an applied cart/order card."""
code = _value_get(applied_card, "code")
if code:
return str(code)
card_id = _value_get(applied_card, "id")
return str(card_id).rsplit("/", 1)[-1] if card_id else ""
def _order_effect_totals(order: Any) -> dict[str, Any]:
totals = order.get("reversedEffects") or {}
if not isinstance(totals, dict):
totals = {}
totals.setdefault("customerSpendAmount", "0.00")
totals.setdefault("giftCardAmount", "0.00")
totals.setdefault("loyaltyPointsEarned", 0)
totals.setdefault("loyaltyPointsRedeemed", 0)
order["reversedEffects"] = totals
return totals
def _add_reversed_money(order: Any, key: str, amount: float) -> None:
totals = _order_effect_totals(order)
current = float(totals.get(key, "0") or 0)
totals[key] = f"{current + amount:.2f}"
def _add_reversed_points(order: Any, key: str, points: int) -> None:
totals = _order_effect_totals(order)
totals[key] = int(totals.get(key) or 0) + points
def _total_gift_card_amount(order: Any) -> float:
return sum(
_money_amount(_value_get(applied_card, "amountUsed", {}))
for applied_card in order.get("appliedGiftCards", []) or []
)
def _restore_gift_card_balances(order: Any, amount: float) -> float:
remaining = max(amount, 0.0)
restored = 0.0
for applied_card in order.get("appliedGiftCards", []) or []:
if remaining <= 0:
break
code = applied_gift_card_code(applied_card)
if not code:
continue
gift_card = get_gift_card_by_code(code)
if gift_card is None:
continue
amount_used = _money_amount(_value_get(applied_card, "amountUsed", {}))
if amount_used <= 0:
continue
restore_amount = min(amount_used, remaining)
balance = float(gift_card.balance.amount)
gift_card.balance = MoneyV2(
amount=f"{balance + restore_amount:.2f}",
currencyCode=gift_card.balance.currencyCode,
)
remaining -= restore_amount
restored += restore_amount
return restored
def _reverse_customer_amount(order: Any, amount: float, *, reversed_at: str) -> float:
customer = get_customer_by_email(order.email) if order.email else None
if customer is None or amount <= 0:
return 0.0
prior_spent = float(customer.totalSpent.amount) if customer.totalSpent is not None else 0.0
reversed_total = min(prior_spent, amount)
customer.totalSpent = MoneyV2(
amount=f"{prior_spent - reversed_total:.2f}",
currencyCode=order.totalPrice.currencyCode,
)
customer.updatedAt = reversed_at
return reversed_total
def _reverse_loyalty_points(order: Any, earned: int, redeemed: int, *, reversed_at: str) -> tuple[int, int]:
customer = get_customer_by_email(order.email) if order.email else None
if customer is None or (earned <= 0 and redeemed <= 0):
return (0, 0)
customer.pointsBalance = max(0, customer.pointsBalance - earned + redeemed)
customer.lifetimePoints = max(0, customer.lifetimePoints - earned)
tier_obj = compute_tier(customer.lifetimePoints, get_state().loyalty_program.tiers)
customer.tier = tier_obj.name if tier_obj else None
customer.updatedAt = reversed_at
return (earned, redeemed)
def reverse_return_effects(order: Any, return_obj: Any, *, reversed_at: str) -> bool:
"""Undo proportional checkout effects for one newly-refunded return."""
if return_obj.get("sideEffectsReversedAt") is not None:
return False
refund_amount = _money_amount(return_obj.refundAmount)
if refund_amount <= 0:
return_obj["sideEffectsReversedAt"] = reversed_at
return False
totals = _order_effect_totals(order)
order_total = float(order.totalPrice.amount)
gift_card_total = _total_gift_card_amount(order)
settlement_total = order_total + gift_card_total
customer_remaining = order_total - float(totals["customerSpendAmount"])
gift_card_remaining = gift_card_total - float(totals["giftCardAmount"])
if settlement_total > 0 and gift_card_total > 0:
gift_card_restore = min(gift_card_remaining, refund_amount * (gift_card_total / settlement_total))
customer_restore = min(customer_remaining, refund_amount * (order_total / settlement_total))
else:
gift_card_restore = 0.0
customer_restore = min(customer_remaining, refund_amount)
restored_gift_card = _restore_gift_card_balances(order, gift_card_restore)
restored_customer_spend = _reverse_customer_amount(order, customer_restore, reversed_at=reversed_at)
subtotal = _money_amount(order.subtotalPrice)
ratio = min(refund_amount / subtotal, 1.0) if subtotal > 0 else 0.0
earned_total = int(order.get("loyaltyPointsEarned") or 0)
redeemed_total = int(order.get("loyaltyPointsRedeemed") or 0)
earned_to_reverse = min(
earned_total - int(totals["loyaltyPointsEarned"]),
round(earned_total * ratio),
)
redeemed_to_reverse = min(
redeemed_total - int(totals["loyaltyPointsRedeemed"]),
round(redeemed_total * ratio),
)
reversed_earned, reversed_redeemed = _reverse_loyalty_points(
order,
earned_to_reverse,
redeemed_to_reverse,
reversed_at=reversed_at,
)
_add_reversed_money(order, "giftCardAmount", restored_gift_card)
_add_reversed_money(order, "customerSpendAmount", restored_customer_spend)
_add_reversed_points(order, "loyaltyPointsEarned", reversed_earned)
_add_reversed_points(order, "loyaltyPointsRedeemed", reversed_redeemed)
return_obj["sideEffectsReversedAt"] = reversed_at
return_obj["reversedEffects"] = {
"customerSpendAmount": f"{restored_customer_spend:.2f}",
"giftCardAmount": f"{restored_gift_card:.2f}",
"loyaltyPointsEarned": reversed_earned,
"loyaltyPointsRedeemed": reversed_redeemed,
}
return True
def reverse_order_effects(order: Any, *, reversed_at: str) -> bool:
"""Undo remaining checkout effects for a fully cancelled/refunded order.
Returns True when this call performed the reversal. Returns False when the
order had already been reversed, which makes cancellation/refund paths
idempotent across later state transitions.
"""
if order.get("sideEffectsReversedAt") is not None:
return False
totals = _order_effect_totals(order)
gift_card_remaining = max(0.0, _total_gift_card_amount(order) - float(totals["giftCardAmount"]))
restored_gift_card = _restore_gift_card_balances(order, gift_card_remaining)
_add_reversed_money(order, "giftCardAmount", restored_gift_card)
discount_entries = order.get("discounts", []) or []
if not discount_entries and order.get("discount") is not None:
discount_entries = [order.get("discount")]
reversed_discount_codes: set[str] = set()
for discount_entry in discount_entries:
code = _value_get(discount_entry, "code")
if not code:
continue
normalized_code = str(code).upper()
if normalized_code in reversed_discount_codes:
continue
discount = get_discount_by_code(str(code))
if discount is not None:
discount.usageCount = max(0, discount.usageCount - 1)
reversed_discount_codes.add(normalized_code)
customer = get_customer_by_email(order.email) if order.email else None
if customer is not None:
customer.ordersCount = max(0, customer.ordersCount - 1)
customer_spend_remaining = max(0.0, float(order.totalPrice.amount) - float(totals["customerSpendAmount"]))
reversed_spend = _reverse_customer_amount(order, customer_spend_remaining, reversed_at=reversed_at)
_add_reversed_money(order, "customerSpendAmount", reversed_spend)
points_earned = int(order.get("loyaltyPointsEarned") or 0)
points_redeemed = int(order.get("loyaltyPointsRedeemed") or 0)
earned_remaining = max(0, points_earned - int(totals["loyaltyPointsEarned"]))
redeemed_remaining = max(0, points_redeemed - int(totals["loyaltyPointsRedeemed"]))
reversed_earned, reversed_redeemed = _reverse_loyalty_points(
order,
earned_remaining,
redeemed_remaining,
reversed_at=reversed_at,
)
_add_reversed_points(order, "loyaltyPointsEarned", reversed_earned)
_add_reversed_points(order, "loyaltyPointsRedeemed", reversed_redeemed)
customer.updatedAt = reversed_at
order["sideEffectsReversedAt"] = reversed_at
return True
@@ -0,0 +1,781 @@
"""Order tool handlers."""
import contextlib
import re
import secrets
from datetime import UTC, datetime
from shopify.models import (
CancelOrderArgs,
CreateOrderArgs,
CreditCardPaymentMethod,
GetOrderArgs,
ListOrdersArgs,
MailingAddress,
MoneyV2,
PaymentMethodInput,
UpdateOrderArgs,
)
from shopify.state import (
LooseOrder,
adjust_variant_stock,
get_all_orders,
get_cart_by_id,
get_customer_by_email,
get_discount_by_code,
get_gift_card_by_code,
get_next_line_item_id,
get_next_order_id,
get_order_by_id,
get_shipping_method_by_id,
get_state,
get_variant_by_id,
save_state,
)
from shopify.tools.loyalty import compute_tier
from shopify.tools.order_effects import applied_gift_card_code, reverse_order_effects
def handle_cancel_order(args: CancelOrderArgs) -> dict:
"""Cancel an order."""
order = get_order_by_id(args.order_id)
if order is None:
return {
"order": None,
"userErrors": [{"field": "order_id", "message": f"Order not found: {args.order_id}"}],
}
if order.cancelledAt is not None:
return {
"order": None,
"userErrors": [{"field": "order_id", "message": "Order is already cancelled"}],
}
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
order.cancelledAt = now
order.financialStatus = "VOIDED" if order.financialStatus == "PENDING" else "REFUNDED"
order.updatedAt = now
if args.reason:
existing_note = order.note or ""
order.note = f"{existing_note}\nCancellation reason: {args.reason}".strip()
# Restore stock if the order never shipped. If it was fulfilled (or partially),
# returning physical product is beyond the scope of this mock.
if order.fulfillmentStatus == "UNFULFILLED":
already_returned_by_line_id: dict[str, int] = {}
for return_obj in get_state().returns.values():
if return_obj.orderId != args.order_id or return_obj.status != "REFUNDED":
continue
for return_line in return_obj.lineItems:
already_returned_by_line_id[return_line.orderLineItemId] = (
already_returned_by_line_id.get(return_line.orderLineItemId, 0) + return_line.quantity
)
for line_item in order.lineItems:
variant_id = line_item.variantId
qty = line_item.quantity - already_returned_by_line_id.get(line_item.id, 0)
if variant_id and qty > 0:
adjust_variant_stock(variant_id, qty)
reverse_order_effects(order, reversed_at=now)
save_state()
return {"order": order, "userErrors": []}
def _validate_payment_method(payment: PaymentMethodInput) -> list[dict]:
"""Validate payment method and return list of errors (empty if valid)."""
errors = []
if isinstance(payment, CreditCardPaymentMethod):
# Validate card number: 13-19 digits after stripping spaces/dashes
card_number = re.sub(r"[\s\-]", "", payment.card_number)
if not card_number.isdigit() or not (13 <= len(card_number) <= 19):
errors.append(
{
"field": "payment_method.card_number",
"message": "Card number must be 13-19 digits",
}
)
# Validate CVV: 3-4 digits
cvv = payment.cvv
if not cvv.isdigit() or not (3 <= len(cvv) <= 4):
errors.append(
{
"field": "payment_method.cvv",
"message": "CVV must be 3-4 digits",
}
)
# Validate expiry: MM/YY format
expiry = payment.expiry
if not re.match(r"^\d{2}/\d{2}$", expiry):
errors.append(
{
"field": "payment_method.expiry",
"message": "Expiry must be in MM/YY format",
}
)
return errors
def _build_payment_display(payment: PaymentMethodInput) -> dict:
"""Build safe display info for the payment method (no sensitive data)."""
ptype = payment.type
if isinstance(payment, CreditCardPaymentMethod):
card_number = re.sub(r"[\s\-]", "", payment.card_number)
last4 = card_number[-4:] if len(card_number) >= 4 else card_number
# Simple brand detection
if card_number.startswith("4"):
brand = "visa"
elif card_number.startswith("5"):
brand = "mastercard"
elif card_number.startswith("3"):
brand = "amex"
else:
brand = "unknown"
return {"type": "credit_card", "last4": last4, "brand": brand}
else:
return {"type": ptype, "email": payment.email}
def _validate_address(address: MailingAddress, field_name: str) -> list[dict]:
"""Validate that an address has required fields."""
errors = []
if not address.address1:
errors.append({"field": f"{field_name}.address1", "message": f"{field_name} requires address1"})
if not address.city:
errors.append({"field": f"{field_name}.city", "message": f"{field_name} requires city"})
if not address.countryCode:
errors.append({"field": f"{field_name}.countryCode", "message": f"{field_name} requires countryCode"})
return errors
def _selected_shipping_method_id(cart) -> str | None:
for group in cart.deliveryGroups:
selected = group.selectedDeliveryOption
if selected is not None:
return selected.handle
return None
def _discount_combination_class(discount) -> str:
if discount.discountType == "FREE_SHIPPING":
return "shippingDiscounts"
if discount.productIds:
return "productDiscounts"
return "orderDiscounts"
def _discounts_can_combine(first, second) -> bool:
first_allows_second = getattr(first.combinesWith, _discount_combination_class(second))
second_allows_first = getattr(second.combinesWith, _discount_combination_class(first))
return first_allows_second and second_allows_first
def handle_create_order(args: CreateOrderArgs) -> dict:
"""Convert a cart into an order with payment validation."""
cart = get_cart_by_id(args.cart_id)
if cart is None:
return {
"order": None,
"userErrors": [{"field": "cart_id", "message": f"Cart not found: {args.cart_id}"}],
}
lines = cart.lines
if not lines:
return {
"order": None,
"userErrors": [{"field": "cart_id", "message": "Cart is empty"}],
}
# Validate payment method
payment_errors = _validate_payment_method(args.payment_method)
if payment_errors:
return {"order": None, "userErrors": payment_errors}
# Validate addresses
address_errors = []
address_errors.extend(_validate_address(args.shipping_address, "shipping_address"))
address_errors.extend(_validate_address(args.billing_address, "billing_address"))
if address_errors:
return {"order": None, "userErrors": address_errors}
shipping_method_id = args.shipping_method_id or _selected_shipping_method_id(cart)
if shipping_method_id is None:
return {
"order": None,
"userErrors": [
{
"field": "shipping_method_id",
"message": "shipping_method_id is required unless the cart has a selected delivery option",
}
],
}
# Validate shipping method
shipping_method = get_shipping_method_by_id(shipping_method_id)
if shipping_method is None:
return {
"order": None,
"userErrors": [
{
"field": "shipping_method_id",
"message": f"Shipping method not found: '{shipping_method_id}'. Use list_shipping_methods to see available options.",
}
],
}
if not shipping_method.active:
return {
"order": None,
"userErrors": [
{"field": "shipping_method_id", "message": f"Shipping method '{shipping_method_id}' is not active"}
],
}
for cart_line in lines:
merch = cart_line.merchandise
_, variant = get_variant_by_id(merch.id)
if (
variant is not None
and variant.quantityAvailable is not None
and cart_line.quantity > variant.quantityAvailable
):
return {
"order": None,
"userErrors": [
{
"field": "cart_id",
"message": (
f"Insufficient inventory for variant {merch.id}: "
f"requested {cart_line.quantity}, available {variant.quantityAvailable}"
),
}
],
}
# Pull buyer identity from cart if not overridden
buyer = cart.buyerIdentity
state = get_state()
email = args.email or buyer.email or state.current_customer_email
phone = args.phone or buyer.phone
# Convert cart lines to order line items
order_line_items = []
subtotal = 0.0
currency = "USD"
for cart_line in lines:
merch = cart_line.merchandise
product_info = merch.product or {}
unit_price = cart_line.cost.amountPerQuantity
quantity = cart_line.quantity
price_amount = float(unit_price.amount)
currency = unit_price.currencyCode
total_price = price_amount * quantity
line_item = {
"title": product_info.get("title", merch.title),
"variantTitle": merch.title,
"quantity": quantity,
"sku": None,
"variantId": merch.id,
"productId": product_info.get("id"),
"price": unit_price.model_dump(mode="json"),
"totalPrice": {"amount": f"{total_price:.2f}", "currencyCode": currency},
"image": merch.image,
}
order_line_items.append(line_item)
subtotal += total_price
# Calculate shipping cost
shipping_cost = float(shipping_method.price.amount)
# Look up customer by email (enables loyalty effects)
program = state.loyalty_program
loyalty_enabled = program.enabled
customer = get_customer_by_email(email) if email else None
# Apply tier discount before discount code (loyalty tier is a separate discount layer)
tier_discount_amount = 0.0
tier_info = None
if loyalty_enabled and args.apply_tier_discount and customer is not None:
tier_obj = compute_tier(customer.lifetimePoints, program.tiers)
if tier_obj and tier_obj.discount_percent > 0:
tier_discount_amount = subtotal * (tier_obj.discount_percent / 100)
tier_info = {
"name": tier_obj.name,
"discountPercent": tier_obj.discount_percent,
"discountAmount": {"amount": f"{tier_discount_amount:.2f}", "currencyCode": currency},
}
# Redeem loyalty points (converts to fixed-amount discount)
points_redeemed = 0
redemption_amount = 0.0
if args.redeem_points:
if not loyalty_enabled:
return {
"order": None,
"userErrors": [{"field": "redeem_points", "message": "Loyalty program is not enabled"}],
}
if customer is None:
return {
"order": None,
"userErrors": [
{"field": "redeem_points", "message": "Cannot redeem points without a matching customer email"}
],
}
if args.redeem_points <= 0:
return {
"order": None,
"userErrors": [{"field": "redeem_points", "message": "redeem_points must be positive"}],
}
balance = customer.pointsBalance
if args.redeem_points > balance:
return {
"order": None,
"userErrors": [
{
"field": "redeem_points",
"message": f"Insufficient balance: requested {args.redeem_points}, available {balance}",
}
],
}
redemption_rate = program.redemption_rate
raw_value = args.redeem_points / redemption_rate
# Cap redemption at max_redemption_percent of the post-tier-discount subtotal
cap = (subtotal - tier_discount_amount) * (program.max_redemption_percent / 100)
redemption_amount = min(raw_value, cap)
# Only deduct the points that actually became discount. When raw_value
# exceeds cap, the extra points would otherwise be burned for no value.
points_redeemed = (
round(redemption_amount * redemption_rate) if redemption_amount < raw_value else args.redeem_points
)
validated_applied_gift_cards = []
for applied_card in cart.appliedGiftCards:
code = applied_gift_card_code(applied_card)
gift_card = get_gift_card_by_code(code)
if gift_card is None:
return {
"order": None,
"userErrors": [{"field": "gift_card_codes", "message": f"Gift card '{code}' not found"}],
}
if not gift_card.active:
return {
"order": None,
"userErrors": [{"field": "gift_card_codes", "message": f"Gift card '{code}' is not active"}],
}
balance = float(gift_card.balance.amount)
if balance <= 0:
return {
"order": None,
"userErrors": [{"field": "gift_card_codes", "message": f"Gift card '{code}' has no remaining balance"}],
}
validated_applied_gift_cards.append((applied_card, gift_card))
# Apply explicit checkout discount, or fall back to applicable cart-level
# discount codes already applied through update_cart.
effective_discount_codes = (
[args.discount_code]
if args.discount_code
else [cart_discount.code for cart_discount in cart.discountCodes if cart_discount.applicable]
)
# Apply discount codes if provided
discount_infos = []
discount_amount = 0.0
item_discount = 0.0
applied_discount_models = []
for effective_discount_code in effective_discount_codes:
if not effective_discount_code:
continue
dc = get_discount_by_code(effective_discount_code)
if dc is None:
return {
"order": None,
"userErrors": [
{"field": "discount_code", "message": f"Discount code '{effective_discount_code}' not found"}
],
}
if not dc.active:
return {
"order": None,
"userErrors": [
{"field": "discount_code", "message": f"Discount code '{effective_discount_code}' is not active"}
],
}
if dc.usageLimit and dc.usageCount >= dc.usageLimit:
return {
"order": None,
"userErrors": [
{
"field": "discount_code",
"message": f"Discount code '{effective_discount_code}' has reached its usage limit",
}
],
}
incompatible = next(
(existing for existing in applied_discount_models if not _discounts_can_combine(existing, dc)),
None,
)
if incompatible is not None:
return {
"order": None,
"userErrors": [
{
"field": "discount_code",
"message": f"Discount code '{effective_discount_code}' cannot be combined with '{incompatible.code}'",
}
],
}
# Check tier gate (loyalty-restricted codes)
required_tier_name = dc.minimumTier
if required_tier_name:
program_tiers = program.tiers
required_tier = next((tier for tier in program_tiers if tier.name == required_tier_name), None)
if required_tier is None:
return {
"order": None,
"userErrors": [
{
"field": "discount_code",
"message": (
f"Discount code '{effective_discount_code}' requires tier "
f"'{required_tier_name}' which is not configured on this store"
),
}
],
}
customer_tier = compute_tier(customer.lifetimePoints, program_tiers) if customer else None
customer_threshold = customer_tier.min_lifetime_points if customer_tier else -1
if customer_threshold < required_tier.min_lifetime_points:
return {
"order": None,
"userErrors": [
{
"field": "discount_code",
"message": (
f"Discount code '{effective_discount_code}' requires '{required_tier_name}' tier or higher"
),
}
],
}
# Check minimum purchase (against subtotal, not including shipping)
min_purchase = dc.minimumPurchase
if min_purchase and subtotal < float(min_purchase.amount):
return {
"order": None,
"userErrors": [
{
"field": "discount_code",
"message": f"Minimum purchase of ${min_purchase.amount} required (subtotal: ${subtotal:.2f})",
}
],
}
discount_type = dc.discountType
discount_value = float(dc.value)
product_ids_filter = dc.productIds
code_discount_amount = 0.0
if discount_type == "FREE_SHIPPING":
# Free shipping — savings come from zeroing shipping, not from subtotal
code_discount_amount = shipping_cost
shipping_cost = 0.0
# Don't set item_discount — FREE_SHIPPING only affects shipping
elif discount_type == "PERCENTAGE":
# Percentage off eligible items
if product_ids_filter:
# Only discount matching products
eligible_total = sum(
float(li.get("totalPrice", {}).get("amount", "0"))
for li in order_line_items
if li.get("productId") in product_ids_filter
)
else:
eligible_total = subtotal
code_discount_amount = eligible_total * (discount_value / 100)
item_discount += code_discount_amount
elif discount_type == "FIXED_AMOUNT":
if product_ids_filter:
eligible_total = sum(
float(li.get("totalPrice", {}).get("amount", "0"))
for li in order_line_items
if li.get("productId") in product_ids_filter
)
code_discount_amount = min(discount_value, eligible_total)
else:
code_discount_amount = min(discount_value, subtotal)
item_discount += code_discount_amount
discount_amount += code_discount_amount
applied_discount_models.append(dc)
discount_infos.append(
{
"code": dc.code,
"type": discount_type,
"value": dc.value,
"discountAmount": {"amount": f"{code_discount_amount:.2f}", "currencyCode": currency},
"productIds": product_ids_filter,
"minimumTier": dc.minimumTier,
}
)
discount_info = discount_infos[0] if discount_infos else None
total = subtotal - tier_discount_amount - redemption_amount - item_discount + shipping_cost
# Floor at zero — defensive guard for stacked discounts
if total < 0:
total = 0.0
applied_gift_cards = []
gift_card_balance_updates = []
gift_card_amount = 0.0
for applied_card, gift_card in validated_applied_gift_cards:
balance = float(gift_card.balance.amount)
amount_used = min(balance, total)
total -= amount_used
gift_card_amount += amount_used
new_balance = MoneyV2(amount=f"{balance - amount_used:.2f}", currencyCode=gift_card.balance.currencyCode)
gift_card_balance_updates.append((gift_card, new_balance))
applied_gift_cards.append(
{
"id": applied_card.id,
"code": applied_gift_card_code(applied_card),
"lastCharacters": applied_card.lastCharacters,
"amountUsed": {"amount": f"{amount_used:.2f}", "currencyCode": gift_card.balance.currencyCode},
"balance": new_balance.model_dump(mode="json"),
"presentmentAmountUsed": {
"amount": f"{amount_used:.2f}",
"currencyCode": gift_card.balance.currencyCode,
},
}
)
# Award points based on post-discount, pre-shipping subtotal
points_earned = 0
if loyalty_enabled and customer is not None:
earn_basis = max(subtotal - tier_discount_amount - redemption_amount - item_discount, 0.0)
points_earned = int(earn_basis * program.earn_rate)
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
order_id = get_next_order_id()
order_num = order_id.rsplit("/", 1)[-1]
order_line_items = [{"id": get_next_line_item_id(), **line_item} for line_item in order_line_items]
order = {
"id": order_id,
"name": f"#{order_num}",
"email": email,
"phone": phone,
"createdAt": now,
"updatedAt": now,
"cancelledAt": None,
"financialStatus": "PAID",
"fulfillmentStatus": "UNFULFILLED",
"trackingNumber": None,
"trackingUrl": None,
"paymentMethod": _build_payment_display(args.payment_method),
"shippingMethod": {
"id": shipping_method.id,
"title": shipping_method.title,
"price": shipping_method.price.model_dump(mode="json"),
},
"lineItems": order_line_items,
"subtotalPrice": {"amount": f"{subtotal:.2f}", "currencyCode": currency},
"shippingPrice": {"amount": f"{shipping_cost:.2f}", "currencyCode": currency},
"discount": discount_info,
"discounts": discount_infos,
"discountAmount": {"amount": f"{discount_amount:.2f}", "currencyCode": currency},
"tierDiscount": tier_info,
"tierDiscountAmount": {"amount": f"{tier_discount_amount:.2f}", "currencyCode": currency},
"loyaltyPointsRedeemed": points_redeemed,
"loyaltyRedemptionAmount": {"amount": f"{redemption_amount:.2f}", "currencyCode": currency},
"loyaltyPointsEarned": points_earned,
"appliedGiftCards": applied_gift_cards,
"giftCardAmount": {"amount": f"{gift_card_amount:.2f}", "currencyCode": currency},
"totalPrice": {"amount": f"{total:.2f}", "currencyCode": currency},
"totalTax": None,
"shippingAddress": args.shipping_address.model_dump(mode="json", exclude_none=True),
"billingAddress": args.billing_address.model_dump(mode="json", exclude_none=True),
"note": args.note or cart.note,
"tags": args.tags or [],
"cartId": args.cart_id,
}
state.orders[order_id] = LooseOrder.model_validate(order)
for discount_model in applied_discount_models:
discount_model.usageCount += 1
for gift_card, new_balance in gift_card_balance_updates:
gift_card.balance = new_balance
# Reduce stock for each ordered line item (reservation happens on order
# confirmation; floors at 0 so overselling doesn't produce negative inventory)
for li in order_line_items:
variant_id = li.get("variantId")
qty = int(li.get("quantity", 0) or 0)
if isinstance(variant_id, str) and qty > 0:
adjust_variant_stock(variant_id, -qty)
# Post-order customer updates: orders count, lifetime spend, loyalty balances, tier
if customer is not None:
customer.ordersCount += 1
prior_spent = 0.0
if customer.totalSpent is not None:
prior_spent = float(customer.totalSpent.amount)
customer.totalSpent = MoneyV2(amount=f"{prior_spent + total:.2f}", currencyCode=currency)
customer.updatedAt = now
if loyalty_enabled:
if customer.loyaltyJoinedAt is None:
customer.loyaltyJoinedAt = now
customer.pointsBalance = customer.pointsBalance + points_earned - points_redeemed
customer.lifetimePoints = customer.lifetimePoints + points_earned
tier_obj = compute_tier(customer.lifetimePoints, program.tiers)
customer.tier = tier_obj.name if tier_obj else None
save_state()
return {"order": order, "userErrors": []}
def handle_get_order(args: GetOrderArgs) -> dict:
"""Retrieve an order by its ID."""
order = get_order_by_id(args.order_id)
if order is None:
return {
"order": None,
"userErrors": [{"field": "order_id", "message": f"Order not found: {args.order_id}"}],
}
return {"order": order, "userErrors": []}
def handle_list_orders(args: ListOrdersArgs) -> dict:
"""List orders, optionally filtered by status."""
orders = get_all_orders()
# Filter by status (matches against both financialStatus and fulfillmentStatus)
if args.status:
orders = [
order for order in orders if order.financialStatus == args.status or order.fulfillmentStatus == args.status
]
# Sort by creation date descending (newest first)
orders.sort(key=lambda order: order.createdAt, reverse=True)
total_count = len(orders)
# Pagination (cursor = index, same pattern as search_products)
start_idx = 0
if args.after:
with contextlib.suppress(ValueError):
start_idx = int(args.after) + 1
end_idx = start_idx + args.limit
paginated = orders[start_idx:end_idx]
has_next = end_idx < total_count
end_cursor = str(end_idx - 1) if paginated else None
return {
"orders": paginated,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": start_idx > 0,
"startCursor": str(start_idx) if paginated else None,
"endCursor": end_cursor,
},
"totalCount": total_count,
}
_SIDE_EFFECT_FINANCIAL_STATUSES = {"VOIDED", "REFUNDED", "PARTIALLY_REFUNDED"}
def _generate_tracking() -> tuple[str, str]:
"""Generate a random mock tracking number and a URL that references it."""
# 12 hex chars keeps it short and distinctive while staying unique in practice
number = f"TRK-{secrets.token_hex(6).upper()}"
url = f"https://track.example.com/{number}"
return number, url
def handle_update_order(args: UpdateOrderArgs) -> dict:
"""Update fields on an existing order."""
order = get_order_by_id(args.order_id)
if order is None:
return {
"order": None,
"userErrors": [{"field": "order_id", "message": f"Order not found: {args.order_id}"}],
}
if (
args.financial_status is not None
and args.financial_status != order.financialStatus
and (
args.financial_status in _SIDE_EFFECT_FINANCIAL_STATUSES
or order.financialStatus in _SIDE_EFFECT_FINANCIAL_STATUSES
)
):
return {
"order": None,
"userErrors": [
{
"field": "financial_status",
"message": (
"Use cancel_order or the return workflow for VOIDED, REFUNDED, "
"or PARTIALLY_REFUNDED so inventory, customer, loyalty, discount, "
"and gift-card side effects stay consistent"
),
}
],
}
if args.email is not None and args.email != order.email:
return {
"order": None,
"userErrors": [
{
"field": "email",
"message": "Order customer email cannot be reassigned after checkout",
}
],
}
if args.financial_status is not None:
order.financialStatus = args.financial_status
if args.fulfillment_status is not None:
new_status = args.fulfillment_status
order.fulfillmentStatus = new_status
# Generate tracking info when an order is first marked (partially) fulfilled
if new_status in {"FULFILLED", "PARTIALLY_FULFILLED"} and not order.trackingNumber:
number, url = _generate_tracking()
order.trackingNumber = number
order.trackingUrl = url
if args.note is not None:
order.note = args.note
if args.tags is not None:
order.tags = args.tags
if args.email is not None:
order.email = args.email
if args.phone is not None:
order.phone = args.phone
if args.shipping_address is not None:
order.shippingAddress = args.shipping_address
order.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {"order": order, "userErrors": []}
@@ -0,0 +1,69 @@
"""Store policy CRUD tools."""
from shopify.models import (
CreatePolicyArgs,
DeletePolicyArgs,
ListPoliciesArgs,
LoosePolicy,
UpdatePolicyArgs,
)
from shopify.state import get_next_policy_id, get_policy_by_id, get_state, save_state
def handle_create_policy(args: CreatePolicyArgs) -> dict:
"""Create a new store policy."""
policy_id = get_next_policy_id()
policy_num = policy_id.rsplit("/", 1)[-1]
policy = {
"id": policy_id,
"title": args.title,
"body": args.body,
"url": f"https://shop.example.com/policies/{policy_num}",
}
state = get_state()
policy_model = LoosePolicy.model_validate(policy)
state.policies.append(policy_model)
save_state()
return {"policy": policy_model, "userErrors": []}
def handle_list_policies(_args: ListPoliciesArgs) -> dict:
"""List all store policies."""
state = get_state()
return {"policies": state.policies, "totalCount": len(state.policies)}
def handle_update_policy(args: UpdatePolicyArgs) -> dict:
"""Update an existing policy."""
policy = get_policy_by_id(args.policy_id)
if policy is None:
return {
"policy": None,
"userErrors": [{"field": "policy_id", "message": f"Policy not found: {args.policy_id}"}],
}
if args.title is not None:
policy.title = args.title
if args.body is not None:
policy.body = args.body
save_state()
return {"policy": policy, "userErrors": []}
def handle_delete_policy(args: DeletePolicyArgs) -> dict:
"""Delete a policy."""
state = get_state()
for i, p in enumerate(state.policies):
if p.id == args.policy_id:
del state.policies[i]
save_state()
return {"deletedPolicyId": args.policy_id, "userErrors": []}
return {
"deletedPolicyId": None,
"userErrors": [{"field": "policy_id", "message": f"Policy not found: {args.policy_id}"}],
}
@@ -0,0 +1,432 @@
"""Review and return tool handlers."""
import contextlib
from datetime import UTC, datetime
from typing import cast
from shopify.models import (
CreateReturnArgs,
CreateReviewArgs,
DeleteReviewArgs,
FinancialStatus,
GetProductReviewsArgs,
GetReturnArgs,
ListReturnsArgs,
UpdateReturnArgs,
UpdateReviewArgs,
)
from shopify.state import (
LooseReturn,
LooseReview,
adjust_variant_stock,
get_all_returns,
get_next_return_id,
get_next_review_id,
get_order_by_id,
get_product_by_id,
get_return_by_id,
get_review_by_id,
get_reviews_for_product,
get_state,
save_state,
)
from shopify.tools.order_effects import reverse_order_effects, reverse_return_effects
def _value_get(value, key: str, default=None):
if isinstance(value, dict):
return value.get(key, default)
if hasattr(value, "get"):
return value.get(key, default)
return getattr(value, key, default)
def _money_amount(value) -> float:
if value is None:
return 0.0
if isinstance(value, dict):
return float(value.get("amount", "0") or 0)
return float(getattr(value, "amount", "0") or 0)
def _effective_item_refund_ratio(order) -> float:
subtotal = _money_amount(_value_get(order, "subtotalPrice"))
if subtotal <= 0:
return 1.0
gift_card_total = sum(
_money_amount(_value_get(card, "amountUsed", {})) for card in _value_get(order, "appliedGiftCards", []) or []
)
shipping_total = _money_amount(_value_get(order, "shippingPrice"))
settled_item_total = _money_amount(_value_get(order, "totalPrice")) + gift_card_total - shipping_total
return min(1.0, max(0.0, settled_item_total / subtotal))
def _returned_quantities_for_order(order_id: str) -> dict[str, int]:
"""Return quantities already tied up in non-rejected returns for an order."""
state = get_state()
quantities: dict[str, int] = {}
for return_obj in state.returns.values():
if return_obj.orderId != order_id or return_obj.status == "REJECTED":
continue
for line_item in return_obj.lineItems:
quantities[line_item.orderLineItemId] = quantities.get(line_item.orderLineItemId, 0) + line_item.quantity
return quantities
def handle_create_return(args: CreateReturnArgs) -> dict:
"""Create a return request linked to an order."""
order = get_order_by_id(args.order_id)
if order is None:
return {
"return": None,
"userErrors": [{"field": "order_id", "message": f"Order not found: {args.order_id}"}],
}
if order.cancelledAt is not None:
return {
"return": None,
"userErrors": [{"field": "order_id", "message": "Cannot return a cancelled order"}],
}
# Validate line items reference real order line items and cannot exceed
# the quantity still available for return across existing non-rejected
# return requests.
order_lines_by_id = {line_item.id: line_item for line_item in order.lineItems}
already_returned = _returned_quantities_for_order(args.order_id)
requested_quantities: dict[str, int] = {}
parsed_line_items = []
errors = []
for item in args.line_items:
line_item_id = item.orderLineItemId
order_line = order_lines_by_id.get(line_item_id)
if order_line is None:
errors.append({"field": "line_items", "message": f"Order line item not found: {line_item_id}"})
continue
requested_so_far = requested_quantities.get(line_item_id, 0)
remaining_quantity = order_line.quantity - already_returned.get(line_item_id, 0) - requested_so_far
if item.quantity > remaining_quantity:
errors.append(
{
"field": "line_items",
"message": (
f"Return quantity {item.quantity} exceeds remaining returnable quantity "
f"{max(remaining_quantity, 0)} for order line item {line_item_id}"
),
}
)
continue
requested_quantities[line_item_id] = requested_so_far + item.quantity
parsed_line_items.append(
{
"orderLineItemId": line_item_id,
"quantity": item.quantity,
"reason": item.reason,
}
)
if errors:
return {"return": None, "userErrors": errors}
if not parsed_line_items:
return {
"return": None,
"userErrors": [{"field": "line_items", "message": "No valid line items to return"}],
}
# Calculate item refund amount from the customer-paid effective price.
# order.lineItems keep pre-discount unit prices, while order.totalPrice plus
# gift cards is the total settled value after discounts/redemptions.
item_refund_ratio = _effective_item_refund_ratio(order)
refund_total = 0.0
currency = "USD"
for ret_item in parsed_line_items:
order_li = order_lines_by_id[ret_item["orderLineItemId"]]
unit_price = float(order_li.price.amount) * item_refund_ratio
refund_total += unit_price * ret_item["quantity"]
currency = order_li.price.currencyCode
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
return_id = get_next_return_id()
return_obj = {
"id": return_id,
"orderId": args.order_id,
"status": "REQUESTED",
"lineItems": parsed_line_items,
"refundAmount": {"amount": f"{refund_total:.2f}", "currencyCode": currency},
"reason": args.reason,
"note": args.note,
"createdAt": now,
"updatedAt": now,
}
state = get_state()
state.returns[return_id] = LooseReturn.model_validate(return_obj)
save_state()
return {"return": return_obj, "userErrors": []}
def handle_create_review(args: CreateReviewArgs) -> dict:
"""Create a new product review."""
product = get_product_by_id(args.product_id)
if product is None:
return {
"review": None,
"userErrors": [{"field": "product_id", "message": f"Product not found: {args.product_id}"}],
}
if args.rating < 1 or args.rating > 5:
return {
"review": None,
"userErrors": [{"field": "rating", "message": "Rating must be between 1 and 5"}],
}
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
review_id = get_next_review_id()
review = {
"id": review_id,
"productId": args.product_id,
"rating": args.rating,
"title": args.title,
"body": args.body,
"author": args.author,
"email": args.email,
"status": "PUBLISHED",
"createdAt": now,
"updatedAt": now,
}
state = get_state()
state.reviews[review_id] = LooseReview.model_validate(review)
save_state()
return {"review": review, "userErrors": []}
def handle_delete_review(args: DeleteReviewArgs) -> dict:
"""Delete a review."""
review = get_review_by_id(args.review_id)
if review is None:
return {
"deletedReviewId": None,
"userErrors": [{"field": "review_id", "message": f"Review not found: {args.review_id}"}],
}
state = get_state()
del state.reviews[args.review_id]
save_state()
return {"deletedReviewId": args.review_id, "userErrors": []}
def handle_get_product_reviews(args: GetProductReviewsArgs) -> dict:
"""Get reviews for a product with optional status filter and pagination."""
product = get_product_by_id(args.product_id)
if product is None:
return {
"reviews": [],
"totalCount": 0,
"averageRating": None,
"userErrors": [{"field": "product_id", "message": f"Product not found: {args.product_id}"}],
}
reviews = get_reviews_for_product(args.product_id)
# Filter by status
if args.status:
reviews = [review for review in reviews if review.status == args.status]
# Sort by date descending
reviews.sort(key=lambda review: review.createdAt, reverse=True)
# Calculate average rating (from all published reviews, not just filtered page)
published = [review for review in get_reviews_for_product(args.product_id) if review.status == "PUBLISHED"]
avg_rating = sum(review.rating for review in published) / len(published) if published else None
total_count = len(reviews)
# Pagination
start_idx = 0
if args.after:
with contextlib.suppress(ValueError):
start_idx = int(args.after) + 1
end_idx = start_idx + args.limit
paginated = reviews[start_idx:end_idx]
has_next = end_idx < total_count
end_cursor = str(end_idx - 1) if paginated else None
return {
"reviews": paginated,
"totalCount": total_count,
"averageRating": round(avg_rating, 1) if avg_rating is not None else None,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": start_idx > 0,
"endCursor": end_cursor,
},
"userErrors": [],
}
def handle_get_return(args: GetReturnArgs) -> dict:
"""Retrieve a return by its ID."""
return_obj = get_return_by_id(args.return_id)
if return_obj is None:
return {
"return": None,
"userErrors": [{"field": "return_id", "message": f"Return not found: {args.return_id}"}],
}
return {"return": return_obj, "userErrors": []}
def handle_list_returns(args: ListReturnsArgs) -> dict:
"""List returns with optional order and status filtering."""
returns = get_all_returns()
if args.order_id:
returns = [return_obj for return_obj in returns if return_obj.orderId == args.order_id]
if args.status:
returns = [return_obj for return_obj in returns if return_obj.status == args.status]
# Sort by creation date descending
returns.sort(key=lambda return_obj: return_obj.createdAt, reverse=True)
total_count = len(returns)
start_idx = 0
if args.after:
with contextlib.suppress(ValueError):
start_idx = int(args.after) + 1
end_idx = start_idx + args.limit
paginated = returns[start_idx:end_idx]
has_next = end_idx < total_count
end_cursor = str(end_idx - 1) if paginated else None
return {
"returns": paginated,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": start_idx > 0,
"startCursor": str(start_idx) if paginated else None,
"endCursor": end_cursor,
},
"totalCount": total_count,
}
_ALLOWED_RETURN_STATUS_TRANSITIONS: dict[str, set[str]] = {
"REQUESTED": {"APPROVED", "RECEIVED", "REFUNDED", "REJECTED"},
"APPROVED": {"RECEIVED", "REFUNDED", "REJECTED"},
"RECEIVED": {"REFUNDED", "REJECTED"},
"REFUNDED": set(),
"REJECTED": set(),
}
def handle_update_return(args: UpdateReturnArgs) -> dict:
"""Update a return's status or note. When status moves to REFUNDED, updates the order's financial status."""
return_obj = get_return_by_id(args.return_id)
if return_obj is None:
return {
"return": None,
"userErrors": [{"field": "return_id", "message": f"Return not found: {args.return_id}"}],
}
if args.status is not None:
status_upper = args.status
prev_status = return_obj.status
if status_upper != prev_status and status_upper not in _ALLOWED_RETURN_STATUS_TRANSITIONS.get(
prev_status, set()
):
return {
"return": None,
"userErrors": [
{
"field": "status",
"message": f"Cannot transition return from {prev_status} to {status_upper}",
}
],
}
return_obj.status = status_upper
# When return is refunded, update the order's financial status
if status_upper == "REFUNDED":
order = get_order_by_id(return_obj.orderId)
if order is not None:
order_total = float(order.totalPrice.amount)
total_refunded = sum(
float(ret.refundAmount.amount) if ret.refundAmount is not None else 0
for ret in get_state().returns.values()
if ret.orderId == return_obj.orderId and ret.status == "REFUNDED"
)
if total_refunded >= order_total:
order.financialStatus = "REFUNDED"
if prev_status != "REFUNDED":
reverse_return_effects(
order, return_obj, reversed_at=datetime.now(UTC).isoformat().replace("+00:00", "Z")
)
reverse_order_effects(order, reversed_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"))
else:
order.financialStatus = cast(FinancialStatus, "PARTIALLY_REFUNDED")
if prev_status != "REFUNDED":
reverse_return_effects(
order, return_obj, reversed_at=datetime.now(UTC).isoformat().replace("+00:00", "Z")
)
order.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
# Restore stock if the order never shipped and we're transitioning
# into REFUNDED for the first time. If fulfilled, the product left
# the warehouse and a physical return is beyond the scope of the mock.
if prev_status != "REFUNDED" and order.fulfillmentStatus == "UNFULFILLED":
order_lines_by_id = {line_item.id: line_item for line_item in order.lineItems}
for ret_li in return_obj.lineItems:
order_li = order_lines_by_id.get(ret_li.orderLineItemId)
if not order_li:
continue
variant_id = order_li.variantId
qty = ret_li.quantity
if variant_id and qty > 0:
adjust_variant_stock(variant_id, qty)
if args.note is not None:
return_obj.note = args.note
return_obj.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {"return": return_obj, "userErrors": []}
def handle_update_review(args: UpdateReviewArgs) -> dict:
"""Update a review's status, title, body, or rating."""
review = get_review_by_id(args.review_id)
if review is None:
return {
"review": None,
"userErrors": [{"field": "review_id", "message": f"Review not found: {args.review_id}"}],
}
if args.status is not None:
review.status = args.status
if args.title is not None:
review.title = args.title
if args.body is not None:
review.body = args.body
if args.rating is not None:
review.rating = args.rating
review.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {"review": review, "userErrors": []}
@@ -0,0 +1,271 @@
"""Customer-scoped self-service tool handlers."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from pydantic import ValidationError
from shopify.models import (
CreateReturnArgs,
CreateReviewArgs,
GetOrderArgs,
MailingAddress,
RedeemPointsArgs,
)
from shopify.state import (
get_customer_by_email,
get_order_by_id,
get_state,
save_state,
)
from shopify.tools.customers import _set_default_address
from shopify.tools.loyalty import compute_tier
def _current_customer() -> tuple[Any | None, str | None]:
"""Resolve the current-customer record from state.
Returns (customer, error_message). If the store has no
``current_customer_email`` set, or the referenced customer doesn't exist,
returns (None, reason).
"""
state = get_state()
email = state.current_customer_email
if not email:
return None, "Customer identity not set for this store (current_customer_email is unset)"
customer = get_customer_by_email(email)
if customer is None:
return None, f"Customer '{email}' not found in store"
return customer, None
def _customer_error(message: str) -> dict[str, Any]:
return {"customer": None, "userErrors": [{"field": "current_customer_email", "message": message}]}
def handle_get_my_customer() -> dict[str, Any]:
"""Return the current customer's own profile."""
customer, err = _current_customer()
if customer is None:
return _customer_error(err or "Customer not found")
return {"customer": customer, "userErrors": []}
def handle_update_my_customer(
first_name: str | None = None,
last_name: str | None = None,
phone: str | None = None,
address: dict | None = None,
accepts_marketing: bool | None = None,
) -> dict[str, Any]:
"""Update a limited set of fields on the current customer's own profile.
Admin-managed fields (tags, note, state, ordersCount/totalSpent, loyalty
balances) are not accessible here — those remain on the admin
`update_customer` tool.
"""
customer, err = _current_customer()
if customer is None:
return _customer_error(err or "Customer not found")
address_model = MailingAddress.model_validate(address) if address is not None else None
if first_name is not None:
customer.firstName = first_name
if last_name is not None:
customer.lastName = last_name
if phone is not None:
customer.phone = phone
if accepts_marketing is not None:
customer.acceptsMarketing = accepts_marketing
if address_model is not None:
_set_default_address(customer, address_model)
customer.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return {"customer": customer, "userErrors": []}
def handle_get_my_loyalty_balance() -> dict[str, Any]:
"""Return the current customer's loyalty balance/tier."""
customer, err = _current_customer()
if customer is None:
return {
"balance": None,
"userErrors": [{"field": "current_customer_email", "message": err or "Customer not found"}],
}
return {
"balance": {
"customerId": customer.id,
"pointsBalance": customer.pointsBalance,
"lifetimePoints": customer.lifetimePoints,
"tier": customer.tier,
"loyaltyJoinedAt": customer.loyaltyJoinedAt,
},
"userErrors": [],
}
def handle_get_my_loyalty_tier() -> dict[str, Any]:
"""Return the full tier object for the current customer's tier (or null)."""
customer, err = _current_customer()
if customer is None:
return {
"tier": None,
"userErrors": [{"field": "current_customer_email", "message": err or "Customer not found"}],
}
state = get_state()
tier_obj = compute_tier(customer.lifetimePoints, state.loyalty_program.tiers)
return {"tier": tier_obj, "userErrors": []}
def handle_redeem_my_points(points: int) -> dict[str, Any]:
"""Redeem loyalty points from the current customer's balance."""
customer, err = _current_customer()
if customer is None:
return {
"redemption": None,
"userErrors": [{"field": "current_customer_email", "message": err or "Customer not found"}],
}
# Reuse the admin handler's validation/logic by calling it with the
# resolved customer's id. The admin tool already enforces balance limits.
from shopify.tools.loyalty import handle_redeem_points
return handle_redeem_points(RedeemPointsArgs(customer_id=customer.id, points=points))
def handle_list_my_orders(limit: int = 20, after: str | None = None) -> dict[str, Any]:
"""List orders belonging to the current customer."""
customer, err = _current_customer()
if customer is None:
return {
"orders": [],
"totalCount": 0,
"pageInfo": {"hasNextPage": False, "endCursor": None},
"userErrors": [{"field": "current_customer_email", "message": err or "Customer not found"}],
}
state = get_state()
email_lower = customer.email.lower()
mine = [order for order in state.orders.values() if (order.email or "").lower() == email_lower]
mine.sort(key=lambda order: order.createdAt, reverse=True)
# Simple cursor = starting index encoded as str.
start = 0
if after is not None:
try:
start = int(after) + 1
except ValueError:
start = 0
end = start + limit
paginated = mine[start:end]
has_next = end < len(mine)
end_cursor = str(end - 1) if paginated else None
return {
"orders": paginated,
"totalCount": len(mine),
"pageInfo": {"hasNextPage": has_next, "endCursor": end_cursor},
"userErrors": [],
}
def handle_get_my_order(args: GetOrderArgs) -> dict[str, Any]:
"""Return a single order by ID, only if it belongs to the current customer."""
customer, err = _current_customer()
if customer is None:
return {
"order": None,
"userErrors": [{"field": "current_customer_email", "message": err or "Customer not found"}],
}
order = get_order_by_id(args.order_id)
if order is None:
return {"order": None, "userErrors": [{"field": "order_id", "message": f"Order not found: {args.order_id}"}]}
if (order.email or "").lower() != customer.email.lower():
return {
"order": None,
"userErrors": [{"field": "order_id", "message": "Order does not belong to the current customer"}],
}
return {"order": order, "userErrors": []}
def handle_create_my_return(args: CreateReturnArgs) -> dict[str, Any]:
"""Create a return on an order, only if it belongs to the current customer.
Reuses the admin `handle_create_return` after the ownership check, so
refund math and validation stay in one place.
"""
customer, err = _current_customer()
if customer is None:
return {
"return": None,
"userErrors": [{"field": "current_customer_email", "message": err or "Customer not found"}],
}
order = get_order_by_id(args.order_id)
if order is None:
return {"return": None, "userErrors": [{"field": "order_id", "message": f"Order not found: {args.order_id}"}]}
if (order.email or "").lower() != customer.email.lower():
return {
"return": None,
"userErrors": [{"field": "order_id", "message": "Order does not belong to the current customer"}],
}
from shopify.tools.reviews_returns import handle_create_return
return handle_create_return(args)
def handle_create_my_review(
product_id: str,
rating: int,
title: str = "",
body: str = "",
) -> dict[str, Any]:
"""Post a review as the current customer (author/email filled in automatically)."""
customer, err = _current_customer()
if customer is None:
return {
"review": None,
"userErrors": [{"field": "current_customer_email", "message": err or "Customer not found"}],
}
# Sign the review with the customer's name+email. Avoid importing the
# admin create_review handler directly to sidestep its arg model,
# which demands separate author/email inputs.
from shopify.tools.reviews_returns import handle_create_review
display = " ".join(filter(None, [customer.firstName, customer.lastName])) or customer.email
try:
args = CreateReviewArgs(
product_id=product_id,
rating=rating,
title=title,
body=body,
author=display,
email=customer.email,
)
except ValidationError as exc:
return {
"review": None,
"userErrors": [
{"field": ".".join(map(str, error["loc"])), "message": error["msg"]} for error in exc.errors()
],
}
return handle_create_review(args)
__all__ = [
"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",
]
@@ -0,0 +1,103 @@
"""Shipping method tool handlers."""
import re
from shopify.models import (
CreateShippingMethodArgs,
DeleteShippingMethodArgs,
ListShippingMethodsArgs,
MoneyV2,
UpdateShippingMethodArgs,
)
from shopify.state import (
LooseShippingMethod,
get_all_shipping_methods,
get_shipping_method_by_id,
get_state,
save_state,
)
def handle_create_shipping_method(args: CreateShippingMethodArgs) -> dict:
"""Create a new shipping method."""
method_id = re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
if not method_id:
return {
"shippingMethod": None,
"userErrors": [
{"field": "title", "message": "Shipping method title must contain at least one letter or number"}
],
}
existing = get_shipping_method_by_id(method_id)
if existing is not None:
return {
"shippingMethod": None,
"userErrors": [{"field": "title", "message": f"Shipping method '{method_id}' already exists"}],
}
method = {
"id": method_id,
"title": args.title,
"price": {"amount": args.price, "currencyCode": "USD"},
"estimatedDays": args.estimated_days,
"active": True,
}
state = get_state()
shipping_method = LooseShippingMethod.model_validate(method)
state.shipping_methods[method_id] = shipping_method
save_state()
return {"shippingMethod": shipping_method, "userErrors": []}
def handle_list_shipping_methods(args: ListShippingMethodsArgs) -> dict:
"""List all shipping methods."""
methods = get_all_shipping_methods()
if args.active_only:
methods = [method for method in methods if method.active]
methods.sort(key=lambda method: float(method.price.amount))
return {"shippingMethods": methods, "totalCount": len(methods)}
def handle_update_shipping_method(args: UpdateShippingMethodArgs) -> dict:
"""Update a shipping method."""
method = get_shipping_method_by_id(args.shipping_method_id)
if method is None:
return {
"shippingMethod": None,
"userErrors": [
{"field": "shipping_method_id", "message": f"Shipping method not found: {args.shipping_method_id}"}
],
}
if args.title is not None:
method.title = args.title
if args.price is not None:
method.price = MoneyV2(amount=args.price, currencyCode="USD")
if args.estimated_days is not None:
method.estimatedDays = args.estimated_days
if args.active is not None:
method.active = args.active
save_state()
return {"shippingMethod": method, "userErrors": []}
def handle_delete_shipping_method(args: DeleteShippingMethodArgs) -> dict:
"""Delete a shipping method."""
method = get_shipping_method_by_id(args.shipping_method_id)
if method is None:
return {
"deletedMethodId": None,
"userErrors": [
{"field": "shipping_method_id", "message": f"Shipping method not found: {args.shipping_method_id}"}
],
}
state = get_state()
del state.shipping_methods[args.shipping_method_id]
save_state()
return {"deletedMethodId": args.shipping_method_id, "userErrors": []}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""
Utilities for Shopify MCP state management.
Used by preprocess and evaluation scripts.
"""
from pathlib import Path
def get_shopify_state_path(agent_workspace: str | Path) -> Path:
"""
Get the Shopify state file path for a given workspace.
Stores in external_services/ directory NEXT TO the workspace:
- If workspace is at /workspace/dumps/workspace, stores at /workspace/dumps/external_services/
- Outside the agent workspace so it can't be read directly via filesystem MCP
- Inside the dumps mount so it persists to the host
- Path is deterministic and can be computed by preprocess, MCP, and evaluation
"""
workspace_path = Path(agent_workspace)
# Go up one level from workspace and create external_services directory
external_services_dir = workspace_path.parent / "external_services"
return external_services_dir / "shopify_data.json"
+854
View File
@@ -0,0 +1,854 @@
"""Shopify viewer — read-only product catalog UI and API endpoints.
Serves:
GET /api/products — product list with summary info (supports ?search=X&type=X&vendor=X)
GET /api/products/:id — product detail with all variants
GET /api/carts — list all carts
GET /api/carts/:id — cart detail with line items
GET /api/policies — list policies
GET / — viewer HTML (single-page app)
All non-MCP routes require the X-Proxy-Token header.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from typing import Any
import uvicorn
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route
# ---------------------------------------------------------------------------
# Auth middleware
# ---------------------------------------------------------------------------
class ProxyTokenMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path.startswith("/mcp"):
return await call_next(request)
token = os.environ.get("MCP_PROXY_TOKEN", "")
if token and request.headers.get("x-proxy-token") != token:
return Response("Forbidden: invalid proxy token", status_code=403)
return await call_next(request)
# ---------------------------------------------------------------------------
# API handlers
# ---------------------------------------------------------------------------
async def api_products(request: Request) -> JSONResponse:
from shopify.models import SearchFilter
from shopify.state import get_state, search_products
search = request.query_params.get("search", "").strip()
type_filter = request.query_params.get("type", "").strip()
vendor_filter = request.query_params.get("vendor", "").strip()
state = get_state()
if search:
filters = []
if type_filter:
filters.append(SearchFilter(productType=type_filter))
if vendor_filter:
filters.append(SearchFilter(productVendor=vendor_filter))
products, _, _, _ = search_products(search, filters=filters or None, limit=250)
else:
products = list(state.products.values())
if type_filter:
products = [p for p in products if p.productType.lower() == type_filter.lower()]
if vendor_filter:
products = [p for p in products if p.vendor.lower() == vendor_filter.lower()]
return JSONResponse(
{
"products": [_format_product_summary(p) for p in products],
"total": len(products),
}
)
async def api_product_detail(request: Request) -> JSONResponse:
from shopify.state import get_product_by_id
product_id = request.path_params["product_id"]
product = get_product_by_id(product_id)
if product is None:
return JSONResponse({"error": "Product not found"}, status_code=404)
return JSONResponse({"product": _format_product_full(product)})
async def api_carts(request: Request) -> JSONResponse:
from shopify.state import get_all_carts
carts = get_all_carts()
return JSONResponse(
{
"carts": [_format_cart_summary(c) for c in carts],
"total": len(carts),
}
)
async def api_cart_detail(request: Request) -> JSONResponse:
from shopify.state import get_cart_by_id
cart_id = request.path_params["cart_id"]
cart = get_cart_by_id(cart_id)
if cart is None:
return JSONResponse({"error": "Cart not found"}, status_code=404)
return JSONResponse({"cart": _as_json_value(cart)})
async def api_policies(request: Request) -> JSONResponse:
from shopify.state import get_state
state = get_state()
return JSONResponse({"policies": _as_json_value(state.policies)})
async def viewer_html(request: Request) -> HTMLResponse:
return HTMLResponse(VIEWER_HTML)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _format_product_summary(product: Any) -> dict[str, Any]:
p: dict[str, Any] = _as_json_dict(product)
price_range = p.get("priceRange") or {}
min_price = price_range.get("minVariantPrice", {})
images = p.get("images", [])
image_url = images[0].get("url") if images else None
return {
"id": p.get("id"),
"title": p.get("title"),
"vendor": p.get("vendor"),
"productType": p.get("productType"),
"tags": p.get("tags", []),
"availableForSale": p.get("availableForSale", True),
"price": min_price.get("amount"),
"currencyCode": min_price.get("currencyCode", "USD"),
"image": image_url,
"handle": p.get("handle"),
}
def _format_product_full(product: Any) -> dict[str, Any]:
p: dict[str, Any] = _as_json_dict(product)
d = _format_product_summary(p)
d["description"] = p.get("description", "")
d["variants"] = p.get("variants", [])
d["images"] = p.get("images", [])
d["options"] = p.get("options", [])
d["priceRange"] = p.get("priceRange", {})
return d
def _format_cart_summary(cart: Any) -> dict[str, Any]:
c: dict[str, Any] = _as_json_dict(cart)
return {
"id": c.get("id"),
"totalQuantity": c.get("totalQuantity", 0),
"itemCount": len(c.get("lines", [])),
"totalAmount": c.get("cost", {}).get("totalAmount", {}),
"createdAt": c.get("createdAt"),
"updatedAt": c.get("updatedAt"),
"note": c.get("note"),
"checkoutUrl": c.get("checkoutUrl"),
}
def _as_json_dict(value: Any) -> dict[str, Any]:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json", by_alias=True, exclude_none=True)
if isinstance(value, Mapping):
return dict(value)
return {}
def _as_json_value(value: Any) -> Any:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json", by_alias=True, exclude_none=True)
if isinstance(value, Mapping):
return {key: _as_json_value(item) for key, item in value.items()}
if isinstance(value, list):
return [_as_json_value(item) for item in value]
return value
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def create_shopify_viewer_app():
routes = [
Route("/", viewer_html),
Route("/api/products", api_products),
Route("/api/products/{product_id:path}", api_product_detail),
Route("/api/carts", api_carts),
Route("/api/carts/{cart_id:path}", api_cart_detail),
Route("/api/policies", api_policies),
]
return Starlette(
routes=routes,
middleware=[Middleware(ProxyTokenMiddleware)],
)
def run_http_server(mcp_app, port: int) -> None:
"""Run combined MCP + viewer HTTP server."""
fastmcp_asgi = mcp_app.http_app(
transport="streamable-http",
path="/mcp",
)
viewer = create_shopify_viewer_app()
async def combined_app(scope, receive, send):
if scope["type"] == "lifespan":
await fastmcp_asgi(scope, receive, send)
return
path = scope.get("path", "")
if path.startswith("/mcp"):
await fastmcp_asgi(scope, receive, send)
else:
await viewer(scope, receive, send)
uvicorn.run(
combined_app,
host="127.0.0.1",
port=port,
log_level="warning",
)
# ---------------------------------------------------------------------------
# Viewer HTML
# ---------------------------------------------------------------------------
VIEWER_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shopify Catalog</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f6f6f7; color: #202223; display: flex; flex-direction: column; height: 100vh; }
/* Top nav */
.topnav { background: #1a1a2e; color: #fff; display: flex; align-items: center; padding: 0 20px; height: 52px; gap: 24px; flex-shrink: 0; }
.topnav .brand { font-size: 15px; font-weight: 700; color: #fff; letter-spacing: -0.3px; }
.topnav .brand span { color: #95bf47; }
.nav-links { display: flex; gap: 4px; }
.nav-link { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #c9ccd1; border: none; background: none; }
.nav-link:hover { background: rgba(255,255,255,0.1); color: #fff; }
.nav-link.active { background: rgba(255,255,255,0.15); color: #fff; font-weight: 600; }
.nav-spacer { flex: 1; }
.cart-badge { background: #95bf47; color: #fff; font-size: 11px; font-weight: 700; border-radius: 10px; padding: 2px 7px; }
/* Main layout */
.main { display: flex; flex: 1; overflow: hidden; }
/* Sidebar */
.sidebar { width: 220px; min-width: 220px; background: #fff; border-right: 1px solid #e1e3e5; display: flex; flex-direction: column; overflow-y: auto; }
.sidebar-section { padding: 16px; border-bottom: 1px solid #e1e3e5; }
.sidebar-section h3 { font-size: 11px; font-weight: 600; color: #6d7175; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.filter-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; cursor: pointer; font-size: 13px; color: #202223; }
.filter-item input[type=radio], .filter-item input[type=checkbox] { accent-color: #008060; }
.filter-item.active { color: #008060; font-weight: 600; }
.filter-count { margin-left: auto; font-size: 11px; color: #8c9196; }
/* Content area */
.content { flex: 1; display: flex; overflow: hidden; }
/* Product list */
.product-list-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.list-toolbar { padding: 12px 20px; background: #fff; border-bottom: 1px solid #e1e3e5; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.list-toolbar input { flex: 1; padding: 8px 12px; border: 1px solid #8c9196; border-radius: 6px; font-size: 13px; }
.list-toolbar input:focus { outline: none; border-color: #008060; box-shadow: 0 0 0 2px rgba(0,128,96,0.15); }
.results-count { font-size: 13px; color: #6d7175; white-space: nowrap; }
.view-toggle { display: flex; gap: 2px; }
.view-btn { padding: 6px 8px; border: 1px solid #d1d5db; background: #fff; cursor: pointer; font-size: 14px; color: #6d7175; }
.view-btn:first-child { border-radius: 6px 0 0 6px; }
.view-btn:last-child { border-radius: 0 6px 6px 0; }
.view-btn.active { background: #f1f2f3; color: #202223; }
.products-scroll { flex: 1; overflow-y: auto; padding: 20px; }
/* Product grid */
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.product-card { background: #fff; border: 1px solid #e1e3e5; border-radius: 8px; cursor: pointer; overflow: hidden; transition: box-shadow 0.15s; }
.product-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.product-card.active { border-color: #008060; box-shadow: 0 0 0 2px rgba(0,128,96,0.2); }
.product-thumb { width: 100%; aspect-ratio: 1; background: #f1f2f3; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.product-thumb img { width: 100%; height: 100%; object-fit: cover; }
.product-thumb-placeholder { font-size: 36px; color: #8c9196; }
.product-info { padding: 12px; }
.product-title { font-size: 13px; font-weight: 600; color: #202223; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.product-vendor { font-size: 11px; color: #6d7175; margin-bottom: 6px; }
.product-price { font-size: 14px; font-weight: 700; color: #202223; }
.product-type-badge { display: inline-block; font-size: 10px; padding: 2px 7px; background: #f1f2f3; border-radius: 10px; color: #6d7175; margin-top: 6px; }
.avail-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px; }
.avail-dot.yes { background: #95bf47; }
.avail-dot.no { background: #d72c0d; }
/* Product list (rows) */
.products-list { display: flex; flex-direction: column; gap: 2px; }
.product-row { background: #fff; border: 1px solid #e1e3e5; border-radius: 6px; display: flex; align-items: center; gap: 14px; padding: 10px 14px; cursor: pointer; }
.product-row:hover { background: #f6f6f7; }
.product-row.active { border-color: #008060; background: #f0faf7; }
.product-row-thumb { width: 44px; height: 44px; border-radius: 4px; background: #f1f2f3; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0; font-size: 20px; color: #8c9196; }
.product-row-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; }
.product-row-info { flex: 1; min-width: 0; }
.product-row-title { font-size: 13px; font-weight: 600; color: #202223; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.product-row-meta { font-size: 12px; color: #6d7175; margin-top: 2px; }
.product-row-price { font-size: 13px; font-weight: 700; color: #202223; flex-shrink: 0; }
.product-row-type { font-size: 11px; color: #6d7175; flex-shrink: 0; min-width: 80px; text-align: right; }
/* Detail panel */
.detail-panel { width: 380px; min-width: 380px; background: #fff; border-left: 1px solid #e1e3e5; display: flex; flex-direction: column; overflow-y: auto; }
.detail-panel.hidden { display: none; }
.detail-close { position: sticky; top: 0; background: #fff; z-index: 1; padding: 12px 16px; border-bottom: 1px solid #e1e3e5; display: flex; justify-content: space-between; align-items: center; }
.detail-close h3 { font-size: 14px; font-weight: 600; }
.close-btn { background: none; border: none; font-size: 18px; cursor: pointer; color: #6d7175; padding: 2px 6px; border-radius: 4px; }
.close-btn:hover { background: #f1f2f3; }
.detail-image { width: 100%; aspect-ratio: 1; background: #f1f2f3; display: flex; align-items: center; justify-content: center; overflow: hidden; font-size: 64px; color: #8c9196; }
.detail-image img { width: 100%; height: 100%; object-fit: cover; }
.detail-body { padding: 16px; }
.detail-body h2 { font-size: 16px; font-weight: 700; margin-bottom: 4px; line-height: 1.3; }
.detail-vendor { font-size: 12px; color: #6d7175; margin-bottom: 12px; }
.detail-price-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.detail-price { font-size: 22px; font-weight: 700; color: #202223; }
.detail-avail { font-size: 12px; padding: 3px 8px; border-radius: 10px; }
.detail-avail.yes { background: #e3f1da; color: #1c6b2a; }
.detail-avail.no { background: #fce8e6; color: #7a0000; }
.detail-desc { font-size: 13px; color: #4a4a4a; line-height: 1.6; margin-bottom: 16px; white-space: pre-wrap; }
.detail-section-title { font-size: 11px; font-weight: 600; color: #6d7175; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.variants-table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
.variants-table th { font-size: 11px; font-weight: 600; color: #6d7175; text-align: left; padding: 6px 8px; border-bottom: 1px solid #e1e3e5; }
.variants-table td { font-size: 12px; padding: 7px 8px; border-bottom: 1px solid #f1f2f3; }
.variants-table tr:last-child td { border-bottom: none; }
.tag-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.tag { display: inline-block; font-size: 11px; padding: 3px 9px; background: #f1f2f3; border-radius: 10px; color: #6d7175; }
.img-gallery { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.img-gallery img { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; border: 1px solid #e1e3e5; cursor: pointer; }
.img-gallery img:hover { border-color: #008060; }
/* Carts view */
.carts-view { flex: 1; overflow-y: auto; padding: 20px; }
.carts-view.hidden { display: none; }
.carts-grid { display: flex; flex-direction: column; gap: 12px; }
.cart-card { background: #fff; border: 1px solid #e1e3e5; border-radius: 8px; padding: 16px; cursor: pointer; }
.cart-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.cart-card.active { border-color: #008060; }
.cart-id { font-size: 11px; color: #6d7175; margin-bottom: 8px; font-family: monospace; word-break: break-all; }
.cart-meta { display: flex; gap: 16px; }
.cart-stat { text-align: center; }
.cart-stat .val { font-size: 18px; font-weight: 700; color: #202223; }
.cart-stat .lbl { font-size: 11px; color: #6d7175; }
.cart-total { font-size: 14px; font-weight: 700; margin-top: 8px; }
/* Cart detail panel */
.cart-lines { margin-top: 12px; }
.cart-line { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #f1f2f3; }
.cart-line:last-child { border-bottom: none; }
.cart-line-thumb { width: 40px; height: 40px; background: #f1f2f3; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 18px; color: #8c9196; flex-shrink: 0; overflow: hidden; }
.cart-line-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; }
.cart-line-info { flex: 1; min-width: 0; }
.cart-line-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cart-line-variant { font-size: 11px; color: #6d7175; }
.cart-line-qty { font-size: 12px; color: #6d7175; }
.cart-line-price { font-size: 13px; font-weight: 700; flex-shrink: 0; }
/* Policies view */
.policies-view { flex: 1; overflow-y: auto; padding: 20px; }
.policies-view.hidden { display: none; }
.policy-card { background: #fff; border: 1px solid #e1e3e5; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
.policy-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; }
.policy-body { font-size: 13px; color: #4a4a4a; line-height: 1.7; white-space: pre-wrap; }
/* Empty states */
.empty { text-align: center; padding: 60px 20px; color: #8c9196; font-size: 14px; }
.empty-icon { font-size: 40px; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="topnav">
<div class="brand"><span>●</span> Shopify Catalog</div>
<div class="nav-links">
<button class="nav-link active" id="nav-products" onclick="showView('products')">Products</button>
<button class="nav-link" id="nav-carts" onclick="showView('carts')">Carts <span id="cart-badge" class="cart-badge" style="display:none"></span></button>
<button class="nav-link" id="nav-policies" onclick="showView('policies')">Policies</button>
</div>
<div class="nav-spacer"></div>
<div id="total-count" style="font-size:12px;color:#8c9196;"></div>
</div>
<div class="main">
<!-- Sidebar filters -->
<div class="sidebar" id="sidebar">
<div class="sidebar-section">
<h3>Availability</h3>
<label class="filter-item"><input type="radio" name="avail" value="" onchange="applyFilters()" checked> All</label>
<label class="filter-item"><input type="radio" name="avail" value="true" onchange="applyFilters()"> Available</label>
<label class="filter-item"><input type="radio" name="avail" value="false" onchange="applyFilters()"> Unavailable</label>
</div>
<div class="sidebar-section" id="type-section">
<h3>Product Type</h3>
<div id="type-filters"></div>
</div>
<div class="sidebar-section" id="vendor-section">
<h3>Vendor</h3>
<div id="vendor-filters"></div>
</div>
</div>
<!-- Content -->
<div class="content">
<!-- Products view -->
<div class="product-list-pane" id="products-view">
<div class="list-toolbar">
<input type="text" id="search-input" placeholder="Search products..." oninput="onSearch()">
<span class="results-count" id="results-count"></span>
<div class="view-toggle">
<button class="view-btn active" id="view-grid" onclick="setView('grid')" title="Grid view">⊞</button>
<button class="view-btn" id="view-list" onclick="setView('list')" title="List view">≡</button>
</div>
</div>
<div class="products-scroll">
<div id="products-container"><div class="empty"><div class="empty-icon">⏳</div>Loading...</div></div>
</div>
</div>
<!-- Carts view -->
<div class="carts-view hidden" id="carts-view">
<div id="carts-container"><div class="empty"><div class="empty-icon">⏳</div>Loading...</div></div>
</div>
<!-- Policies view -->
<div class="policies-view hidden" id="policies-view">
<div id="policies-container"><div class="empty"><div class="empty-icon">⏳</div>Loading...</div></div>
</div>
<!-- Detail panel -->
<div class="detail-panel hidden" id="detail-panel">
<div class="detail-close">
<h3 id="detail-panel-title">Product Detail</h3>
<button class="close-btn" onclick="closeDetail()">✕</button>
</div>
<div id="detail-content"></div>
</div>
</div>
</div>
<script>
let allProducts = [];
let filteredProducts = [];
let selectedTypes = new Set();
let selectedVendors = new Set();
let viewMode = 'grid';
let searchTimeout = null;
let currentView = 'products';
let selectedProductId = null;
let selectedCartId = null;
const base = window.location.pathname.replace(/\\/$/, '');
async function fetchJSON(path) {
const r = await fetch(base + path);
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
function esc(s) {
if (!s && s !== 0) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function formatPrice(amount, currency) {
if (!amount) return '';
try {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency || 'USD' }).format(parseFloat(amount));
} catch { return amount + ' ' + (currency || ''); }
}
function productColor(id) {
const colors = ['#e8d5f5','#d5e8f5','#d5f5e8','#f5e8d5','#f5d5d5','#d5f5f5'];
let hash = 0;
for (let i = 0; i < (id || '').length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
return colors[hash % colors.length];
}
// ---- Products ----
async function loadProducts() {
try {
const data = await fetchJSON('/api/products?_limit=500');
allProducts = data.products || [];
buildFilters();
applyFilters();
document.getElementById('total-count').textContent = allProducts.length + ' products';
} catch (e) {
document.getElementById('products-container').innerHTML = '<div class="empty"><div class="empty-icon">⚠️</div>Failed to load products</div>';
}
}
function buildFilters() {
const types = {};
const vendors = {};
allProducts.forEach(p => {
if (p.productType) types[p.productType] = (types[p.productType] || 0) + 1;
if (p.vendor) vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
});
const typeEl = document.getElementById('type-filters');
typeEl.innerHTML = '<label class="filter-item"><input type="radio" name="ptype" value="" onchange="onTypeChange(\\'\\')" checked> All</label>' +
Object.entries(types).sort().map(([t, c]) =>
'<label class="filter-item"><input type="radio" name="ptype" value="' + esc(t) + '" onchange="onTypeChange(\\'' + esc(t) + '\\')">' +
'<span>' + esc(t) + '</span><span class="filter-count">' + c + '</span></label>'
).join('');
const vendorEl = document.getElementById('vendor-filters');
vendorEl.innerHTML = '<label class="filter-item"><input type="radio" name="pvendor" value="" onchange="onVendorChange(\\'\\')" checked> All</label>' +
Object.entries(vendors).sort().map(([v, c]) =>
'<label class="filter-item"><input type="radio" name="pvendor" value="' + esc(v) + '" onchange="onVendorChange(\\'' + esc(v) + '\\')">' +
'<span>' + esc(v) + '</span><span class="filter-count">' + c + '</span></label>'
).join('');
}
let activeType = '';
let activeVendor = '';
function onTypeChange(t) { activeType = t; applyFilters(); }
function onVendorChange(v) { activeVendor = v; applyFilters(); }
function applyFilters() {
const search = (document.getElementById('search-input').value || '').toLowerCase();
const availVal = document.querySelector('input[name=avail]:checked')?.value || '';
filteredProducts = allProducts.filter(p => {
if (activeType && (p.productType || '') !== activeType) return false;
if (activeVendor && (p.vendor || '') !== activeVendor) return false;
if (availVal === 'true' && !p.availableForSale) return false;
if (availVal === 'false' && p.availableForSale) return false;
if (search) {
const hay = ((p.title || '') + ' ' + (p.vendor || '') + ' ' + (p.productType || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
if (!hay.includes(search)) return false;
}
return true;
});
document.getElementById('results-count').textContent = filteredProducts.length + ' of ' + allProducts.length;
renderProducts();
}
function renderProducts() {
const container = document.getElementById('products-container');
if (!filteredProducts.length) {
container.innerHTML = '<div class="empty"><div class="empty-icon">🔍</div>No products found</div>';
return;
}
if (viewMode === 'grid') {
container.innerHTML = '<div class="products-grid">' +
filteredProducts.map(p => productCardHTML(p)).join('') + '</div>';
} else {
container.innerHTML = '<div class="products-list">' +
filteredProducts.map(p => productRowHTML(p)).join('') + '</div>';
}
}
function productCardHTML(p) {
const price = formatPrice(p.price, p.currencyCode);
const thumbHTML = p.image
? '<img src="' + esc(p.image) + '" alt="" onerror="this.parentNode.innerHTML=\\'<span class=product-thumb-placeholder>🛍️</span>\\'">'
: '<span class="product-thumb-placeholder" style="background:' + productColor(p.id) + ';width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🛍️</span>';
const active = p.id === selectedProductId ? ' active' : '';
return '<div class="product-card' + active + '" onclick="showProduct(\\'' + esc(p.id) + '\\')">' +
'<div class="product-thumb">' + thumbHTML + '</div>' +
'<div class="product-info">' +
'<div class="product-title">' + esc(p.title) + '</div>' +
'<div class="product-vendor">' + esc(p.vendor) + '</div>' +
'<div class="product-price">' + price + '</div>' +
(p.productType ? '<div class="product-type-badge">' + esc(p.productType) + '</div>' : '') +
'</div>' +
'</div>';
}
function productRowHTML(p) {
const price = formatPrice(p.price, p.currencyCode);
const thumbHTML = p.image
? '<img src="' + esc(p.image) + '" alt="" onerror="this.innerHTML=\\'🛍️\\'">'
: '🛍️';
const active = p.id === selectedProductId ? ' active' : '';
const avail = p.availableForSale ? '<span class="avail-dot yes"></span>In stock' : '<span class="avail-dot no"></span>Out of stock';
return '<div class="product-row' + active + '" onclick="showProduct(\\'' + esc(p.id) + '\\')">' +
'<div class="product-row-thumb">' + thumbHTML + '</div>' +
'<div class="product-row-info">' +
'<div class="product-row-title">' + esc(p.title) + '</div>' +
'<div class="product-row-meta">' + esc(p.vendor) + ' · ' + avail + '</div>' +
'</div>' +
'<div class="product-row-type">' + esc(p.productType) + '</div>' +
'<div class="product-row-price">' + price + '</div>' +
'</div>';
}
async function showProduct(id) {
selectedProductId = id;
renderProducts(); // re-render to highlight
const panel = document.getElementById('detail-panel');
const content = document.getElementById('detail-content');
content.innerHTML = '<div class="empty">Loading...</div>';
panel.classList.remove('hidden');
try {
const data = await fetchJSON('/api/products/' + encodeURIComponent(id));
const p = data.product;
renderProductDetail(p);
} catch (e) {
content.innerHTML = '<div class="empty">Failed to load product</div>';
}
}
function renderProductDetail(p) {
const content = document.getElementById('detail-content');
document.getElementById('detail-panel-title').textContent = 'Product';
const images = p.images || [];
const mainImage = images[0]?.url;
const price = formatPrice(p.price, p.currencyCode);
const availClass = p.availableForSale ? 'yes' : 'no';
const availText = p.availableForSale ? 'In stock' : 'Out of stock';
let html = '';
// Main image
if (mainImage) {
html += '<div class="detail-image"><img src="' + esc(mainImage) + '" alt="" onerror="this.parentNode.innerHTML=\\'<span>🛍️</span>\\'">' + '</div>';
} else {
const bg = productColor(p.id);
html += '<div class="detail-image" style="background:' + bg + '">🛍️</div>';
}
html += '<div class="detail-body">';
html += '<h2>' + esc(p.title) + '</h2>';
html += '<div class="detail-vendor">' + esc(p.vendor) + (p.productType ? ' · ' + esc(p.productType) : '') + '</div>';
html += '<div class="detail-price-row"><span class="detail-price">' + price + '</span><span class="detail-avail ' + availClass + '">' + availText + '</span></div>';
if (p.description) {
html += '<div class="detail-desc">' + esc(p.description) + '</div>';
}
// Variants
const variants = p.variants || [];
if (variants.length) {
html += '<div class="detail-section-title">Variants (' + variants.length + ')</div>';
html += '<table class="variants-table"><thead><tr><th>Title</th><th>SKU</th><th>Price</th><th>Qty</th></tr></thead><tbody>';
variants.forEach(v => {
const vp = v.price ? formatPrice(v.price.amount || v.price, v.price.currencyCode) : '';
const qty = v.quantityAvailable !== undefined && v.quantityAvailable !== null ? v.quantityAvailable : '';
html += '<tr><td>' + esc(v.title) + '</td><td style="font-family:monospace;font-size:11px">' + esc(v.sku || '') + '</td><td>' + vp + '</td><td>' + qty + '</td></tr>';
});
html += '</tbody></table>';
}
// Tags
const tags = p.tags || [];
if (tags.length) {
html += '<div class="detail-section-title">Tags</div>';
html += '<div class="tag-list">' + tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('') + '</div>';
}
// Image gallery
if (images.length > 1) {
html += '<div class="detail-section-title">Images</div>';
html += '<div class="img-gallery">' + images.map(img =>
'<img src="' + esc(img.url) + '" alt="' + esc(img.altText || '') + '" onclick="document.querySelector(\\'.detail-image\\').innerHTML=\\'<img src=\\\\\\"' + esc(img.url) + '\\\\\\" style=\\\\"width:100%;height:100%;object-fit:cover\\\\">\\'">'
).join('') + '</div>';
}
html += '</div>';
content.innerHTML = html;
}
// ---- Carts ----
async function loadCarts() {
try {
const data = await fetchJSON('/api/carts');
const carts = data.carts || [];
const badge = document.getElementById('cart-badge');
if (carts.length) { badge.textContent = carts.length; badge.style.display = ''; }
else badge.style.display = 'none';
renderCarts(carts);
} catch (e) {
document.getElementById('carts-container').innerHTML = '<div class="empty"><div class="empty-icon">⚠️</div>Failed to load carts</div>';
}
}
function renderCarts(carts) {
const container = document.getElementById('carts-container');
if (!carts.length) {
container.innerHTML = '<div class="empty"><div class="empty-icon">🛒</div>No carts found</div>';
return;
}
container.innerHTML = '<div class="carts-grid">' + carts.map(c => cartCardHTML(c)).join('') + '</div>';
}
function cartCardHTML(c) {
const total = c.totalAmount ? formatPrice(c.totalAmount.amount, c.totalAmount.currencyCode) : '';
const active = c.id === selectedCartId ? ' active' : '';
return '<div class="cart-card' + active + '" onclick="showCart(\\'' + esc(c.id) + '\\')">' +
'<div class="cart-id">' + esc(c.id) + '</div>' +
'<div class="cart-meta">' +
'<div class="cart-stat"><div class="val">' + (c.itemCount || 0) + '</div><div class="lbl">Items</div></div>' +
'<div class="cart-stat"><div class="val">' + (c.totalQuantity || 0) + '</div><div class="lbl">Qty</div></div>' +
'</div>' +
'<div class="cart-total">' + total + '</div>' +
'</div>';
}
async function showCart(id) {
selectedCartId = id;
loadCarts(); // re-render to highlight
const panel = document.getElementById('detail-panel');
const content = document.getElementById('detail-content');
document.getElementById('detail-panel-title').textContent = 'Cart';
content.innerHTML = '<div class="empty">Loading...</div>';
panel.classList.remove('hidden');
try {
const data = await fetchJSON('/api/carts/' + encodeURIComponent(id));
renderCartDetail(data.cart);
} catch (e) {
content.innerHTML = '<div class="empty">Failed to load cart</div>';
}
}
function renderCartDetail(cart) {
const content = document.getElementById('detail-content');
const total = cart.cost?.totalAmount ? formatPrice(cart.cost.totalAmount.amount, cart.cost.totalAmount.currencyCode) : '';
const lines = cart.lines || [];
let html = '<div class="detail-body">';
html += '<div class="cart-id" style="margin-bottom:12px">' + esc(cart.id) + '</div>';
html += '<div class="detail-price-row"><span class="detail-price">' + total + '</span><span style="font-size:13px;color:#6d7175">' + (cart.totalQuantity || 0) + ' items</span></div>';
if (cart.note) html += '<div style="font-size:13px;color:#4a4a4a;margin:8px 0;padding:8px;background:#f6f6f7;border-radius:4px">' + esc(cart.note) + '</div>';
if (cart.checkoutUrl) html += '<div style="margin-bottom:12px"><a href="' + esc(cart.checkoutUrl) + '" target="_blank" style="font-size:12px;color:#008060">Checkout URL ↗</a></div>';
if (lines.length) {
html += '<div class="detail-section-title">Line Items</div>';
html += '<div class="cart-lines">';
lines.forEach(line => {
const merch = line.merchandise || {};
const product = merch.product || {};
const img = merch.image;
const thumbHTML = img ? '<img src="' + esc(img.url || img) + '" alt="">' : '🛍️';
const lineTotal = line.cost?.totalAmount ? formatPrice(line.cost.totalAmount.amount, line.cost.totalAmount.currencyCode) : '';
const opts = (merch.selectedOptions || []).map(o => o.name + ': ' + o.value).join(', ');
html += '<div class="cart-line">' +
'<div class="cart-line-thumb">' + thumbHTML + '</div>' +
'<div class="cart-line-info">' +
'<div class="cart-line-name">' + esc(product.title || merch.title || 'Item') + '</div>' +
(merch.title && merch.title !== 'Default Title' ? '<div class="cart-line-variant">' + esc(merch.title) + '</div>' : '') +
(opts ? '<div class="cart-line-variant">' + esc(opts) + '</div>' : '') +
'<div class="cart-line-qty">Qty: ' + (line.quantity || 0) + '</div>' +
'</div>' +
'<div class="cart-line-price">' + lineTotal + '</div>' +
'</div>';
});
html += '</div>';
} else {
html += '<div style="padding:20px 0;text-align:center;color:#8c9196;font-size:13px">Empty cart</div>';
}
html += '</div>';
content.innerHTML = html;
}
// ---- Policies ----
async function loadPolicies() {
try {
const data = await fetchJSON('/api/policies');
renderPolicies(data.policies || []);
} catch (e) {
document.getElementById('policies-container').innerHTML = '<div class="empty"><div class="empty-icon">⚠️</div>Failed to load policies</div>';
}
}
function renderPolicies(policies) {
const container = document.getElementById('policies-container');
if (!policies.length) {
container.innerHTML = '<div class="empty"><div class="empty-icon">📄</div>No policies found</div>';
return;
}
container.innerHTML = policies.map(p =>
'<div class="policy-card">' +
'<div class="policy-title">' + esc(p.title) + '</div>' +
'<div class="policy-body">' + esc(p.body) + '</div>' +
'</div>'
).join('');
}
// ---- View switching ----
function showView(view) {
currentView = view;
closeDetail();
document.getElementById('products-view').classList.toggle('hidden', view !== 'products');
document.getElementById('carts-view').classList.toggle('hidden', view !== 'carts');
document.getElementById('policies-view').classList.toggle('hidden', view !== 'policies');
document.getElementById('sidebar').style.display = view === 'products' ? '' : 'none';
document.getElementById('nav-products').classList.toggle('active', view === 'products');
document.getElementById('nav-carts').classList.toggle('active', view === 'carts');
document.getElementById('nav-policies').classList.toggle('active', view === 'policies');
if (view === 'carts') loadCarts();
if (view === 'policies') loadPolicies();
}
function closeDetail() {
document.getElementById('detail-panel').classList.add('hidden');
selectedProductId = null;
selectedCartId = null;
}
function setView(mode) {
viewMode = mode;
document.getElementById('view-grid').classList.toggle('active', mode === 'grid');
document.getElementById('view-list').classList.toggle('active', mode === 'list');
renderProducts();
}
function onSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 250);
}
// ---- Init ----
loadProducts();
loadCarts(); // pre-load for badge count
</script>
</body>
</html>"""
@@ -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"