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