feat: add shopify-development skill

- Add comprehensive Shopify development skill with validated GraphQL
- Fixed 4 mutations using Shopify MCP (fulfillmentCreate, appSubscription, etc.)
- Added shopify_graphql.py utilities with pagination & rate limiting
- Updated API version to 2026-01
- Added zircote/.claude as reference source
This commit is contained in:
“vuth-dogo”
2026-01-19 17:30:49 +07:00
parent 46d575b8d0
commit 9850b6b8e7
11 changed files with 3375 additions and 1 deletions

View File

@@ -0,0 +1,49 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Testing
.coverage
.pytest_cache/
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
*.py,cover
# Environments
.env
.venv
env/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,19 @@
# Shopify Skill Dependencies
# Python 3.10+ required
# No Python package dependencies - uses only standard library
# Testing dependencies (dev)
pytest>=8.0.0
pytest-cov>=4.1.0
pytest-mock>=3.12.0
# Note: This script requires the Shopify CLI tool
# Install Shopify CLI:
# npm install -g @shopify/cli @shopify/theme
# or via Homebrew (macOS):
# brew tap shopify/shopify
# brew install shopify-cli
#
# Authenticate with:
# shopify auth login

View File

@@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""
Shopify GraphQL Utilities
Helper functions for common Shopify GraphQL operations.
Provides query templates, pagination helpers, and rate limit handling.
Usage:
from shopify_graphql import ShopifyGraphQL
client = ShopifyGraphQL(shop_domain, access_token)
products = client.get_products(first=10)
"""
import os
import time
import json
from typing import Dict, List, Optional, Any, Generator
from dataclasses import dataclass
from urllib.request import Request, urlopen
from urllib.error import HTTPError
# API Configuration
API_VERSION = "2026-01"
MAX_RETRIES = 3
RETRY_DELAY = 1.0 # seconds
@dataclass
class GraphQLResponse:
"""Container for GraphQL response data."""
data: Optional[Dict[str, Any]] = None
errors: Optional[List[Dict[str, Any]]] = None
extensions: Optional[Dict[str, Any]] = None
@property
def is_success(self) -> bool:
return self.errors is None or len(self.errors) == 0
@property
def query_cost(self) -> Optional[int]:
"""Get the actual query cost from extensions."""
if self.extensions and 'cost' in self.extensions:
return self.extensions['cost'].get('actualQueryCost')
return None
class ShopifyGraphQL:
"""
Shopify GraphQL API client with built-in utilities.
Features:
- Query templates for common operations
- Automatic pagination
- Rate limit handling with exponential backoff
- Response parsing helpers
"""
def __init__(self, shop_domain: str, access_token: str):
"""
Initialize the GraphQL client.
Args:
shop_domain: Store domain (e.g., 'my-store.myshopify.com')
access_token: Admin API access token
"""
self.shop_domain = shop_domain.replace('https://', '').replace('http://', '')
self.access_token = access_token
self.base_url = f"https://{self.shop_domain}/admin/api/{API_VERSION}/graphql.json"
def execute(self, query: str, variables: Optional[Dict] = None) -> GraphQLResponse:
"""
Execute a GraphQL query/mutation.
Args:
query: GraphQL query string
variables: Query variables
Returns:
GraphQLResponse object
"""
payload = {"query": query}
if variables:
payload["variables"] = variables
headers = {
"Content-Type": "application/json",
"X-Shopify-Access-Token": self.access_token
}
for attempt in range(MAX_RETRIES):
try:
request = Request(
self.base_url,
data=json.dumps(payload).encode('utf-8'),
headers=headers,
method='POST'
)
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
return GraphQLResponse(
data=result.get('data'),
errors=result.get('errors'),
extensions=result.get('extensions')
)
except HTTPError as e:
if e.code == 429: # Rate limited
delay = RETRY_DELAY * (2 ** attempt)
print(f"Rate limited. Retrying in {delay}s...")
time.sleep(delay)
continue
raise
except Exception as e:
if attempt == MAX_RETRIES - 1:
raise
time.sleep(RETRY_DELAY)
return GraphQLResponse(errors=[{"message": "Max retries exceeded"}])
# ==================== Query Templates ====================
def get_products(
self,
first: int = 10,
query: Optional[str] = None,
after: Optional[str] = None
) -> GraphQLResponse:
"""
Query products with pagination.
Args:
first: Number of products to fetch (max 250)
query: Optional search query
after: Cursor for pagination
"""
gql = """
query GetProducts($first: Int!, $query: String, $after: String) {
products(first: $first, query: $query, after: $after) {
edges {
node {
id
title
handle
status
totalInventory
variants(first: 5) {
edges {
node {
id
title
price
inventoryQuantity
sku
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
return self.execute(gql, {"first": first, "query": query, "after": after})
def get_orders(
self,
first: int = 10,
query: Optional[str] = None,
after: Optional[str] = None
) -> GraphQLResponse:
"""
Query orders with pagination.
Args:
first: Number of orders to fetch (max 250)
query: Optional search query (e.g., "financial_status:paid")
after: Cursor for pagination
"""
gql = """
query GetOrders($first: Int!, $query: String, $after: String) {
orders(first: $first, query: $query, after: $after) {
edges {
node {
id
name
createdAt
displayFinancialStatus
displayFulfillmentStatus
totalPriceSet {
shopMoney { amount currencyCode }
}
customer {
id
firstName
lastName
}
lineItems(first: 5) {
edges {
node {
title
quantity
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
return self.execute(gql, {"first": first, "query": query, "after": after})
def get_customers(
self,
first: int = 10,
query: Optional[str] = None,
after: Optional[str] = None
) -> GraphQLResponse:
"""
Query customers with pagination.
Args:
first: Number of customers to fetch (max 250)
query: Optional search query
after: Cursor for pagination
"""
gql = """
query GetCustomers($first: Int!, $query: String, $after: String) {
customers(first: $first, query: $query, after: $after) {
edges {
node {
id
firstName
lastName
displayName
defaultEmailAddress {
emailAddress
}
numberOfOrders
amountSpent {
amount
currencyCode
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
return self.execute(gql, {"first": first, "query": query, "after": after})
def set_metafields(self, metafields: List[Dict]) -> GraphQLResponse:
"""
Set metafields on resources.
Args:
metafields: List of metafield inputs, each containing:
- ownerId: Resource GID
- namespace: Metafield namespace
- key: Metafield key
- value: Metafield value
- type: Metafield type
"""
gql = """
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
"""
return self.execute(gql, {"metafields": metafields})
# ==================== Pagination Helpers ====================
def paginate_products(
self,
batch_size: int = 50,
query: Optional[str] = None
) -> Generator[Dict, None, None]:
"""
Generator that yields all products with automatic pagination.
Args:
batch_size: Products per request (max 250)
query: Optional search query
Yields:
Product dictionaries
"""
cursor = None
while True:
response = self.get_products(first=batch_size, query=query, after=cursor)
if not response.is_success or not response.data:
break
products = response.data.get('products', {})
edges = products.get('edges', [])
for edge in edges:
yield edge['node']
page_info = products.get('pageInfo', {})
if not page_info.get('hasNextPage'):
break
cursor = page_info.get('endCursor')
def paginate_orders(
self,
batch_size: int = 50,
query: Optional[str] = None
) -> Generator[Dict, None, None]:
"""
Generator that yields all orders with automatic pagination.
Args:
batch_size: Orders per request (max 250)
query: Optional search query
Yields:
Order dictionaries
"""
cursor = None
while True:
response = self.get_orders(first=batch_size, query=query, after=cursor)
if not response.is_success or not response.data:
break
orders = response.data.get('orders', {})
edges = orders.get('edges', [])
for edge in edges:
yield edge['node']
page_info = orders.get('pageInfo', {})
if not page_info.get('hasNextPage'):
break
cursor = page_info.get('endCursor')
# ==================== Utility Functions ====================
def extract_id(gid: str) -> str:
"""
Extract numeric ID from Shopify GID.
Args:
gid: Global ID (e.g., 'gid://shopify/Product/123')
Returns:
Numeric ID string (e.g., '123')
"""
return gid.split('/')[-1] if gid else ''
def build_gid(resource_type: str, id: str) -> str:
"""
Build Shopify GID from resource type and ID.
Args:
resource_type: Resource type (e.g., 'Product', 'Order')
id: Numeric ID
Returns:
Global ID (e.g., 'gid://shopify/Product/123')
"""
return f"gid://shopify/{resource_type}/{id}"
# ==================== Example Usage ====================
def main():
"""Example usage of ShopifyGraphQL client."""
import os
# Load from environment
shop = os.environ.get('SHOP_DOMAIN', 'your-store.myshopify.com')
token = os.environ.get('SHOPIFY_ACCESS_TOKEN', '')
if not token:
print("Set SHOPIFY_ACCESS_TOKEN environment variable")
return
client = ShopifyGraphQL(shop, token)
# Example: Get first 5 products
print("Fetching products...")
response = client.get_products(first=5)
if response.is_success:
products = response.data['products']['edges']
for edge in products:
product = edge['node']
print(f" - {product['title']} ({product['status']})")
print(f"\nQuery cost: {response.query_cost}")
else:
print(f"Errors: {response.errors}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""
Shopify Project Initialization Script
Interactive script to scaffold Shopify apps, extensions, or themes.
Supports environment variable loading from multiple locations.
"""
import os
import sys
import json
import subprocess
from pathlib import Path
from typing import Dict, Optional, List
from dataclasses import dataclass
@dataclass
class EnvConfig:
"""Environment configuration container."""
shopify_api_key: Optional[str] = None
shopify_api_secret: Optional[str] = None
shop_domain: Optional[str] = None
scopes: Optional[str] = None
class EnvLoader:
"""Load environment variables from multiple sources in priority order."""
@staticmethod
def load_env_file(filepath: Path) -> Dict[str, str]:
"""
Load environment variables from .env file.
Args:
filepath: Path to .env file
Returns:
Dictionary of environment variables
"""
env_vars = {}
if not filepath.exists():
return env_vars
try:
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
env_vars[key.strip()] = value.strip().strip('"').strip("'")
except Exception as e:
print(f"Warning: Failed to load {filepath}: {e}")
return env_vars
@staticmethod
def get_env_paths(skill_dir: Path) -> List[Path]:
"""
Get list of .env file paths in priority order.
Works with any AI tool directory structure:
- .agent/skills/ (universal)
- .claude/skills/ (Claude Code)
- .gemini/skills/ (Gemini CLI)
- .cursor/skills/ (Cursor)
Priority: process.env > skill/.env > skills/.env > agent_dir/.env
Args:
skill_dir: Path to skill directory
Returns:
List of .env file paths
"""
paths = []
# skill/.env
skill_env = skill_dir / '.env'
if skill_env.exists():
paths.append(skill_env)
# skills/.env
skills_env = skill_dir.parent / '.env'
if skills_env.exists():
paths.append(skills_env)
# agent_dir/.env (e.g., .agent, .claude, .gemini, .cursor)
agent_env = skill_dir.parent.parent / '.env'
if agent_env.exists():
paths.append(agent_env)
return paths
@staticmethod
def load_config(skill_dir: Path) -> EnvConfig:
"""
Load configuration from environment variables.
Works with any AI tool directory structure.
Priority: process.env > skill/.env > skills/.env > agent_dir/.env
Args:
skill_dir: Path to skill directory
Returns:
EnvConfig object
"""
config = EnvConfig()
# Load from .env files (reverse priority order)
for env_path in reversed(EnvLoader.get_env_paths(skill_dir)):
env_vars = EnvLoader.load_env_file(env_path)
if 'SHOPIFY_API_KEY' in env_vars:
config.shopify_api_key = env_vars['SHOPIFY_API_KEY']
if 'SHOPIFY_API_SECRET' in env_vars:
config.shopify_api_secret = env_vars['SHOPIFY_API_SECRET']
if 'SHOP_DOMAIN' in env_vars:
config.shop_domain = env_vars['SHOP_DOMAIN']
if 'SCOPES' in env_vars:
config.scopes = env_vars['SCOPES']
# Override with process environment (highest priority)
if 'SHOPIFY_API_KEY' in os.environ:
config.shopify_api_key = os.environ['SHOPIFY_API_KEY']
if 'SHOPIFY_API_SECRET' in os.environ:
config.shopify_api_secret = os.environ['SHOPIFY_API_SECRET']
if 'SHOP_DOMAIN' in os.environ:
config.shop_domain = os.environ['SHOP_DOMAIN']
if 'SCOPES' in os.environ:
config.scopes = os.environ['SCOPES']
return config
class ShopifyInitializer:
"""Initialize Shopify projects."""
def __init__(self, config: EnvConfig):
"""
Initialize ShopifyInitializer.
Args:
config: Environment configuration
"""
self.config = config
def prompt(self, message: str, default: Optional[str] = None) -> str:
"""
Prompt user for input.
Args:
message: Prompt message
default: Default value
Returns:
User input or default
"""
if default:
message = f"{message} [{default}]"
user_input = input(f"{message}: ").strip()
return user_input if user_input else (default or '')
def select_option(self, message: str, options: List[str]) -> str:
"""
Prompt user to select from options.
Args:
message: Prompt message
options: List of options
Returns:
Selected option
"""
print(f"\n{message}")
for i, option in enumerate(options, 1):
print(f"{i}. {option}")
while True:
try:
choice = int(input("Select option: ").strip())
if 1 <= choice <= len(options):
return options[choice - 1]
print(f"Please select 1-{len(options)}")
except (ValueError, KeyboardInterrupt):
print("Invalid input")
def check_cli_installed(self) -> bool:
"""
Check if Shopify CLI is installed.
Returns:
True if installed, False otherwise
"""
try:
result = subprocess.run(
['shopify', 'version'],
capture_output=True,
text=True,
timeout=5
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def create_app_config(self, project_dir: Path, app_name: str, scopes: str) -> None:
"""
Create shopify.app.toml configuration file.
Args:
project_dir: Project directory
app_name: Application name
scopes: Access scopes
"""
config_content = f"""# Shopify App Configuration
name = "{app_name}"
client_id = "{self.config.shopify_api_key or 'YOUR_API_KEY'}"
application_url = "https://your-app.com"
embedded = true
[build]
automatically_update_urls_on_dev = true
dev_store_url = "{self.config.shop_domain or 'your-store.myshopify.com'}"
[access_scopes]
scopes = "{scopes}"
[webhooks]
api_version = "2026-01"
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"
[webhooks.privacy_compliance]
customer_data_request_url = "/webhooks/gdpr/data-request"
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
"""
config_path = project_dir / 'shopify.app.toml'
config_path.write_text(config_content)
print(f"✓ Created {config_path}")
def create_extension_config(self, project_dir: Path, extension_name: str, extension_type: str) -> None:
"""
Create shopify.extension.toml configuration file.
Args:
project_dir: Project directory
extension_name: Extension name
extension_type: Extension type
"""
target_map = {
'checkout': 'purchase.checkout.block.render',
'admin_action': 'admin.product-details.action.render',
'admin_block': 'admin.product-details.block.render',
'pos': 'pos.home.tile.render',
'function': 'function',
'customer_account': 'customer-account.order-status.block.render',
'theme_app': 'theme-app-extension'
}
config_content = f"""name = "{extension_name}"
type = "ui_extension"
handle = "{extension_name.lower().replace(' ', '-')}"
[extension_points]
api_version = "2026-01"
[[extension_points.targets]]
target = "{target_map.get(extension_type, 'purchase.checkout.block.render')}"
[capabilities]
network_access = true
api_access = true
"""
config_path = project_dir / 'shopify.extension.toml'
config_path.write_text(config_content)
print(f"✓ Created {config_path}")
def create_readme(self, project_dir: Path, project_type: str, project_name: str) -> None:
"""
Create README.md file.
Args:
project_dir: Project directory
project_type: Project type (app/extension/theme)
project_name: Project name
"""
content = f"""# {project_name}
Shopify {project_type.capitalize()} project.
## Setup
```bash
# Install dependencies
npm install
# Start development
shopify {project_type} dev
```
## Deployment
```bash
# Deploy to Shopify
shopify {project_type} deploy
```
## Resources
- [Shopify Documentation](https://shopify.dev/docs)
- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli)
"""
readme_path = project_dir / 'README.md'
readme_path.write_text(content)
print(f"✓ Created {readme_path}")
def init_app(self) -> None:
"""Initialize Shopify app project."""
print("\n=== Shopify App Initialization ===\n")
app_name = self.prompt("App name", "my-shopify-app")
scopes = self.prompt("Access scopes", self.config.scopes or "read_products,write_products")
project_dir = Path.cwd() / app_name
project_dir.mkdir(exist_ok=True)
print(f"\nCreating app in {project_dir}...")
self.create_app_config(project_dir, app_name, scopes)
self.create_readme(project_dir, "app", app_name)
# Create basic package.json
package_json = {
"name": app_name.lower().replace(' ', '-'),
"version": "1.0.0",
"scripts": {
"dev": "shopify app dev",
"deploy": "shopify app deploy"
}
}
(project_dir / 'package.json').write_text(json.dumps(package_json, indent=2))
print(f"✓ Created package.json")
print(f"\n✓ App '{app_name}' initialized successfully!")
print(f"\nNext steps:")
print(f" cd {app_name}")
print(f" npm install")
print(f" shopify app dev")
def init_extension(self) -> None:
"""Initialize Shopify extension project."""
print("\n=== Shopify Extension Initialization ===\n")
extension_types = [
'checkout',
'admin_action',
'admin_block',
'pos',
'function',
'customer_account',
'theme_app'
]
extension_type = self.select_option("Select extension type", extension_types)
extension_name = self.prompt("Extension name", "my-extension")
project_dir = Path.cwd() / extension_name
project_dir.mkdir(exist_ok=True)
print(f"\nCreating extension in {project_dir}...")
self.create_extension_config(project_dir, extension_name, extension_type)
self.create_readme(project_dir, "extension", extension_name)
print(f"\n✓ Extension '{extension_name}' initialized successfully!")
print(f"\nNext steps:")
print(f" cd {extension_name}")
print(f" shopify app dev")
def init_theme(self) -> None:
"""Initialize Shopify theme project."""
print("\n=== Shopify Theme Initialization ===\n")
theme_name = self.prompt("Theme name", "my-theme")
print(f"\nInitializing theme '{theme_name}'...")
print("\nRecommended: Use 'shopify theme init' for full theme scaffolding")
print(f"\nRun: shopify theme init {theme_name}")
def run(self) -> None:
"""Run interactive initialization."""
print("=" * 60)
print("Shopify Project Initializer")
print("=" * 60)
# Check CLI
if not self.check_cli_installed():
print("\n⚠ Shopify CLI not found!")
print("Install: npm install -g @shopify/cli@latest")
sys.exit(1)
# Select project type
project_types = ['app', 'extension', 'theme']
project_type = self.select_option("Select project type", project_types)
# Initialize based on type
if project_type == 'app':
self.init_app()
elif project_type == 'extension':
self.init_extension()
elif project_type == 'theme':
self.init_theme()
def main() -> None:
"""Main entry point."""
try:
# Get skill directory
script_dir = Path(__file__).parent
skill_dir = script_dir.parent
# Load configuration
config = EnvLoader.load_config(skill_dir)
# Initialize project
initializer = ShopifyInitializer(config)
initializer.run()
except KeyboardInterrupt:
print("\n\nAborted.")
sys.exit(0)
except Exception as e:
print(f"\n✗ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,379 @@
"""
Tests for shopify_init.py
Run with: pytest test_shopify_init.py -v --cov=shopify_init --cov-report=term-missing
"""
import os
import sys
import json
import pytest
import subprocess
from pathlib import Path
from unittest.mock import Mock, patch, mock_open, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent))
from shopify_init import EnvLoader, EnvConfig, ShopifyInitializer
class TestEnvLoader:
"""Test EnvLoader class."""
def test_load_env_file_success(self, tmp_path):
"""Test loading valid .env file."""
env_file = tmp_path / ".env"
env_file.write_text("""
SHOPIFY_API_KEY=test_key
SHOPIFY_API_SECRET=test_secret
SHOP_DOMAIN=test.myshopify.com
# Comment line
SCOPES=read_products,write_products
""")
result = EnvLoader.load_env_file(env_file)
assert result['SHOPIFY_API_KEY'] == 'test_key'
assert result['SHOPIFY_API_SECRET'] == 'test_secret'
assert result['SHOP_DOMAIN'] == 'test.myshopify.com'
assert result['SCOPES'] == 'read_products,write_products'
def test_load_env_file_with_quotes(self, tmp_path):
"""Test loading .env file with quoted values."""
env_file = tmp_path / ".env"
env_file.write_text("""
SHOPIFY_API_KEY="test_key"
SHOPIFY_API_SECRET='test_secret'
""")
result = EnvLoader.load_env_file(env_file)
assert result['SHOPIFY_API_KEY'] == 'test_key'
assert result['SHOPIFY_API_SECRET'] == 'test_secret'
def test_load_env_file_nonexistent(self, tmp_path):
"""Test loading non-existent .env file."""
result = EnvLoader.load_env_file(tmp_path / "nonexistent.env")
assert result == {}
def test_load_env_file_invalid_format(self, tmp_path):
"""Test loading .env file with invalid lines."""
env_file = tmp_path / ".env"
env_file.write_text("""
VALID_KEY=value
INVALID_LINE_NO_EQUALS
ANOTHER_VALID=test
""")
result = EnvLoader.load_env_file(env_file)
assert result['VALID_KEY'] == 'value'
assert result['ANOTHER_VALID'] == 'test'
assert 'INVALID_LINE_NO_EQUALS' not in result
def test_get_env_paths(self, tmp_path):
"""Test getting .env file paths from universal directory structure."""
# Create directory structure (works with .agent, .claude, .gemini, .cursor)
agent_dir = tmp_path / ".agent"
skills_dir = agent_dir / "skills"
skill_dir = skills_dir / "shopify"
skill_dir.mkdir(parents=True)
# Create .env files at each level
(skill_dir / ".env").write_text("SKILL=1")
(skills_dir / ".env").write_text("SKILLS=1")
(agent_dir / ".env").write_text("AGENT=1")
paths = EnvLoader.get_env_paths(skill_dir)
assert len(paths) == 3
assert skill_dir / ".env" in paths
assert skills_dir / ".env" in paths
assert agent_dir / ".env" in paths
def test_load_config_priority(self, tmp_path, monkeypatch):
"""Test configuration loading priority across different AI tool directories."""
skill_dir = tmp_path / "skill"
skills_dir = tmp_path
agent_dir = tmp_path.parent # Could be .agent, .claude, .gemini, .cursor
skill_dir.mkdir(parents=True)
(skill_dir / ".env").write_text("SHOPIFY_API_KEY=skill_key")
(skills_dir / ".env").write_text("SHOPIFY_API_KEY=skills_key\nSHOP_DOMAIN=skills.myshopify.com")
monkeypatch.setenv("SHOPIFY_API_KEY", "process_key")
config = EnvLoader.load_config(skill_dir)
assert config.shopify_api_key == "process_key"
# Shop domain from skills/.env
assert config.shop_domain == "skills.myshopify.com"
def test_load_config_no_files(self, tmp_path):
"""Test configuration loading with no .env files."""
config = EnvLoader.load_config(tmp_path)
assert config.shopify_api_key is None
assert config.shopify_api_secret is None
assert config.shop_domain is None
assert config.scopes is None
class TestShopifyInitializer:
"""Test ShopifyInitializer class."""
@pytest.fixture
def config(self):
"""Create test config."""
return EnvConfig(
shopify_api_key="test_key",
shopify_api_secret="test_secret",
shop_domain="test.myshopify.com",
scopes="read_products,write_products"
)
@pytest.fixture
def initializer(self, config):
"""Create initializer instance."""
return ShopifyInitializer(config)
def test_prompt_with_default(self, initializer):
"""Test prompt with default value."""
with patch('builtins.input', return_value=''):
result = initializer.prompt("Test", "default_value")
assert result == "default_value"
def test_prompt_with_input(self, initializer):
"""Test prompt with user input."""
with patch('builtins.input', return_value='user_input'):
result = initializer.prompt("Test", "default_value")
assert result == "user_input"
def test_select_option_valid(self, initializer):
"""Test select option with valid choice."""
options = ['app', 'extension', 'theme']
with patch('builtins.input', return_value='2'):
result = initializer.select_option("Choose", options)
assert result == 'extension'
def test_select_option_invalid_then_valid(self, initializer):
"""Test select option with invalid then valid choice."""
options = ['app', 'extension']
with patch('builtins.input', side_effect=['5', 'invalid', '1']):
result = initializer.select_option("Choose", options)
assert result == 'app'
def test_check_cli_installed_success(self, initializer):
"""Test CLI installed check - success."""
mock_result = Mock()
mock_result.returncode = 0
with patch('subprocess.run', return_value=mock_result):
assert initializer.check_cli_installed() is True
def test_check_cli_installed_failure(self, initializer):
"""Test CLI installed check - failure."""
with patch('subprocess.run', side_effect=FileNotFoundError):
assert initializer.check_cli_installed() is False
def test_create_app_config(self, initializer, tmp_path):
"""Test creating app configuration file."""
initializer.create_app_config(tmp_path, "test-app", "read_products")
config_file = tmp_path / "shopify.app.toml"
assert config_file.exists()
content = config_file.read_text()
assert 'name = "test-app"' in content
assert 'scopes = "read_products"' in content
assert 'client_id = "test_key"' in content
def test_create_extension_config(self, initializer, tmp_path):
"""Test creating extension configuration file."""
initializer.create_extension_config(tmp_path, "test-ext", "checkout")
config_file = tmp_path / "shopify.extension.toml"
assert config_file.exists()
content = config_file.read_text()
assert 'name = "test-ext"' in content
assert 'purchase.checkout.block.render' in content
def test_create_extension_config_admin_action(self, initializer, tmp_path):
"""Test creating admin action extension config."""
initializer.create_extension_config(tmp_path, "admin-ext", "admin_action")
config_file = tmp_path / "shopify.extension.toml"
content = config_file.read_text()
assert 'admin.product-details.action.render' in content
def test_create_readme(self, initializer, tmp_path):
"""Test creating README file."""
initializer.create_readme(tmp_path, "app", "Test App")
readme_file = tmp_path / "README.md"
assert readme_file.exists()
content = readme_file.read_text()
assert '# Test App' in content
assert 'shopify app dev' in content
@patch('builtins.input')
@patch('builtins.print')
def test_init_app(self, mock_print, mock_input, initializer, tmp_path, monkeypatch):
"""Test app initialization."""
monkeypatch.chdir(tmp_path)
# Mock user inputs
mock_input.side_effect = ['my-app', 'read_products,write_products']
initializer.init_app()
# Check directory created
app_dir = tmp_path / "my-app"
assert app_dir.exists()
# Check files created
assert (app_dir / "shopify.app.toml").exists()
assert (app_dir / "README.md").exists()
assert (app_dir / "package.json").exists()
# Check package.json content
package_json = json.loads((app_dir / "package.json").read_text())
assert package_json['name'] == 'my-app'
assert 'dev' in package_json['scripts']
@patch('builtins.input')
@patch('builtins.print')
def test_init_extension(self, mock_print, mock_input, initializer, tmp_path, monkeypatch):
"""Test extension initialization."""
monkeypatch.chdir(tmp_path)
# Mock user inputs: type selection (1 = checkout), name
mock_input.side_effect = ['1', 'my-extension']
initializer.init_extension()
# Check directory and files created
ext_dir = tmp_path / "my-extension"
assert ext_dir.exists()
assert (ext_dir / "shopify.extension.toml").exists()
assert (ext_dir / "README.md").exists()
@patch('builtins.input')
@patch('builtins.print')
def test_init_theme(self, mock_print, mock_input, initializer):
"""Test theme initialization."""
mock_input.return_value = 'my-theme'
initializer.init_theme()
assert mock_print.called
@patch('builtins.print')
def test_run_no_cli(self, mock_print, initializer):
"""Test run when CLI not installed."""
with patch.object(initializer, 'check_cli_installed', return_value=False):
with pytest.raises(SystemExit) as exc_info:
initializer.run()
assert exc_info.value.code == 1
@patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True)
@patch.object(ShopifyInitializer, 'init_app')
@patch('builtins.input')
@patch('builtins.print')
def test_run_app_selected(self, mock_print, mock_input, mock_init_app, mock_cli_check, initializer):
"""Test run with app selection."""
mock_input.return_value = '1' # Select app
initializer.run()
mock_init_app.assert_called_once()
@patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True)
@patch.object(ShopifyInitializer, 'init_extension')
@patch('builtins.input')
@patch('builtins.print')
def test_run_extension_selected(self, mock_print, mock_input, mock_init_ext, mock_cli_check, initializer):
"""Test run with extension selection."""
mock_input.return_value = '2' # Select extension
initializer.run()
mock_init_ext.assert_called_once()
class TestMain:
"""Test main function."""
@patch('shopify_init.ShopifyInitializer')
@patch('shopify_init.EnvLoader')
def test_main_success(self, mock_loader, mock_initializer):
"""Test main function success path."""
from shopify_init import main
mock_config = Mock()
mock_loader.load_config.return_value = mock_config
mock_init_instance = Mock()
mock_initializer.return_value = mock_init_instance
with patch('builtins.print'):
main()
mock_init_instance.run.assert_called_once()
@patch('shopify_init.ShopifyInitializer')
@patch('sys.exit')
def test_main_keyboard_interrupt(self, mock_exit, mock_initializer):
"""Test main function with keyboard interrupt."""
from shopify_init import main
mock_initializer.return_value.run.side_effect = KeyboardInterrupt
with patch('builtins.print'):
main()
mock_exit.assert_called_with(0)
@patch('shopify_init.ShopifyInitializer')
@patch('sys.exit')
def test_main_exception(self, mock_exit, mock_initializer):
"""Test main function with exception."""
from shopify_init import main
mock_initializer.return_value.run.side_effect = Exception("Test error")
with patch('builtins.print'):
main()
mock_exit.assert_called_with(1)
class TestEnvConfig:
"""Test EnvConfig dataclass."""
def test_env_config_defaults(self):
"""Test EnvConfig default values."""
config = EnvConfig()
assert config.shopify_api_key is None
assert config.shopify_api_secret is None
assert config.shop_domain is None
assert config.scopes is None
def test_env_config_with_values(self):
"""Test EnvConfig with values."""
config = EnvConfig(
shopify_api_key="key",
shopify_api_secret="secret",
shop_domain="test.myshopify.com",
scopes="read_products"
)
assert config.shopify_api_key == "key"
assert config.shopify_api_secret == "secret"
assert config.shop_domain == "test.myshopify.com"
assert config.scopes == "read_products"