diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f9e711..9a70057 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,7 +63,7 @@ jobs: - name: Install dependencies if: steps.composer-cache.outputs.cache-hit != 'true' run: | - composer require "craftcms/cms:~5.8.8" --prefer-dist --no-progress + composer require "craftcms/cms:~5.9" --prefer-dist --no-progress - name: Copy config files run: | diff --git a/README.md b/README.md index 0742405..7df03c2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,14 @@ By adding this skill to Claude, you transform your Craft CMS site into an AI-acc - **Field Layout Management**: Build and modify field layouts programmatically - **Draft Support**: Create, update, and apply drafts for content workflows - **Asset Management**: Upload, update, and manage assets and volumes +- **Address Management**: Create, read, update, and delete owner-backed addresses plus manage the global address field layout +- **User Management**: Create, read, update, and delete users plus inspect the global user field layout +- **User Group Management**: Manage user groups and permissions on Craft Pro installations - **Site Information**: Access multi-site configuration details +- **Commerce: Products**: Create, read, update, and delete products with variant support +- **Commerce: Variants**: Manage product variants with pricing, inventory, and dimensions +- **Commerce: Orders**: Search and manage orders with status updates +- **Commerce: Stores**: View and configure store settings, checkout behavior, and pricing strategies ## Installation @@ -253,12 +260,65 @@ All tools from the HTTP API are available via CLI. Common operations include: - `assets/delete ` - Delete asset - `volumes/list` - List asset volumes +### Addresses +- `addresses/list` - List/search addresses +- `addresses/get ` - Get address by ID +- `addresses/create` - Create address for an owner or address field +- `addresses/update ` - Update address +- `addresses/delete ` - Delete address +- `addresses/field-layout` - Get the global address field layout + +### Users +- `users/list` - List/search users +- `users/get ` - Get user by ID +- `users/create` - Create user +- `users/permissions` - List available user permissions +- `users/update ` - Update user +- `users/delete ` - Delete user +- `users/field-layout` - Get the global user field layout + +### User Groups +- `user-groups/list` - List user groups +- `user-groups/get ` - Get user group by ID +- `user-groups/create` - Create user group +- `user-groups/update ` - Update user group +- `user-groups/delete ` - Delete user group + ### Other - `sites/list` - List all sites - `field-layouts/create` - Create field layout - `field-layouts/get` - Get field layout - `field-layouts/update ` - Update field layout +### Commerce: Products +- `products/create` - Create new product +- `products/get ` - Get product by ID +- `products/search` - Search/filter products +- `products/update ` - Update product +- `products/delete ` - Delete product +- `product-types/list` - List product types +- `product-types/get ` - Get product type by ID with field layouts +- `product-types/create` - Create product type +- `product-types/update ` - Update product type +- `product-types/delete ` - Delete product type + +### Commerce: Variants +- `variants/create` - Create variant for a product +- `variants/get ` - Get variant by ID +- `variants/update ` - Update variant +- `variants/delete ` - Delete variant + +### Commerce: Orders +- `orders/get ` - Get order by ID +- `orders/search` - Search/filter orders +- `orders/update ` - Update order status +- `order-statuses/list` - List order statuses + +### Commerce: Stores +- `stores/list` - List all stores with configuration +- `stores/get ` - Get store by ID +- `stores/update ` - Update store settings + For detailed documentation on each tool, see the [SKILLS documentation](./SKILLS/). ## Development diff --git a/SKILLS/SKILL.md b/SKILLS/SKILL.md index a086074..e883cfc 100644 --- a/SKILLS/SKILL.md +++ b/SKILLS/SKILL.md @@ -1,6 +1,6 @@ --- name: Craft CMS Skills -description: Complete skill suite for managing Craft CMS content including sections, entry types, fields, entries, drafts, field layouts, and sites. +description: Complete skill suite for managing Craft CMS content including users, addresses, sections, entry types, fields, entries, drafts, field layouts, sites, and Commerce products, variants, and orders. --- ## Important: Use this plugin, Not YAML Files @@ -80,5 +80,58 @@ All API endpoints: - [delete_asset](delete_asset.md) - `DELETE /api/assets/` - Delete asset and file - [get_volumes](get_volumes.md) - `GET /api/volumes` - List asset volumes with IDs/URLs +## Addresses +- [get_addresses](get_addresses.md) - `GET /api/addresses` - List/search addresses by owner, field, and location +- [get_address](get_address.md) - `GET /api/addresses/` - Retrieve address details with owner and field context +- [create_address](create_address.md) - `POST /api/addresses` - Create generic owner-backed addresses for users or custom address fields +- [update_address](update_address.md) - `PUT /api/addresses/` - Update address attributes and custom fields +- [delete_address](delete_address.md) - `DELETE /api/addresses/` - Delete address (soft/permanent) +- [get_address_field_layout](get_address_field_layout.md) - `GET /api/addresses/field-layout` - Retrieve the single global address field layout + +## Users +- [get_users](get_users.md) - `GET /api/users` - List/search users by query, identity fields, status, and optionally group +- [get_user](get_user.md) - `GET /api/users/` - Retrieve a user by ID, email, or username +- [create_user](create_user.md) - `POST /api/users` - Create a user with native attributes and custom fields +- [get_available_permissions](get_available_permissions.md) - `GET /api/users/permissions` - List all known permissions plus custom stored permission names +- [update_user](update_user.md) - `PUT /api/users/` - Update a user by ID, email, or username +- [delete_user](delete_user.md) - `DELETE /api/users/` - Delete a user by ID, email, or username +- [get_user_field_layout](get_user_field_layout.md) - `GET /api/users/field-layout` - Retrieve the single global user field layout + +## User Groups +- [get_user_groups](get_user_groups.md) - `GET /api/user-groups` - List user groups and their permissions +- [get_user_group](get_user_group.md) - `GET /api/user-groups/` - Retrieve a user group by ID or handle +- [create_user_group](create_user_group.md) - `POST /api/user-groups` - Create a user group and set permissions +- [update_user_group](update_user_group.md) - `PUT /api/user-groups/` - Update a user group and its permissions +- [delete_user_group](delete_user_group.md) - `DELETE /api/user-groups/` - Delete a user group by ID or handle + ## System - [health](health.md) - `GET /api/health` - Health check endpoint to verify plugin installation and API availability + +## Commerce: Products +- [create_product](create_product.md) - `POST /api/products` - Create product with type, title, SKU, price, and custom fields +- [get_product](get_product.md) - `GET /api/products/` - Retrieve product with variants, pricing, and custom fields +- [get_products](get_products.md) - `GET /api/products/search` - Search/filter products by type/status/query +- [update_product](update_product.md) - `PUT /api/products/` - Update product attributes and custom fields +- [delete_product](delete_product.md) - `DELETE /api/products/` - Delete product (soft/permanent) +- [get_product_types](get_product_types.md) - `GET /api/product-types` - List available Commerce product types +- [get_product_type](get_product_type.md) - `GET /api/product-types/` - Retrieve product type with field layouts and site settings +- [create_product_type](create_product_type.md) - `POST /api/product-types` - Create product type with title, variant, layout, and site settings +- [update_product_type](update_product_type.md) - `PUT /api/product-types/` - Update product type configuration and site settings +- [delete_product_type](delete_product_type.md) - `DELETE /api/product-types/` - Delete product type with impact analysis and force protection + +## Commerce: Variants +- [create_variant](create_variant.md) - `POST /api/variants` - Add variant to existing product with SKU, price, and attributes +- [get_variant](get_variant.md) - `GET /api/variants/` - Retrieve variant with pricing, inventory, and dimensions +- [update_variant](update_variant.md) - `PUT /api/variants/` - Update variant pricing, SKU, stock, and fields +- [delete_variant](delete_variant.md) - `DELETE /api/variants/` - Delete variant (soft/permanent) + +## Commerce: Orders +- [get_order](get_order.md) - `GET /api/orders/` - Retrieve order with line items, totals, and addresses +- [search_orders](search_orders.md) - `GET /api/orders/search` - Search/filter orders by email/status/date/payment +- [update_order](update_order.md) - `PUT /api/orders/` - Update order status or message +- [get_order_statuses](get_order_statuses.md) - `GET /api/order-statuses` - List all order statuses with IDs/handles/colors + +## Commerce: Stores +- [get_stores](get_stores.md) - `GET /api/stores` - List all stores with checkout/payment/tax configuration +- [get_store](get_store.md) - `GET /api/stores/` - Retrieve store with full configuration details +- [update_store](update_store.md) - `PUT /api/stores/` - Update store checkout, payment, and pricing settings diff --git a/SKILLS/create_address.md b/SKILLS/create_address.md new file mode 100644 index 0000000..30b50df --- /dev/null +++ b/SKILLS/create_address.md @@ -0,0 +1,35 @@ +# create_address + +Create an Address for a generic owner, either directly on an element or inside an `Addresses` custom field. + +## Endpoint + +`POST /api/addresses` + +## Parameters + +- `ownerId` (int, required) - Owner element ID +- `ownerType` (string, required) - Owner element class name +- `fieldId` (int, optional) - Target `craft\fields\Addresses` field ID +- `fieldHandle` (string, optional) - Alternative to `fieldId` +- Native address attributes such as `title`, `countryCode`, `administrativeArea`, `locality`, `postalCode`, `addressLine1`, `addressLine2`, `organization`, `latitude`, `longitude` +- `fields` (object, optional) - Custom address field values from the global Address field layout + +## Returns + +Returns the created address with ownership metadata and serialized custom fields. + +## Example + +```json +{ + "ownerId": 12, + "ownerType": "craft\\elements\\User", + "title": "Home", + "countryCode": "US", + "addressLine1": "123 Main St", + "locality": "Portland", + "administrativeArea": "OR", + "postalCode": "97205" +} +``` diff --git a/SKILLS/create_product.md b/SKILLS/create_product.md new file mode 100644 index 0000000..2cca682 --- /dev/null +++ b/SKILLS/create_product.md @@ -0,0 +1,92 @@ +# create_product + +Create a new Commerce product with a default variant. + +## Route + +`POST /api/products` + +## Description + +Creates a new Commerce product with the specified product type, title, and default variant (SKU and price). The product is created with a single default variant; use `create_variant` to add additional variants after creation. + +## Parameters + +### Required Parameters + +- **typeId** (integer): The product type ID. Use `get_product_types` to discover available types. +- **title** (string): Product title. +- **sku** (string): SKU for the default variant. +- **price** (float): Price for the default variant. + +### Optional Parameters + +- **slug** (string, optional): Product slug. Auto-generated from title if not provided. +- **postDate** (string, optional): Post date in ISO 8601 format. Defaults to now. +- **expiryDate** (string, optional): Expiry date in ISO 8601 format. Null means no expiry. +- **enabled** (boolean, optional): Whether the product is enabled. Default: `true`. +- **fields** (object, optional): Custom field data keyed by field handle. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **productId** (integer): Created product ID +- **title** (string): Product title +- **slug** (string): Product slug +- **status** (string): Product status (live, pending, expired, disabled) +- **typeId** (integer): Product type ID +- **typeName** (string): Product type name +- **defaultSku** (string): Default variant SKU +- **defaultPrice** (float): Default variant price +- **url** (string): Craft control panel edit URL + +## Example Usage + +### Basic Product +```json +{ + "typeId": 1, + "title": "Ergonomic Widget", + "sku": "WIDGET-001", + "price": 29.99 +} +``` + +### Product with All Options +```json +{ + "typeId": 1, + "title": "Premium Widget", + "sku": "WIDGET-PRE-001", + "price": 99.99, + "slug": "premium-widget", + "postDate": "2025-06-01T00:00:00+00:00", + "expiryDate": "2025-12-31T23:59:59+00:00", + "enabled": true, + "fields": { + "description": "A premium ergonomic widget." + } +} +``` + +### CLI Usage +```bash +agent-craft products/create --typeId=1 --title="Ergonomic Widget" --sku="WIDGET-001" --price=29.99 +agent-craft products/create --typeId=1 --title="Premium Widget" --sku="WIDGET-PRE" --price=99.99 --slug="premium-widget" --enabled=false +``` + +## Notes + +- A default variant is always created with the provided SKU and price +- Use `get_product_types` to discover available product type IDs +- Use `create_variant` to add additional variants after creation +- Throws an error if the product type ID is invalid +- Requires Craft Commerce to be installed + +## See Also + +- [get_product_types](get_product_types.md) - Discover available product types +- [update_product](update_product.md) - Update product attributes +- [create_variant](create_variant.md) - Add variants to a product diff --git a/SKILLS/create_product_type.md b/SKILLS/create_product_type.md new file mode 100644 index 0000000..e1df58f --- /dev/null +++ b/SKILLS/create_product_type.md @@ -0,0 +1,160 @@ +# create_product_type + +Create a new Commerce product type with configurable fields, variants, and site settings. + +## Route + +`POST /api/product-types` + +## Description + +Creates a new product type in Craft Commerce. Product types define the structure, fields, and variant configuration for products — similar to how sections and entry types define structure for entries. + +Product types have two independent field layouts: +- **Product-level fields** (`fieldLayoutId`) for product attributes +- **Variant-level fields** (`variantFieldLayoutId`) for variant attributes + +Create field layouts first using `create_field_layout`, then assign their IDs here. + +Supports multi-site installations with per-site URL settings. If no site settings are provided, the product type will be enabled for all sites with default settings. + +After creating the product type, always link the user back to the product type settings in the Craft control panel for further configuration. + +## Parameters + +### Required Parameters + +- **name** (string): The display name for the product type + +### Optional Parameters + +- **handle** (string): Machine-readable name. Auto-generated from name if not provided. +- **hasProductTitleField** (boolean): Whether products have a title field. Default: `true`. If `false`, `productTitleFormat` is required. +- **productTitleFormat** (string): Auto-generated title format for products when `hasProductTitleField` is false. +- **productTitleTranslationMethod** (string): How product titles are translated. Options: `none`, `site`, `language`, `custom`. Default: `site` +- **productTitleTranslationKeyFormat** (string): Translation key format for custom translation. +- **hasVariantTitleField** (boolean): Whether variants have a title field. Default: `true`. If `false`, `variantTitleFormat` is required. +- **variantTitleFormat** (string): Auto-generated title format for variants when `hasVariantTitleField` is false. Default: `{product.title}` +- **variantTitleTranslationMethod** (string): How variant titles are translated. Default: `site` +- **variantTitleTranslationKeyFormat** (string): Translation key format for custom variant title translation. +- **showSlugField** (boolean): Whether to show the slug field. Default: `true` +- **slugTranslationMethod** (string): How slugs are translated. Default: `site` +- **slugTranslationKeyFormat** (string): Translation key format for custom slug translation. +- **skuFormat** (string): SKU format pattern. If set, SKUs are auto-generated (e.g., `{product.slug}`). +- **descriptionFormat** (string): Variant description format. Default: `{product.title} - {title}` +- **template** (string): Product page template path. +- **hasDimensions** (boolean): Whether products track dimensions. Default: `false` +- **maxVariants** (integer): Maximum variants per product. `null` for unlimited. +- **enableVersioning** (boolean): Whether to enable versioning. Default: `false` +- **isStructure** (boolean): Whether products use hierarchical structure. Default: `false` +- **maxLevels** (integer): Maximum hierarchy levels (structure only). `null` for unlimited. +- **defaultPlacement** (string): Where new products are placed: `beginning` or `end`. Default: `end` +- **fieldLayoutId** (integer): Product-level field layout ID. Create with `create_field_layout` first. +- **variantFieldLayoutId** (integer): Variant-level field layout ID. Create with `create_field_layout` first. +- **siteSettings** (array): Site-specific settings. If not provided, enabled for all sites. Each object contains: + - `siteId` (integer, required): Site ID + - `enabledByDefault` (boolean): Enable products by default. Default: `true` + - `hasUrls` (boolean): Whether products have URLs. Default: `false` + - `uriFormat` (string): URI format pattern (e.g., `shop/products/{slug}`) + - `template` (string): Template path for rendering products + +## Return Value + +Returns an object containing: + +- **_notes** (string): Success message +- **id** (integer): The newly created product type's ID +- **name** (string): Product type name +- **handle** (string): Product type handle +- **fieldLayoutId** (integer|null): Product-level field layout ID +- **variantFieldLayoutId** (integer|null): Variant-level field layout ID +- **hasProductTitleField** (boolean): Whether products have a title field +- **productTitleFormat** (string): Product title format +- **hasVariantTitleField** (boolean): Whether variants have a title field +- **variantTitleFormat** (string): Variant title format +- **skuFormat** (string|null): SKU format +- **hasDimensions** (boolean): Whether dimensions are tracked +- **maxVariants** (integer|null): Maximum variants +- **enableVersioning** (boolean): Whether versioning is enabled +- **editUrl** (string): Craft control panel URL for product type settings +- **editVariantUrl** (string): Craft control panel URL for variant settings + +## Example Usage + +### Basic Product Type +```json +{ + "name": "Clothing" +} +``` + +### Product Type with Dimensions and Variants +```json +{ + "name": "Electronics", + "handle": "electronics", + "hasDimensions": true, + "maxVariants": 10, + "enableVersioning": true, + "skuFormat": "{product.slug}-{sku}" +} +``` + +### Product Type with Auto-Generated Titles +```json +{ + "name": "Digital Downloads", + "hasProductTitleField": false, + "productTitleFormat": "{dateCreated|date}", + "hasVariantTitleField": false, + "variantTitleFormat": "{product.title} - {sku}" +} +``` + +### Product Type with Site-Specific URLs +```json +{ + "name": "Shop Items", + "siteSettings": [ + { + "siteId": 1, + "enabledByDefault": true, + "hasUrls": true, + "uriFormat": "shop/{slug}", + "template": "shop/products/_entry" + } + ] +} +``` + +## CLI Usage + +```bash +# Basic creation +agent-craft product-types/create --name="Clothing" + +# With options +agent-craft product-types/create \ + --name="Electronics" \ + --handle="electronics" \ + --hasDimensions=true \ + --maxVariants=10 +``` + +## Notes + +- Requires Craft Commerce to be installed and enabled +- Handle is auto-generated from name if not provided +- If `hasProductTitleField` is false, `productTitleFormat` is required +- If `hasVariantTitleField` is false, `variantTitleFormat` is required +- Site settings default to all sites enabled without URLs if not provided +- Field layouts must be created separately using `create_field_layout` +- After creation, configure further in the Craft control panel + +## See Also + +- [get_product_types](get_product_types.md) - List all product types +- [get_product_type](get_product_type.md) - Get detailed product type info +- [update_product_type](update_product_type.md) - Update a product type +- [delete_product_type](delete_product_type.md) - Delete a product type +- [create_field_layout](create_field_layout.md) - Create field layouts for product/variant fields diff --git a/SKILLS/create_user.md b/SKILLS/create_user.md new file mode 100644 index 0000000..9d6b8a3 --- /dev/null +++ b/SKILLS/create_user.md @@ -0,0 +1,46 @@ +# create_user + +Create a Craft user with native attributes and optional custom field values. + +## Endpoint + +`POST /api/users` + +## Parameters + +- `email` (string, required) - User email address +- `username` (string, optional) - Username; defaults to the email +- `newPassword` (string, optional) - Initial password +- `fullName` (string, optional) - Full name +- `firstName` (string, optional) - First name +- `lastName` (string, optional) - Last name +- `admin` (bool, optional) - Admin flag, default `false` +- `active` (bool, optional) - Active flag, default `true` +- `pending` (bool, optional) - Pending flag, default `false` +- `suspended` (bool, optional) - Suspended flag, default `false` +- `locked` (bool, optional) - Locked flag, default `false` +- `affiliatedSiteId` (int, optional) - Affiliated site ID +- `groupIds` (array, optional) - User group IDs; requires Craft Team or Pro +- `groupHandles` (array, optional) - User group handles; requires Craft Team or Pro +- `permissions` (array, optional) - Direct user permissions; requires Craft Pro +- `fields` (object, optional) - Custom field values from the global user field layout + +## Returns + +Returns the created user with native attributes, groups, permissions, and custom fields. + +## Notes + +- User creation still respects Craft edition user-count limits. +- Provide only one of `groupIds` or `groupHandles`. +- Direct permission assignment is unavailable outside Craft Pro. + +## Example + +```json +{ + "email": "new-user@example.com", + "newPassword": "Password123!", + "fullName": "New User" +} +``` diff --git a/SKILLS/create_user_group.md b/SKILLS/create_user_group.md new file mode 100644 index 0000000..39af88c --- /dev/null +++ b/SKILLS/create_user_group.md @@ -0,0 +1,32 @@ +# create_user_group + +Create a Craft user group and optionally set its permissions. + +## Endpoint + +`POST /api/user-groups` + +## Parameters + +- `name` (string, required) - Group name +- `handle` (string, optional) - Group handle; defaults to a kebab-case version of the name +- `description` (string, optional) - Group description +- `permissions` (array, optional) - Group permissions, including custom permission names + +## Returns + +Returns the created user group with permissions and user count. + +## Notes + +- User groups require Craft Pro. +- Permission names are normalized to lowercase. + +## Example + +```json +{ + "name": "Publishers", + "permissions": ["accesscp", "custompermission:publish"] +} +``` diff --git a/SKILLS/create_variant.md b/SKILLS/create_variant.md new file mode 100644 index 0000000..fbe76da --- /dev/null +++ b/SKILLS/create_variant.md @@ -0,0 +1,95 @@ +# create_variant + +Create a new variant on an existing Commerce product. + +## Route + +`POST /api/variants` + +## Description + +Adds a new variant to the specified product with the given SKU, price, and optional attributes such as dimensions, quantity limits, and shipping settings. The product type must allow multiple variants (maxVariants > 1) for this to add beyond the default variant. + +## Parameters + +### Required Parameters + +- **productId** (integer): The parent product ID. +- **sku** (string): Variant SKU. Must be unique. +- **price** (float): Variant price. + +### Optional Parameters + +- **title** (string, optional): Variant title. +- **minQty** (integer, optional): Minimum purchase quantity. +- **maxQty** (integer, optional): Maximum purchase quantity. +- **weight** (float, optional): Variant weight. +- **height** (float, optional): Variant height. +- **length** (float, optional): Variant length. +- **width** (float, optional): Variant width. +- **freeShipping** (boolean, optional): Whether the variant qualifies for free shipping. +- **inventoryTracked** (boolean, optional): Whether inventory is tracked for this variant. +- **fields** (object, optional): Custom field data keyed by field handle. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **variantId** (integer): Created variant ID +- **title** (string): Variant title +- **sku** (string): Variant SKU +- **price** (float): Variant price +- **stock** (integer): Current stock level (read-only, managed via inventory system) +- **productId** (integer): Parent product ID +- **productTitle** (string): Parent product title +- **url** (string): Craft control panel edit URL for the parent product + +## Example Usage + +### Basic Variant +```json +{ + "productId": 42, + "sku": "WIDGET-LG", + "price": 39.99 +} +``` + +### Variant with All Options +```json +{ + "productId": 42, + "sku": "WIDGET-LG-RED", + "price": 39.99, + "title": "Large Red", + "minQty": 1, + "maxQty": 10, + "weight": 2.5, + "height": 10.0, + "length": 20.0, + "width": 15.0, + "freeShipping": false, + "inventoryTracked": true +} +``` + +### CLI Usage +```bash +agent-craft variants/create --productId=42 --sku="WIDGET-LG" --price=39.99 +agent-craft variants/create --productId=42 --sku="WIDGET-SM" --price=19.99 --title="Small" --weight=1.0 +``` + +## Notes + +- The new variant is appended to the product's existing variants +- Stock is read-only and managed through Commerce's inventory system +- Throws an error if the product ID is invalid +- Requires Craft Commerce to be installed + +## See Also + +- [get_variant](get_variant.md) - Retrieve variant details +- [update_variant](update_variant.md) - Update variant attributes +- [delete_variant](delete_variant.md) - Delete a variant +- [create_product](create_product.md) - Create a product with default variant diff --git a/SKILLS/delete_address.md b/SKILLS/delete_address.md new file mode 100644 index 0000000..bb67f55 --- /dev/null +++ b/SKILLS/delete_address.md @@ -0,0 +1,25 @@ +# delete_address + +Delete an Address element. + +## Endpoint + +`DELETE /api/addresses/` + +## Parameters + +- `addressId` (int, required) - Address element ID +- `permanentlyDelete` (bool, optional) - Permanently remove the address instead of soft-deleting it + +## Returns + +Returns the deleted address metadata plus whether the deletion was permanent. + +## Example + +```json +{ + "addressId": 42, + "permanentlyDelete": true +} +``` diff --git a/SKILLS/delete_product.md b/SKILLS/delete_product.md new file mode 100644 index 0000000..05eab79 --- /dev/null +++ b/SKILLS/delete_product.md @@ -0,0 +1,64 @@ +# delete_product + +Delete a Commerce product and its variants. + +## Route + +`DELETE /api/products/` + +## Description + +Deletes a Commerce product. By default, performs a soft delete where the product is marked as deleted but can be restored. Set `permanentlyDelete` to true to permanently remove the product and all its variants from the database. + +## Parameters + +### Required Parameters + +- **productId** (integer): The ID of the product to delete. + +### Optional Parameters + +- **permanentlyDelete** (boolean, optional): Set to `true` to permanently delete the product. Default: `false` (soft delete). + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **productId** (integer): Deleted product ID +- **title** (string): Product title +- **slug** (string): Product slug +- **typeId** (integer): Product type ID +- **typeName** (string): Product type name +- **deletedPermanently** (boolean): Whether the product was permanently deleted + +## Example Usage + +### Soft Delete +```json +{ + "productId": 42 +} +``` + +### Permanent Delete +```json +{ + "productId": 42, + "permanentlyDelete": true +} +``` + +### CLI Usage +```bash +agent-craft products/delete 42 +agent-craft products/delete 42 --permanentlyDelete=true +``` + +## Notes + +- Soft-deleted products can be restored from the Craft control panel +- Permanently deleted products and their variants cannot be recovered +- Deleting a product also removes all its variants +- Throws an error if the product ID doesn't exist +- Requires Craft Commerce to be installed diff --git a/SKILLS/delete_product_type.md b/SKILLS/delete_product_type.md new file mode 100644 index 0000000..61e1efb --- /dev/null +++ b/SKILLS/delete_product_type.md @@ -0,0 +1,106 @@ +# delete_product_type + +Delete a Commerce product type with impact analysis and data protection. + +## Route + +`DELETE /api/product-types/` + +## Description + +Deletes a product type from Craft Commerce. This will remove the product type and potentially affect related product data. The tool analyzes impact and provides usage statistics before deletion. + +**WARNING**: Deleting a product type with existing products causes permanent data loss. This action cannot be undone. Always get user approval before forcing deletion of product types with content. + +## Parameters + +### Required Parameters + +- **productTypeId** (integer): The ID of the product type to delete. + +### Optional Parameters + +- **force** (boolean): Force deletion even if products exist. Default: `false`. Requires user approval for product types with content. + +## Return Value + +Returns an object containing impact analysis: + +- **_notes** (string): Success message +- **id** (integer): Deleted product type's ID +- **name** (string): Product type name +- **handle** (string): Product type handle +- **impact** (object): Impact assessment containing: + - `hasContent` (boolean): Whether product type contains products + - `productCount` (integer): Number of products + +## Example Usage + +### Delete Empty Product Type +```json +{ + "productTypeId": 5 +} +``` + +### Force Delete Product Type with Products +```json +{ + "productTypeId": 3, + "force": true +} +``` + +## Example Response + +```json +{ + "_notes": "The product type was successfully deleted.", + "id": 3, + "name": "Old Products", + "handle": "oldProducts", + "impact": { + "hasContent": true, + "productCount": 12 + } +} +``` + +## Error Behavior + +If product type contains products and `force=false`, the tool throws an error with detailed impact assessment: + +``` +Product type 'Clothing' contains data and cannot be deleted without force=true. + +Impact Assessment: +- Products: 12 + +Set force=true to proceed with deletion. This action cannot be undone. +``` + +## CLI Usage + +```bash +# Delete empty product type +agent-craft product-types/delete 5 + +# Force delete +agent-craft product-types/delete 3 --force=true +``` + +## Notes + +- Requires Craft Commerce to be installed and enabled +- Always review impact assessment before deletion +- Product types with products require `force=true` to delete +- Get explicit user approval before forcing deletion +- Deleted product types cannot be recovered +- All products and their variants are permanently deleted when forced + +## See Also + +- [get_product_type](get_product_type.md) - Get detailed product type info +- [get_product_types](get_product_types.md) - List all product types +- [create_product_type](create_product_type.md) - Create a new product type +- [update_product_type](update_product_type.md) - Update a product type diff --git a/SKILLS/delete_user.md b/SKILLS/delete_user.md new file mode 100644 index 0000000..c42a172 --- /dev/null +++ b/SKILLS/delete_user.md @@ -0,0 +1,30 @@ +# delete_user + +Delete a Craft user by ID, email, or username. + +## Endpoint + +`DELETE /api/users/` + +## Parameters + +- `userId` (int, optional) - User ID +- `email` (string, optional) - Resolve by email +- `username` (string, optional) - Resolve by username +- `permanentlyDelete` (bool, optional) - Permanently delete instead of soft-deleting, default `false` + +## Returns + +Returns the deleted user's serialized details and whether the delete was permanent. + +## Notes + +- Provide exactly one of `userId`, `email`, or `username`. + +## Example + +```json +{ + "email": "former-user@example.com" +} +``` diff --git a/SKILLS/delete_user_group.md b/SKILLS/delete_user_group.md new file mode 100644 index 0000000..488326e --- /dev/null +++ b/SKILLS/delete_user_group.md @@ -0,0 +1,29 @@ +# delete_user_group + +Delete a Craft user group by ID or handle. + +## Endpoint + +`DELETE /api/user-groups/` + +## Parameters + +- `groupId` (int, optional) - Group ID +- `handle` (string, optional) - Group handle + +## Returns + +Returns the deleted group details. + +## Notes + +- Provide exactly one of `groupId` or `handle`. +- User groups require Craft Pro. + +## Example + +```json +{ + "handle": "temporary-group" +} +``` diff --git a/SKILLS/delete_variant.md b/SKILLS/delete_variant.md new file mode 100644 index 0000000..c439f0d --- /dev/null +++ b/SKILLS/delete_variant.md @@ -0,0 +1,69 @@ +# delete_variant + +Delete a Commerce product variant. + +## Route + +`DELETE /api/variants/` + +## Description + +Deletes a Commerce product variant. By default, performs a soft delete where the variant is marked as deleted but can be restored. Set `permanentlyDelete` to true to permanently remove the variant. + +## Parameters + +### Required Parameters + +- **variantId** (integer): The ID of the variant to delete. + +### Optional Parameters + +- **permanentlyDelete** (boolean, optional): Set to `true` to permanently delete the variant. Default: `false` (soft delete). + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **variantId** (integer): Deleted variant ID +- **title** (string): Variant title +- **sku** (string): Variant SKU +- **productId** (integer|null): Parent product ID +- **productTitle** (string|null): Parent product title +- **deletedPermanently** (boolean): Whether the variant was permanently deleted + +## Example Usage + +### Soft Delete +```json +{ + "variantId": 456 +} +``` + +### Permanent Delete +```json +{ + "variantId": 456, + "permanentlyDelete": true +} +``` + +### CLI Usage +```bash +agent-craft variants/delete 456 +agent-craft variants/delete 456 --permanentlyDelete=true +``` + +## Notes + +- Soft-deleted variants can be restored from the Craft control panel +- Permanently deleted variants cannot be recovered +- Throws an error if the variant ID doesn't exist +- Requires Craft Commerce to be installed + +## See Also + +- [get_variant](get_variant.md) - Retrieve variant details +- [update_variant](update_variant.md) - Update variant attributes +- [create_variant](create_variant.md) - Create a new variant diff --git a/SKILLS/get_address.md b/SKILLS/get_address.md new file mode 100644 index 0000000..740e8e9 --- /dev/null +++ b/SKILLS/get_address.md @@ -0,0 +1,23 @@ +# get_address + +Retrieve a single Address element by ID. + +## Endpoint + +`GET /api/addresses/` + +## Parameters + +- `addressId` (int, required) - Address element ID + +## Returns + +Returns the address record with owner information, linked address field info, and custom field values. + +## Example + +```json +{ + "addressId": 42 +} +``` diff --git a/SKILLS/get_address_field_layout.md b/SKILLS/get_address_field_layout.md new file mode 100644 index 0000000..d506773 --- /dev/null +++ b/SKILLS/get_address_field_layout.md @@ -0,0 +1,26 @@ +# get_address_field_layout + +Retrieve the single global field layout used by all Address elements. + +## Endpoint + +`GET /api/addresses/field-layout` + +## Parameters + +None. + +## Returns + +Returns the global Address field layout tabs and elements, plus the control-panel settings URL. + +## Notes + +- Addresses use a singleton-style global field layout. +- The returned `fieldLayout.id` is a stable placeholder identifier for use with field-layout mutation tools. + +## Example + +```json +{} +``` diff --git a/SKILLS/get_addresses.md b/SKILLS/get_addresses.md new file mode 100644 index 0000000..bbb400d --- /dev/null +++ b/SKILLS/get_addresses.md @@ -0,0 +1,33 @@ +# get_addresses + +List Address elements, with optional filtering by owner, address field, and location. + +## Endpoint + +`GET /api/addresses` + +## Parameters + +- `ownerId` (int, optional) - Owner element ID; must be paired with `ownerType` +- `ownerType` (string, optional) - Owner element class name such as `craft\elements\User` +- `fieldId` (int, optional) - Restrict results to a specific `craft\fields\Addresses` field +- `fieldHandle` (string, optional) - Alternative to `fieldId` +- `countryCode` (string, optional) - Filter by ISO country code +- `postalCode` (string, optional) - Filter by postal code +- `locality` (string, optional) - Filter by city/locality +- `limit` (int, optional) - Maximum number of results; defaults to `10` + +## Returns + +Returns matching addresses with owner details, field linkage, native address attributes, and serialized custom field values. + +## Example + +```json +{ + "ownerId": 12, + "ownerType": "craft\\elements\\User", + "countryCode": "US", + "limit": 20 +} +``` diff --git a/SKILLS/get_available_permissions.md b/SKILLS/get_available_permissions.md new file mode 100644 index 0000000..1177227 --- /dev/null +++ b/SKILLS/get_available_permissions.md @@ -0,0 +1,31 @@ +# get_available_permissions + +List all available Craft user permissions, grouped the same way Craft registers them, plus any custom permission names already stored in the database. + +## Endpoint + +`GET /api/users/permissions` + +## Parameters + +None. + +## Returns + +Returns: + +- `groups` - Permission groups with headings, labels, info, warnings, and nested permissions +- `allPermissions` - Flat permission list with user-facing labels, info, warnings, and group headings +- `allPermissionNames` - Flat list of all registered and stored permission names +- `customPermissions` - Flat list of stored custom permissions with labels and group heading metadata +- `customPermissionNames` - Stored permission names that are not part of Craft's registered permission tree + +## Notes + +- Custom permission names appear after they have been assigned to a user or user group. + +## Example + +```json +{} +``` diff --git a/SKILLS/get_order.md b/SKILLS/get_order.md new file mode 100644 index 0000000..4dc1229 --- /dev/null +++ b/SKILLS/get_order.md @@ -0,0 +1,76 @@ +# get_order + +Retrieve complete Commerce order details by ID. + +## Route + +`GET /api/orders/` + +## Description + +Gets detailed information about a specific order, including status, totals, line items, adjustments (discounts, shipping, tax), and addresses. + +## Parameters + +### Required Parameters + +- **orderId** (integer): The ID of the order to retrieve. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message +- **orderId** (integer): Order ID +- **number** (string): Order number (unique hash) +- **reference** (string|null): Order reference number +- **email** (string): Customer email +- **isCompleted** (boolean): Whether the order is completed (vs. active cart) +- **dateOrdered** (string|null): Date ordered in ISO 8601 format +- **datePaid** (string|null): Date paid in ISO 8601 format +- **currency** (string): Order currency code +- **couponCode** (string|null): Applied coupon code +- **orderStatusId** (integer|null): Order status ID +- **orderStatusName** (string|null): Order status name +- **paidStatus** (string): Paid status (paid, unpaid, partial, overPaid) +- **origin** (string): Order origin (web, cp) +- **shippingMethodHandle** (string|null): Selected shipping method +- **itemTotal** (float): Sum of line item totals +- **totalShippingCost** (float): Total shipping cost +- **totalDiscount** (float): Total discount amount +- **totalTax** (float): Total tax amount +- **totalPaid** (float): Total amount paid +- **total** (float): Order grand total +- **lineItems** (array): Array of line items, each containing: + - **id** (integer): Line item ID + - **description** (string): Item description + - **sku** (string): Item SKU + - **qty** (integer): Quantity + - **price** (float): Unit price + - **subtotal** (float): Line item subtotal + - **total** (float): Line item total (with adjustments) +- **adjustments** (array): Array of adjustments (discounts, tax, shipping), each containing: + - **id** (integer): Adjustment ID + - **type** (string): Adjustment type + - **name** (string): Adjustment name + - **description** (string): Adjustment description + - **amount** (float): Adjustment amount + - **included** (boolean): Whether the adjustment is included in the price +- **shippingAddress** (object|null): Shipping address details +- **billingAddress** (object|null): Billing address details +- **url** (string): Craft control panel edit URL + +## Example Usage + +```json +{ + "orderId": 156 +} +``` + +## Notes + +- Returns complete order data including line items, adjustments, and addresses +- Use `search_orders` to find order IDs if you don't know them +- Throws an error if the order ID doesn't exist +- Requires Craft Commerce to be installed diff --git a/SKILLS/get_order_statuses.md b/SKILLS/get_order_statuses.md new file mode 100644 index 0000000..fd51933 --- /dev/null +++ b/SKILLS/get_order_statuses.md @@ -0,0 +1,75 @@ +# get_order_statuses + +List all available Commerce order statuses. + +## Route + +`GET /api/order-statuses` + +## Description + +Returns all configured order statuses in Commerce. Order statuses define the workflow stages for orders (e.g., New, Processing, Shipped). Use this to discover valid status IDs before updating an order's status with `update_order`. + +## Parameters + +None. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message +- **orderStatuses** (array): Array of order status objects, each containing: + - **id** (integer): Status ID + - **name** (string): Status display name + - **handle** (string): Status handle + - **color** (string): Status color code + - **description** (string): Status description + - **isDefault** (boolean): Whether this is the default status for new orders + - **sortOrder** (integer): Display sort order + +## Example Usage + +```bash +agent-craft order-statuses/list +``` + +## Example Response + +```json +{ + "_notes": "Retrieved all Commerce order statuses.", + "orderStatuses": [ + { + "id": 1, + "name": "New", + "handle": "new", + "color": "green", + "description": "", + "isDefault": true, + "sortOrder": 1 + }, + { + "id": 2, + "name": "Processing", + "handle": "processing", + "color": "blue", + "description": "", + "isDefault": false, + "sortOrder": 2 + } + ] +} +``` + +## Notes + +- Use the returned status IDs with `update_order` to change an order's status +- At least one status is always configured as the default +- Requires Craft Commerce to be installed + +## See Also + +- [update_order](update_order.md) - Update order status +- [get_order](get_order.md) - Retrieve order details +- [search_orders](search_orders.md) - Search orders by status diff --git a/SKILLS/get_product.md b/SKILLS/get_product.md new file mode 100644 index 0000000..9bff455 --- /dev/null +++ b/SKILLS/get_product.md @@ -0,0 +1,67 @@ +# get_product + +Retrieve complete Commerce product details by ID. + +## Route + +`GET /api/products/` + +## Description + +Gets detailed information about a specific Commerce product, including all custom fields, native attributes, variant details, and pricing information. + +## Parameters + +### Required Parameters + +- **productId** (integer): The ID of the product to retrieve. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message +- **productId** (integer): Product ID +- **title** (string): Product title +- **slug** (string): Product slug +- **status** (string): Product status (live, pending, expired, disabled) +- **typeId** (integer): Product type ID +- **typeName** (string): Product type name +- **typeHandle** (string): Product type handle +- **postDate** (string|null): Publication date in ISO 8601 format +- **expiryDate** (string|null): Expiry date in ISO 8601 format +- **defaultSku** (string): Default variant SKU +- **defaultPrice** (float): Default variant price +- **url** (string): Craft control panel edit URL +- **variants** (array): Array of variant objects, each containing: + - **id** (integer): Variant ID + - **title** (string): Variant title + - **sku** (string): SKU + - **price** (float): Price + - **isDefault** (boolean): Whether this is the default variant + - **stock** (integer): Current stock level + - **minQty** (integer|null): Minimum purchase quantity + - **maxQty** (integer|null): Maximum purchase quantity + - **weight** (float): Weight + - **height** (float): Height + - **length** (float): Length + - **width** (float): Width + - **freeShipping** (boolean): Whether variant qualifies for free shipping + - **inventoryTracked** (boolean): Whether inventory is tracked + - **sortOrder** (integer): Sort order among variants +- **customFields** (object): Custom field values keyed by field handle + +## Example Usage + +```json +{ + "productId": 42 +} +``` + +## Notes + +- Returns full product data including all variants and custom fields +- Use `get_products` to search/list products if you don't know the ID +- Throws an error if the product ID doesn't exist +- Requires Craft Commerce to be installed diff --git a/SKILLS/get_product_type.md b/SKILLS/get_product_type.md new file mode 100644 index 0000000..327ff4d --- /dev/null +++ b/SKILLS/get_product_type.md @@ -0,0 +1,89 @@ +# get_product_type + +Get detailed information about a single Commerce product type by ID. + +## Route + +`GET /api/product-types/` + +## Description + +Returns comprehensive product type details including all configuration properties, per-site URL settings, and full field information for both the product-level and variant-level field layouts. Use this when you need the complete schema for a product type. For an overview of all product types without field details, use `get_product_types` instead. + +After retrieving product type information, you can use the product type ID to create new products with `create_product`. + +## Parameters + +### Required Parameters + +- **productTypeId** (integer): The ID of the product type to retrieve. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message +- **id** (integer): Product type ID +- **name** (string): Product type name +- **handle** (string): Product type handle +- **fieldLayoutId** (integer|null): Product-level field layout ID +- **variantFieldLayoutId** (integer|null): Variant-level field layout ID +- **hasDimensions** (boolean): Whether the product type tracks dimensions +- **hasProductTitleField** (boolean): Whether products have a title field +- **productTitleFormat** (string): Auto-generated title format for products +- **productTitleTranslationMethod** (string): How product titles are translated +- **productTitleTranslationKeyFormat** (string|null): Custom translation key format +- **hasVariantTitleField** (boolean): Whether variants have a title field +- **variantTitleFormat** (string): Auto-generated title format for variants +- **variantTitleTranslationMethod** (string): How variant titles are translated +- **variantTitleTranslationKeyFormat** (string|null): Custom translation key format +- **showSlugField** (boolean): Whether slug field is shown +- **slugTranslationMethod** (string): How slugs are translated +- **slugTranslationKeyFormat** (string|null): Custom slug translation key format +- **skuFormat** (string|null): SKU format pattern +- **descriptionFormat** (string): Variant description format +- **template** (string|null): Product page template path +- **maxVariants** (integer): Maximum number of variants +- **enableVersioning** (boolean): Whether versioning is enabled +- **isStructure** (boolean): Whether products use hierarchical structure +- **maxLevels** (integer|null): Maximum hierarchy levels (structure only) +- **defaultPlacement** (string|null): Default placement for new products (structure only) +- **propagationMethod** (string): How content propagates across sites +- **siteSettings** (array): Per-site configuration, each containing: + - **siteId** (integer): Site ID + - **hasUrls** (boolean): Whether products have URLs on this site + - **uriFormat** (string|null): URI format pattern + - **template** (string|null): Template path + - **enabledByDefault** (boolean): Whether products are enabled by default +- **productFields** (array): Product-level custom fields with full details +- **variantFields** (array): Variant-level custom fields with full details +- **editUrl** (string): Craft control panel URL for product type settings +- **editVariantUrl** (string): Craft control panel URL for variant settings + +## Example Usage + +```json +{ + "productTypeId": 1 +} +``` + +## CLI Usage + +```bash +agent-craft product-types/get 1 +``` + +## Notes + +- Requires Craft Commerce to be installed and enabled +- Throws an error if the product type ID doesn't exist +- Returns full field layout details for both product and variant levels +- Use `get_product_types` to discover available product type IDs + +## See Also + +- [get_product_types](get_product_types.md) - List all product types +- [create_product_type](create_product_type.md) - Create a new product type +- [update_product_type](update_product_type.md) - Update a product type +- [delete_product_type](delete_product_type.md) - Delete a product type diff --git a/SKILLS/get_product_types.md b/SKILLS/get_product_types.md new file mode 100644 index 0000000..fc8b46f --- /dev/null +++ b/SKILLS/get_product_types.md @@ -0,0 +1,68 @@ +# get_product_types + +List all available Commerce product types. + +## Route + +`GET /api/product-types` + +## Description + +Returns all product types configured in Craft Commerce. Product types define the structure and fields for products, similar to how entry types define structure for entries. Use this to discover available product types before creating or searching for products. + +Returns each product type's configuration including field layout IDs, title field settings, variant settings, and per-site URL configuration. + +## Parameters + +No parameters required. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message about the results +- **productTypes** (array): Array of product types, each containing: + - **id** (integer): Product type ID + - **name** (string): Product type name + - **handle** (string): Product type handle + - **fieldLayoutId** (integer|null): Product-level field layout ID + - **variantFieldLayoutId** (integer|null): Variant-level field layout ID + - **hasDimensions** (boolean): Whether the product type tracks dimensions + - **hasProductTitleField** (boolean): Whether products have a title field + - **productTitleFormat** (string): Auto-generated title format for products + - **hasVariantTitleField** (boolean): Whether variants have a title field + - **variantTitleFormat** (string): Auto-generated title format for variants + - **skuFormat** (string|null): SKU format pattern, null if manually entered + - **maxVariants** (integer): Maximum number of variants allowed + - **siteSettings** (array): Per-site configuration, each containing: + - **siteId** (integer): Site ID + - **hasUrls** (boolean): Whether products have URLs on this site + - **uriFormat** (string|null): URI format pattern + - **template** (string|null): Template path for rendering + - **enabledByDefault** (boolean): Whether products are enabled by default + +## Example Usage + +```json +{} +``` + +## CLI Usage + +```bash +agent-craft product-types/list +``` + +## Notes + +- Requires Craft Commerce to be installed and enabled +- Use product type IDs to filter results in `get_products` +- Product types are configured in the Commerce section of the Craft control panel +- For detailed information including field layouts, use `get_product_type` with a specific ID + +## See Also + +- [get_product_type](get_product_type.md) - Get detailed product type with field layouts +- [create_product_type](create_product_type.md) - Create a new product type +- [update_product_type](update_product_type.md) - Update a product type +- [delete_product_type](delete_product_type.md) - Delete a product type diff --git a/SKILLS/get_products.md b/SKILLS/get_products.md new file mode 100644 index 0000000..5f1ada8 --- /dev/null +++ b/SKILLS/get_products.md @@ -0,0 +1,75 @@ +# get_products + +Search and list Commerce products with flexible filtering options. + +## Route + +`GET /api/products/search` + +## Description + +Searches for products in the Craft Commerce system. Returns matching products with their IDs, titles, pricing, and control panel edit URLs. Supports filtering by product type, status, search query, and result limits. + +## Parameters + +### Optional Parameters + +- **query** (string, optional): Search query text to match against product content. If omitted, returns all products (filtered by other parameters). +- **limit** (integer, optional): Maximum number of results to return. Default: 10. +- **status** (string, optional): Product status filter. Options: + - `live` (default): Published, enabled products + - `pending`: Scheduled for future publication + - `expired`: Past expiration date + - `disabled`: Manually disabled products +- **typeIds** (array of integers, optional): Filter results to specific product types. Only products of these types will be returned. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message about the search results +- **results** (array): Array of matching products, each containing: + - **productId** (integer): Product ID + - **title** (string): Product title + - **slug** (string): Product slug + - **status** (string): Product status + - **typeId** (integer): Product type ID + - **defaultSku** (string): Default variant SKU + - **defaultPrice** (float): Default variant price + - **url** (string): Craft control panel edit URL + +## Example Usage + +### Search All Products +```json +{ + "query": "t-shirt", + "limit": 20 +} +``` + +### Filter by Product Type +```json +{ + "typeIds": [1, 2], + "limit": 50, + "status": "live" +} +``` + +### Get All Live Products +```json +{ + "limit": 100, + "status": "live" +} +``` + +## Notes + +- Default limit is 10 products - increase for broader searches +- Use `get_product_types` to discover valid product type IDs +- Search query matches against product content, not just titles +- Control panel URLs allow users to quickly navigate to products for editing +- Product type validation ensures provided type IDs exist +- Requires Craft Commerce to be installed diff --git a/SKILLS/get_store.md b/SKILLS/get_store.md new file mode 100644 index 0000000..57e1e11 --- /dev/null +++ b/SKILLS/get_store.md @@ -0,0 +1,71 @@ +# get_store + +Get detailed information about a single Commerce store by ID. + +## Route + +`GET /api/stores/` + +## Description + +Returns a single store's full configuration including checkout settings, currency, pricing strategies, and associated sites. Use this to inspect a specific store's settings before making changes with `update_store`. + +## Parameters + +### Required Parameters + +- **storeId** (integer): The ID of the store to retrieve. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message +- **id** (integer): Store ID +- **name** (string): Store display name +- **handle** (string): Store handle +- **primary** (boolean): Whether this is the primary store +- **currency** (string): 3-letter ISO currency code (e.g. USD, EUR) +- **autoSetNewCartAddresses** (boolean): Whether to auto-set user's primary addresses on new carts +- **autoSetCartShippingMethodOption** (boolean): Whether to auto-set first available shipping method +- **autoSetPaymentSource** (boolean): Whether to auto-set user's primary payment source +- **allowEmptyCartOnCheckout** (boolean): Whether carts can be empty on checkout +- **allowCheckoutWithoutPayment** (boolean): Whether orders can complete without payment +- **allowPartialPaymentOnCheckout** (boolean): Whether partial payments are allowed +- **requireShippingAddressAtCheckout** (boolean): Whether shipping address is required +- **requireBillingAddressAtCheckout** (boolean): Whether billing address is required +- **requireShippingMethodSelectionAtCheckout** (boolean): Whether shipping method selection is required +- **useBillingAddressForTax** (boolean): Whether to use billing address for tax calculations +- **validateOrganizationTaxIdAsVatId** (boolean): Whether to validate tax ID as VAT ID +- **orderReferenceFormat** (string): Order reference number format template +- **freeOrderPaymentStrategy** (string): How free orders are handled ("complete" or "process") +- **minimumTotalPriceStrategy** (string): Minimum total price strategy ("default", "zero", or "shipping") +- **sortOrder** (integer): Sort order +- **sites** (array): Associated sites, each with id, name, handle +- **url** (string): Craft control panel settings URL for the store + +## Example Usage + +```json +{ + "storeId": 1 +} +``` + +## CLI Usage + +```bash +agent-craft stores/get 1 +``` + +## Notes + +- Requires Craft Commerce to be installed and enabled +- Throws an error if the store ID doesn't exist +- Returns all checkout, payment, and tax configuration for the store +- Use `get_stores` to discover available store IDs + +## See Also + +- [get_stores](get_stores.md) - List all stores +- [update_store](update_store.md) - Update store configuration settings diff --git a/SKILLS/get_stores.md b/SKILLS/get_stores.md new file mode 100644 index 0000000..3e9b304 --- /dev/null +++ b/SKILLS/get_stores.md @@ -0,0 +1,69 @@ +# get_stores + +List all Commerce stores with their configuration. + +## Route + +`GET /api/stores` + +## Description + +Returns all stores configured in Craft Commerce, including checkout settings, currency, pricing strategies, and associated sites. Use this to discover available stores and their current configuration before updating store settings with `update_store`. + +## Parameters + +No parameters required. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message about the results +- **stores** (array): Array of stores, each containing: + - **id** (integer): Store ID + - **name** (string): Store display name + - **handle** (string): Store handle + - **primary** (boolean): Whether this is the primary store + - **currency** (string): 3-letter ISO currency code (e.g. USD, EUR) + - **autoSetNewCartAddresses** (boolean): Whether to auto-set user's primary addresses on new carts + - **autoSetCartShippingMethodOption** (boolean): Whether to auto-set first available shipping method + - **autoSetPaymentSource** (boolean): Whether to auto-set user's primary payment source + - **allowEmptyCartOnCheckout** (boolean): Whether carts can be empty on checkout + - **allowCheckoutWithoutPayment** (boolean): Whether orders can complete without payment + - **allowPartialPaymentOnCheckout** (boolean): Whether partial payments are allowed + - **requireShippingAddressAtCheckout** (boolean): Whether shipping address is required + - **requireBillingAddressAtCheckout** (boolean): Whether billing address is required + - **requireShippingMethodSelectionAtCheckout** (boolean): Whether shipping method selection is required + - **useBillingAddressForTax** (boolean): Whether to use billing address for tax calculations + - **validateOrganizationTaxIdAsVatId** (boolean): Whether to validate tax ID as VAT ID + - **orderReferenceFormat** (string): Order reference number format template + - **freeOrderPaymentStrategy** (string): How free orders are handled ("complete" or "process") + - **minimumTotalPriceStrategy** (string): Minimum total price strategy ("default", "zero", or "shipping") + - **sortOrder** (integer): Sort order + - **sites** (array): Associated sites, each with id, name, handle + - **url** (string): Craft control panel settings URL for the store + +## Example Usage + +```json +{} +``` + +## CLI Usage + +```bash +agent-craft stores/list +``` + +## Notes + +- Requires Craft Commerce to be installed and enabled +- Use store IDs when working with store-specific operations +- Each store has its own checkout, payment, and tax configuration +- Stores are managed in the Commerce section of the Craft control panel +- A store can be associated with multiple sites + +## See Also + +- [get_store](get_store.md) - Get detailed information about a single store +- [update_store](update_store.md) - Update store configuration settings diff --git a/SKILLS/get_user.md b/SKILLS/get_user.md new file mode 100644 index 0000000..6cf06ec --- /dev/null +++ b/SKILLS/get_user.md @@ -0,0 +1,30 @@ +# get_user + +Retrieve a single Craft user by ID, email, or username. + +## Endpoint + +`GET /api/users/` + +## Parameters + +- `userId` (int, optional) - User ID +- `email` (string, optional) - Resolve user by email +- `username` (string, optional) - Resolve user by username + +## Returns + +Returns the resolved user with native attributes, groups, permissions, custom fields, and control-panel URLs. + +## Notes + +- Provide exactly one of `userId`, `email`, or `username`. +- The route parameter `` maps to `userId` automatically. + +## Example + +```json +{ + "email": "author@example.com" +} +``` diff --git a/SKILLS/get_user_field_layout.md b/SKILLS/get_user_field_layout.md new file mode 100644 index 0000000..9947606 --- /dev/null +++ b/SKILLS/get_user_field_layout.md @@ -0,0 +1,26 @@ +# get_user_field_layout + +Retrieve the single global field layout used by Craft users. + +## Endpoint + +`GET /api/users/field-layout` + +## Parameters + +None. + +## Returns + +Returns the global user field layout tabs and elements, plus the control-panel settings URL. + +## Notes + +- Users share one global field layout. +- The returned `fieldLayout.id` is a stable placeholder identifier for field-layout mutation tools. + +## Example + +```json +{} +``` diff --git a/SKILLS/get_user_group.md b/SKILLS/get_user_group.md new file mode 100644 index 0000000..6925ee3 --- /dev/null +++ b/SKILLS/get_user_group.md @@ -0,0 +1,29 @@ +# get_user_group + +Retrieve a Craft user group by ID or handle. + +## Endpoint + +`GET /api/user-groups/` + +## Parameters + +- `groupId` (int, optional) - Group ID +- `handle` (string, optional) - Group handle + +## Returns + +Returns the resolved user group with handle, description, user count, permissions, and CP URL. + +## Notes + +- Provide exactly one of `groupId` or `handle`. +- User groups require Craft Pro. + +## Example + +```json +{ + "handle": "editors" +} +``` diff --git a/SKILLS/get_user_groups.md b/SKILLS/get_user_groups.md new file mode 100644 index 0000000..3666164 --- /dev/null +++ b/SKILLS/get_user_groups.md @@ -0,0 +1,25 @@ +# get_user_groups + +List Craft user groups. + +## Endpoint + +`GET /api/user-groups` + +## Parameters + +None. + +## Returns + +Returns a `results` array of user groups with handles, descriptions, user counts, and permissions. + +## Notes + +- User groups require Craft Pro. + +## Example + +```json +{} +``` diff --git a/SKILLS/get_users.md b/SKILLS/get_users.md new file mode 100644 index 0000000..ebcd05b --- /dev/null +++ b/SKILLS/get_users.md @@ -0,0 +1,35 @@ +# get_users + +List Craft users, with optional filters for search text, email, username, status, and user group. + +## Endpoint + +`GET /api/users` + +## Parameters + +- `query` (string, optional) - Search text for Craft's user element query +- `email` (string, optional) - Exact email filter +- `username` (string, optional) - Exact username filter +- `status` (string, optional) - User status filter +- `groupId` (int, optional) - Filter by user group ID; requires Craft Team or Pro +- `groupHandle` (string, optional) - Filter by user group handle; requires Craft Team or Pro +- `limit` (int, optional) - Maximum users to return, default `25` + +## Returns + +Returns a `results` array of formatted users, including native attributes, current groups, permissions, and custom fields. + +## Notes + +- Provide only one of `groupId` or `groupHandle`. +- Group-based filtering is unavailable on Craft Solo. + +## Example + +```json +{ + "status": "active", + "limit": 10 +} +``` diff --git a/SKILLS/get_variant.md b/SKILLS/get_variant.md new file mode 100644 index 0000000..6b7c581 --- /dev/null +++ b/SKILLS/get_variant.md @@ -0,0 +1,58 @@ +# get_variant + +Retrieve complete Commerce variant details by ID. + +## Route + +`GET /api/variants/` + +## Description + +Gets detailed information about a specific product variant, including pricing, SKU, inventory, dimensions, and custom fields. Also includes parent product information. + +## Parameters + +### Required Parameters + +- **variantId** (integer): The ID of the variant to retrieve. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message +- **variantId** (integer): Variant ID +- **title** (string): Variant title +- **sku** (string): Variant SKU +- **price** (float): Variant price +- **isDefault** (boolean): Whether this is the default variant +- **sortOrder** (integer): Sort order among variants +- **stock** (integer): Current stock level +- **minQty** (integer|null): Minimum purchase quantity +- **maxQty** (integer|null): Maximum purchase quantity +- **weight** (float): Variant weight +- **height** (float): Variant height +- **length** (float): Variant length +- **width** (float): Variant width +- **freeShipping** (boolean): Whether variant qualifies for free shipping +- **inventoryTracked** (boolean): Whether inventory is tracked +- **productId** (integer|null): Parent product ID +- **productTitle** (string|null): Parent product title +- **url** (string|null): Parent product's control panel edit URL +- **customFields** (object): Custom field values keyed by field handle + +## Example Usage + +```json +{ + "variantId": 99 +} +``` + +## Notes + +- Returns variant data including its parent product context +- Use `get_product` to see all variants of a product at once +- The URL links to the parent product's edit page (variants are edited within the product) +- Throws an error if the variant ID doesn't exist +- Requires Craft Commerce to be installed diff --git a/SKILLS/search_orders.md b/SKILLS/search_orders.md new file mode 100644 index 0000000..f679084 --- /dev/null +++ b/SKILLS/search_orders.md @@ -0,0 +1,87 @@ +# search_orders + +Search and list Commerce orders with flexible filtering options. + +## Route + +`GET /api/orders/search` + +## Description + +Searches for orders in the Craft Commerce system. Returns matching orders with their IDs, numbers, totals, and payment status. Supports filtering by email, order status, completion state, paid status, and date range. + +## Parameters + +### Optional Parameters + +- **query** (string, optional): Search query text. +- **limit** (integer, optional): Maximum number of results to return. Default: 10. +- **email** (string, optional): Filter by customer email address. +- **orderStatusId** (integer, optional): Filter by order status ID. +- **isCompleted** (boolean, optional): Filter by completion state. `true` for completed orders, `false` for active carts. +- **paidStatus** (string, optional): Filter by paid status. Options: `paid`, `unpaid`, `partial`, `overPaid`. +- **dateOrderedAfter** (string, optional): Filter orders placed on or after this date (ISO 8601 format). +- **dateOrderedBefore** (string, optional): Filter orders placed on or before this date (ISO 8601 format). + +## Return Value + +Returns an object containing: + +- **_notes** (string): Descriptive message about the search results +- **results** (array): Array of matching orders, each containing: + - **orderId** (integer): Order ID + - **number** (string): Order number + - **reference** (string|null): Order reference + - **email** (string): Customer email + - **isCompleted** (boolean): Whether the order is completed + - **dateOrdered** (string|null): Date ordered in ISO 8601 format + - **total** (float): Order total + - **totalPaid** (float): Total amount paid + - **paidStatus** (string): Payment status + - **currency** (string): Currency code + - **url** (string): Craft control panel edit URL + +## Example Usage + +### Search by Email +```json +{ + "email": "customer@example.com", + "limit": 20 +} +``` + +### Filter by Status +```json +{ + "orderStatusId": 1, + "isCompleted": true, + "limit": 50 +} +``` + +### Date Range +```json +{ + "dateOrderedAfter": "2025-01-01T00:00:00+00:00", + "dateOrderedBefore": "2025-03-01T00:00:00+00:00", + "isCompleted": true +} +``` + +### Find Unpaid Orders +```json +{ + "paidStatus": "unpaid", + "isCompleted": true, + "limit": 100 +} +``` + +## Notes + +- Default limit is 10 orders - increase for broader searches +- Combine filters for precise results (e.g., email + date range + status) +- `isCompleted: false` returns active carts, not completed orders +- Paid status filtering is applied after the query for accuracy +- Requires Craft Commerce to be installed diff --git a/SKILLS/update_address.md b/SKILLS/update_address.md new file mode 100644 index 0000000..717936d --- /dev/null +++ b/SKILLS/update_address.md @@ -0,0 +1,28 @@ +# update_address + +Update an existing Address element. + +## Endpoint + +`PUT /api/addresses/` + +## Parameters + +- `addressId` (int, required) - Address element ID +- Any native address attributes you want to change +- `fields` (object, optional) - Updated custom address field values + +## Returns + +Returns the updated address. + +## Example + +```json +{ + "addressId": 42, + "title": "Office", + "locality": "Seattle", + "postalCode": "98101" +} +``` diff --git a/SKILLS/update_order.md b/SKILLS/update_order.md new file mode 100644 index 0000000..eee5604 --- /dev/null +++ b/SKILLS/update_order.md @@ -0,0 +1,67 @@ +# update_order + +Update an existing Commerce order's status or message. + +## Route + +`PUT /api/orders/` + +## Description + +Updates a Commerce order's status or internal message/notes. Primarily used to change order status (e.g., from "Processing" to "Shipped") or add notes. For safety, only limited administrative fields can be modified. + +## Parameters + +### Required Parameters + +- **orderId** (integer): The ID of the order to update. + +### Optional Parameters + +- **orderStatusId** (integer, optional): New order status ID. Use the Commerce control panel to find valid status IDs. +- **message** (string, optional): Order message or internal notes. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **orderId** (integer): Order ID +- **number** (string): Order number +- **reference** (string|null): Order reference +- **orderStatusId** (integer): Updated status ID +- **orderStatusName** (string|null): Updated status name +- **message** (string): Order message +- **url** (string): Craft control panel edit URL + +## Example Usage + +### Update Order Status +```json +{ + "orderId": 156, + "orderStatusId": 3 +} +``` + +### Add Order Notes +```json +{ + "orderId": 156, + "message": "Customer requested expedited shipping" +} +``` + +### CLI Usage +```bash +agent-craft orders/update 156 --orderStatusId=3 +agent-craft orders/update 156 --message="Shipped via FedEx tracking #12345" +``` + +## Notes + +- Only status and message can be updated; line items and pricing are not modifiable +- Validates that the provided order status ID exists +- Throws an error if the order ID doesn't exist +- Returns the control panel URL for review after updating +- Requires Craft Commerce to be installed diff --git a/SKILLS/update_product.md b/SKILLS/update_product.md new file mode 100644 index 0000000..2209569 --- /dev/null +++ b/SKILLS/update_product.md @@ -0,0 +1,71 @@ +# update_product + +Update an existing Commerce product's attributes and custom fields. + +## Route + +`PUT /api/products/` + +## Description + +Updates a Commerce product's title, slug, dates, enabled state, and custom field values. Only the provided fields are updated; all others remain unchanged. + +## Parameters + +### Required Parameters + +- **productId** (integer): The ID of the product to update. + +### Optional Parameters + +- **title** (string, optional): New product title. +- **slug** (string, optional): New product slug. +- **postDate** (string, optional): Post date in ISO 8601 format (e.g., `2025-01-01T00:00:00+00:00`). +- **expiryDate** (string, optional): Expiry date in ISO 8601 format, or null to remove. +- **enabled** (boolean, optional): Whether the product is enabled. +- **fields** (object, optional): Custom field data keyed by field handle. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **productId** (integer): Product ID +- **title** (string): Updated title +- **slug** (string): Updated slug +- **status** (string): Current status +- **url** (string): Craft control panel edit URL + +## Example Usage + +### Update Title +```json +{ + "productId": 42, + "title": "Premium Widget" +} +``` + +### Update Custom Fields +```json +{ + "productId": 42, + "fields": { + "description": "An updated product description", + "featured": true + } +} +``` + +### CLI Usage +```bash +agent-craft products/update 42 --title="Premium Widget" --fields[description]="Updated" +``` + +## Notes + +- Only provided fields are updated; omitted fields remain unchanged +- Variant data is not modified through this tool; use `update_variant` instead +- Throws an error if the product ID doesn't exist +- Returns the control panel URL for review after updating +- Requires Craft Commerce to be installed diff --git a/SKILLS/update_product_type.md b/SKILLS/update_product_type.md new file mode 100644 index 0000000..34d88a9 --- /dev/null +++ b/SKILLS/update_product_type.md @@ -0,0 +1,132 @@ +# update_product_type + +Update an existing Commerce product type's configuration. + +## Route + +`PUT /api/product-types/` + +## Description + +Updates an existing product type in Craft Commerce. Only the provided properties are updated; all others remain unchanged. Allows modification of product type configuration including name, handle, title field settings, variant settings, field layouts, and per-site URL configuration. + +After updating the product type, always link the user back to the product type settings in the Craft control panel for review. + +## Parameters + +### Required Parameters + +- **productTypeId** (integer): The ID of the product type to update. + +### Optional Parameters + +All parameters are optional. Only provided values are updated. + +- **name** (string): The display name for the product type +- **handle** (string): Machine-readable name +- **hasProductTitleField** (boolean): Whether products have a title field. If set to `false`, `productTitleFormat` is required. +- **productTitleFormat** (string): Auto-generated title format for products +- **productTitleTranslationMethod** (string): How product titles are translated +- **productTitleTranslationKeyFormat** (string): Custom translation key format +- **hasVariantTitleField** (boolean): Whether variants have a title field. If set to `false`, `variantTitleFormat` is required. +- **variantTitleFormat** (string): Auto-generated title format for variants +- **variantTitleTranslationMethod** (string): How variant titles are translated +- **variantTitleTranslationKeyFormat** (string): Custom variant translation key format +- **showSlugField** (boolean): Whether to show the slug field +- **slugTranslationMethod** (string): How slugs are translated +- **slugTranslationKeyFormat** (string): Custom slug translation key format +- **skuFormat** (string): SKU format pattern +- **descriptionFormat** (string): Variant description format +- **template** (string): Product page template path +- **hasDimensions** (boolean): Whether products track dimensions +- **maxVariants** (integer): Maximum variants per product +- **enableVersioning** (boolean): Whether versioning is enabled +- **isStructure** (boolean): Whether products use hierarchical structure +- **maxLevels** (integer): Maximum hierarchy levels (structure only) +- **defaultPlacement** (string): Where new products are placed (structure only) +- **fieldLayoutId** (integer): Product-level field layout ID +- **variantFieldLayoutId** (integer): Variant-level field layout ID +- **siteSettings** (array): Site-specific settings. Replaces all existing site settings. Each object contains: + - `siteId` (integer, required): Site ID + - `enabledByDefault` (boolean): Enable products by default + - `hasUrls` (boolean): Whether products have URLs + - `uriFormat` (string): URI format pattern + - `template` (string): Template path + +## Return Value + +Returns an object containing: + +- **_notes** (string): Success message +- **id** (integer): Product type ID +- **name** (string): Product type name +- **handle** (string): Product type handle +- **fieldLayoutId** (integer|null): Product-level field layout ID +- **variantFieldLayoutId** (integer|null): Variant-level field layout ID +- **hasProductTitleField** (boolean): Whether products have a title field +- **productTitleFormat** (string): Product title format +- **hasVariantTitleField** (boolean): Whether variants have a title field +- **variantTitleFormat** (string): Variant title format +- **skuFormat** (string|null): SKU format +- **hasDimensions** (boolean): Whether dimensions are tracked +- **maxVariants** (integer|null): Maximum variants +- **enableVersioning** (boolean): Whether versioning is enabled +- **editUrl** (string): Craft control panel URL for product type settings +- **editVariantUrl** (string): Craft control panel URL for variant settings + +## Example Usage + +### Update Name +```json +{ + "productTypeId": 1, + "name": "Updated Product Type" +} +``` + +### Enable Dimensions and Versioning +```json +{ + "productTypeId": 1, + "hasDimensions": true, + "enableVersioning": true, + "maxVariants": 5 +} +``` + +### Update Title Field Settings +```json +{ + "productTypeId": 1, + "hasProductTitleField": false, + "productTitleFormat": "{dateCreated|date}" +} +``` + +## CLI Usage + +```bash +# Update name +agent-craft product-types/update 1 --name="Updated Type" + +# Update multiple settings +agent-craft product-types/update 1 \ + --hasDimensions=true \ + --enableVersioning=true \ + --maxVariants=5 +``` + +## Notes + +- Requires Craft Commerce to be installed and enabled +- Only provided fields are updated; others remain unchanged +- Throws an error if the product type ID doesn't exist +- Disabling title fields requires providing a title format +- Site settings replace all existing site settings when provided + +## See Also + +- [get_product_type](get_product_type.md) - Get detailed product type info +- [get_product_types](get_product_types.md) - List all product types +- [create_product_type](create_product_type.md) - Create a new product type +- [delete_product_type](delete_product_type.md) - Delete a product type diff --git a/SKILLS/update_store.md b/SKILLS/update_store.md new file mode 100644 index 0000000..f231b3d --- /dev/null +++ b/SKILLS/update_store.md @@ -0,0 +1,108 @@ +# update_store + +Update a Commerce store's configuration settings. + +## Route + +`PUT /api/stores/` + +## Description + +Updates a Commerce store's checkout behavior, pricing strategies, and address requirements. Only the provided settings are updated; all others remain unchanged. Currency cannot be changed after orders have been placed in the store. + +## Parameters + +### Required Parameters + +- **storeId** (integer): The ID of the store to update. + +### Optional Parameters + +- **name** (string, optional): Store display name. +- **currency** (string, optional): Currency code (e.g. USD, EUR). Cannot be changed after orders are placed. +- **autoSetNewCartAddresses** (boolean, optional): Whether to auto-set the user's primary addresses on new carts. +- **autoSetCartShippingMethodOption** (boolean, optional): Whether to auto-set the first available shipping method on carts. +- **autoSetPaymentSource** (boolean, optional): Whether to auto-set the user's primary payment source on new carts. +- **allowEmptyCartOnCheckout** (boolean, optional): Whether carts are allowed to be empty on checkout. +- **allowCheckoutWithoutPayment** (boolean, optional): Whether orders can be completed without payment. +- **allowPartialPaymentOnCheckout** (boolean, optional): Whether partial payments are allowed from the front end. +- **requireShippingAddressAtCheckout** (boolean, optional): Whether a shipping address is required before payment. +- **requireBillingAddressAtCheckout** (boolean, optional): Whether a billing address is required before payment. +- **requireShippingMethodSelectionAtCheckout** (boolean, optional): Whether shipping method selection is required before payment. +- **useBillingAddressForTax** (boolean, optional): Whether to use the billing address for tax calculations instead of shipping. +- **validateOrganizationTaxIdAsVatId** (boolean, optional): Whether to validate organizationTaxId as a VAT ID. +- **orderReferenceFormat** (string, optional): Order reference number format template (e.g. `{{number[:7]}}`). +- **freeOrderPaymentStrategy** (string, optional): How free orders are handled: `"complete"` (immediately) or `"process"` (via gateway). +- **minimumTotalPriceStrategy** (string, optional): Minimum total price strategy: `"default"`, `"zero"`, or `"shipping"`. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **id** (integer): Store ID +- **name** (string): Updated store name +- **handle** (string): Store handle +- **primary** (boolean): Whether this is the primary store +- **currency** (string): Currency code +- **url** (string): Craft control panel settings URL + +## Example Usage + +### Update Store Name +```json +{ + "storeId": 1, + "name": "US Store" +} +``` + +### Update Checkout Settings +```json +{ + "storeId": 1, + "allowCheckoutWithoutPayment": true, + "requireBillingAddressAtCheckout": true, + "requireShippingAddressAtCheckout": true +} +``` + +### Update Pricing Strategy +```json +{ + "storeId": 1, + "freeOrderPaymentStrategy": "process", + "minimumTotalPriceStrategy": "zero" +} +``` + +## CLI Usage + +```bash +# Update store name +agent-craft stores/update 1 --name="US Store" + +# Update checkout settings +agent-craft stores/update 1 \ + --allowCheckoutWithoutPayment=true \ + --requireBillingAddressAtCheckout=true + +# Update pricing strategy +agent-craft stores/update 1 \ + --freeOrderPaymentStrategy=process \ + --minimumTotalPriceStrategy=zero +``` + +## Notes + +- Only provided fields are updated; omitted fields remain unchanged +- Currency cannot be changed after orders have been placed in the store +- Throws an error if the store ID doesn't exist +- Returns the control panel URL for review after updating +- Requires Craft Commerce to be installed and enabled +- Store handle cannot be changed through this tool (use the Craft control panel) + +## See Also + +- [get_store](get_store.md) - Get store details before updating +- [get_stores](get_stores.md) - List all stores to find store IDs diff --git a/SKILLS/update_user.md b/SKILLS/update_user.md new file mode 100644 index 0000000..e3b0ff9 --- /dev/null +++ b/SKILLS/update_user.md @@ -0,0 +1,46 @@ +# update_user + +Update a Craft user resolved by ID, email, or username. + +## Endpoint + +`PUT /api/users/` + +## Parameters + +- One identifier is required: `userId` (int), `email` (string), or `username` (string) +- `newEmail` (string, optional) - Replacement email +- `newUsername` (string, optional) - Replacement username +- `newPassword` (string, optional) - Replacement password +- `fullName` (string, optional) - Updated full name +- `firstName` (string, optional) - Updated first name +- `lastName` (string, optional) - Updated last name +- `admin` (bool, optional) - Updated admin flag +- `active` (bool, optional) - Activate/deactivate the user +- `pending` (bool, optional) - Updated pending flag +- `suspended` (bool, optional) - Suspend/unsuspend the user +- `locked` (bool, optional) - Set `false` to unlock the user +- `affiliatedSiteId` (int, optional) - Updated affiliated site ID +- `groupIds` (array, optional) - Replace user groups; requires Craft Team or Pro +- `groupHandles` (array, optional) - Replace user groups by handle; requires Craft Team or Pro +- `permissions` (array, optional) - Replace direct user permissions; requires Craft Pro +- `fields` (object, optional) - Updated custom field values + +## Returns + +Returns the updated user with native attributes, groups, permissions, and custom fields. + +## Notes + +- Provide exactly one identifier. +- Provide only one of `groupIds` or `groupHandles`. +- Direct permission assignment is unavailable outside Craft Pro. + +## Example + +```json +{ + "username": "editor@example.com", + "fullName": "Updated User" +} +``` diff --git a/SKILLS/update_user_group.md b/SKILLS/update_user_group.md new file mode 100644 index 0000000..de640fe --- /dev/null +++ b/SKILLS/update_user_group.md @@ -0,0 +1,32 @@ +# update_user_group + +Update a Craft user group by ID or handle. + +## Endpoint + +`PUT /api/user-groups/` + +## Parameters + +- One identifier is required: `groupId` (int) or `handle` (string) +- `newName` (string, optional) - Replacement name +- `newHandle` (string, optional) - Replacement handle +- `description` (string, optional) - Replacement description +- `permissions` (array, optional) - Replacement permissions, including custom permission names + +## Returns + +Returns the updated user group with permissions and user count. + +## Notes + +- User groups require Craft Pro. + +## Example + +```json +{ + "handle": "reviewers", + "permissions": ["accesscp", "custompermission:review"] +} +``` diff --git a/SKILLS/update_variant.md b/SKILLS/update_variant.md new file mode 100644 index 0000000..e482690 --- /dev/null +++ b/SKILLS/update_variant.md @@ -0,0 +1,78 @@ +# update_variant + +Update an existing Commerce product variant. + +## Route + +`PUT /api/variants/` + +## Description + +Updates a variant's pricing, SKU, dimensions, and custom field values. Only the provided fields are updated; all others remain unchanged. + +## Parameters + +### Required Parameters + +- **variantId** (integer): The ID of the variant to update. + +### Optional Parameters + +- **sku** (string, optional): Variant SKU. +- **price** (float, optional): Variant price. +- **title** (string, optional): Variant title. +- **minQty** (integer, optional): Minimum purchase quantity. +- **maxQty** (integer, optional): Maximum purchase quantity. +- **weight** (float, optional): Variant weight. +- **height** (float, optional): Variant height. +- **length** (float, optional): Variant length. +- **width** (float, optional): Variant width. +- **freeShipping** (boolean, optional): Whether the variant qualifies for free shipping. +- **inventoryTracked** (boolean, optional): Whether inventory is tracked for this variant. +- **fields** (object, optional): Custom field data keyed by field handle. + +## Return Value + +Returns an object containing: + +- **_notes** (string): Confirmation message +- **variantId** (integer): Variant ID +- **title** (string): Updated title +- **sku** (string): Updated SKU +- **price** (float): Updated price +- **stock** (integer): Current stock level (read-only, managed via Commerce inventory system) +- **productId** (integer|null): Parent product ID +- **url** (string|null): Parent product's control panel edit URL + +## Example Usage + +### Update Price and SKU +```json +{ + "variantId": 99, + "price": 29.99, + "sku": "WIDGET-LG-BLUE" +} +``` + +### Update Dimensions and Shipping +```json +{ + "variantId": 99, + "weight": 2.5, + "freeShipping": true +} +``` + +### CLI Usage +```bash +agent-craft variants/update 99 --price=29.99 --sku="WIDGET-LG-BLUE" +agent-craft variants/update 99 --weight=2.5 --freeShipping=true +``` + +## Notes + +- Only provided fields are updated; omitted fields remain unchanged +- The URL links to the parent product's edit page +- Throws an error if the variant ID doesn't exist +- Requires Craft Commerce to be installed diff --git a/bin/agent-craft b/bin/agent-craft index 21c59f5..e0de20a 100755 --- a/bin/agent-craft +++ b/bin/agent-craft @@ -60,10 +60,15 @@ function exitWithError(int $code, string|array $message, ?Throwable $exception = } } - fwrite(STDERR, json_encode($output) . PHP_EOL); + fwrite(STDERR, safeJsonEncode($output) . PHP_EOL); exit($code); } +function safeJsonEncode(mixed $value): string +{ + return json_encode($value, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE); +} + // If parsing failed completely, exit now with what verbosity we have if ($parsed === null) { exitWithError(2, 'Failed to parse arguments', $e ?? null, $parser->verbosity); @@ -183,7 +188,8 @@ try { // Create Valinor mapper for parameter validation $mapperBuilder = (new \CuyZ\Valinor\MapperBuilder()) ->allowPermissiveTypes() - ->allowSuperfluousKeys(); + ->allowSuperfluousKeys() + ->allowScalarValueCasting(); $mapper = $mapperBuilder->argumentsMapper(); @@ -192,7 +198,7 @@ try { $result = $router->route($command, $positional, $flags); // Output successful result as JSON to stdout - echo json_encode($result) . PHP_EOL; + echo safeJsonEncode($result) . PHP_EOL; exit(0); } catch (\InvalidArgumentException $e) { // Unknown command or invalid arguments diff --git a/composer.json b/composer.json index ba82d5e..49e5176 100644 --- a/composer.json +++ b/composer.json @@ -31,10 +31,14 @@ "class": "happycog\\craftmcp\\Plugin" }, "require-dev": { + "craftcms/commerce": "^5.4", "markhuot/craft-pest-core": "dev-main", "phpstan/phpstan": "*", "craftcms/phpstan": "dev-main" }, + "replace": { + "ibericode/vat": "*" + }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { diff --git a/src/Plugin.php b/src/Plugin.php index 5ea98ec..a49d0ae 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -65,7 +65,60 @@ protected function registerSiteUrlRules(RegisterUrlRulesEvent $event): void $event->rules['DELETE ' . $apiPrefix . '/assets/'] = 'skills/assets/delete'; $event->rules['GET ' . $apiPrefix . '/volumes'] = 'skills/assets/volumes'; + // Address routes + $event->rules['GET ' . $apiPrefix . '/addresses'] = 'skills/addresses/list'; + $event->rules['POST ' . $apiPrefix . '/addresses'] = 'skills/addresses/create'; + $event->rules['GET ' . $apiPrefix . '/addresses/'] = 'skills/addresses/get'; + $event->rules['PUT ' . $apiPrefix . '/addresses/'] = 'skills/addresses/update'; + $event->rules['DELETE ' . $apiPrefix . '/addresses/'] = 'skills/addresses/delete'; + $event->rules['GET ' . $apiPrefix . '/addresses/field-layout'] = 'skills/addresses/field-layout'; + + // User routes + $event->rules['GET ' . $apiPrefix . '/users'] = 'skills/users/list'; + $event->rules['POST ' . $apiPrefix . '/users'] = 'skills/users/create'; + $event->rules['GET ' . $apiPrefix . '/users/permissions'] = 'skills/users/permissions'; + $event->rules['GET ' . $apiPrefix . '/users/'] = 'skills/users/get'; + $event->rules['PUT ' . $apiPrefix . '/users/'] = 'skills/users/update'; + $event->rules['DELETE ' . $apiPrefix . '/users/'] = 'skills/users/delete'; + $event->rules['GET ' . $apiPrefix . '/users/field-layout'] = 'skills/users/field-layout'; + + // User group routes + $event->rules['GET ' . $apiPrefix . '/user-groups'] = 'skills/user-groups/list'; + $event->rules['POST ' . $apiPrefix . '/user-groups'] = 'skills/user-groups/create'; + $event->rules['GET ' . $apiPrefix . '/user-groups/'] = 'skills/user-groups/get'; + $event->rules['PUT ' . $apiPrefix . '/user-groups/'] = 'skills/user-groups/update'; + $event->rules['DELETE ' . $apiPrefix . '/user-groups/'] = 'skills/user-groups/delete'; + // Health check route $event->rules['GET ' . $apiPrefix . '/health'] = 'skills/health/index'; + + // Commerce: Product routes + $event->rules['POST ' . $apiPrefix . '/products'] = 'skills/products/create'; + $event->rules['GET ' . $apiPrefix . '/products/search'] = 'skills/products/search'; + $event->rules['GET ' . $apiPrefix . '/products/'] = 'skills/products/get'; + $event->rules['PUT ' . $apiPrefix . '/products/'] = 'skills/products/update'; + $event->rules['DELETE ' . $apiPrefix . '/products/'] = 'skills/products/delete'; + $event->rules['GET ' . $apiPrefix . '/product-types'] = 'skills/products/types'; + $event->rules['POST ' . $apiPrefix . '/product-types'] = 'skills/products/create-type'; + $event->rules['GET ' . $apiPrefix . '/product-types/'] = 'skills/products/get-type'; + $event->rules['PUT ' . $apiPrefix . '/product-types/'] = 'skills/products/update-type'; + $event->rules['DELETE ' . $apiPrefix . '/product-types/'] = 'skills/products/delete-type'; + + // Commerce: Variant routes + $event->rules['POST ' . $apiPrefix . '/variants'] = 'skills/variants/create'; + $event->rules['GET ' . $apiPrefix . '/variants/'] = 'skills/variants/get'; + $event->rules['PUT ' . $apiPrefix . '/variants/'] = 'skills/variants/update'; + $event->rules['DELETE ' . $apiPrefix . '/variants/'] = 'skills/variants/delete'; + + // Commerce: Order routes + $event->rules['GET ' . $apiPrefix . '/orders/search'] = 'skills/orders/search'; + $event->rules['GET ' . $apiPrefix . '/orders/'] = 'skills/orders/get'; + $event->rules['PUT ' . $apiPrefix . '/orders/'] = 'skills/orders/update'; + $event->rules['GET ' . $apiPrefix . '/order-statuses'] = 'skills/orders/statuses'; + + // Commerce: Store routes + $event->rules['GET ' . $apiPrefix . '/stores'] = 'skills/stores/list'; + $event->rules['GET ' . $apiPrefix . '/stores/'] = 'skills/stores/get'; + $event->rules['PUT ' . $apiPrefix . '/stores/'] = 'skills/stores/update'; } } diff --git a/src/actions/ClearFieldLayoutCache.php b/src/actions/ClearFieldLayoutCache.php new file mode 100644 index 0000000..c0ea9ca --- /dev/null +++ b/src/actions/ClearFieldLayoutCache.php @@ -0,0 +1,27 @@ +_layouts = null; + })->call($this->fieldsService); + + (function (): void { + $this->_sections = null; + $this->_entryTypes = null; + })->call($this->entriesService); + } +} diff --git a/src/actions/EnsureAddressFieldLayoutRow.php b/src/actions/EnsureAddressFieldLayoutRow.php new file mode 100644 index 0000000..8dc88ef --- /dev/null +++ b/src/actions/EnsureAddressFieldLayoutRow.php @@ -0,0 +1,57 @@ +uid; + throw_unless(is_string($uid) && $uid !== '', 'Address field layout UID is missing.'); + + $db = Craft::$app->getDb(); + $row = (new \yii\db\Query()) + ->from(Table::FIELDLAYOUTS) + ->where(['uid' => $uid]) + ->one(); + + if (is_array($row)) { + $db->createCommand()->update(Table::FIELDLAYOUTS, [ + 'type' => Address::class, + 'config' => $fieldLayout->getConfig(), + ], ['uid' => $uid])->execute(); + } + + $existingId = $row['id'] ?? null; + + if (!is_int($existingId) && !ctype_digit((string) $existingId)) { + $db->createCommand()->insert(Table::FIELDLAYOUTS, [ + 'type' => Address::class, + 'uid' => $uid, + 'config' => $fieldLayout->getConfig(), + ])->execute(); + + $existingId = (new \yii\db\Query()) + ->from(Table::FIELDLAYOUTS) + ->where(['uid' => $uid]) + ->scalar(); + } + + throw_unless(is_int($existingId) || ctype_digit((string) $existingId), 'Failed to ensure address field layout row exists.'); + + $fieldLayout->id = (int) $existingId; + ($this->clearFieldLayoutCache)(); + + return $fieldLayout; + } +} diff --git a/src/actions/EnsureUserFieldLayoutRow.php b/src/actions/EnsureUserFieldLayoutRow.php new file mode 100644 index 0000000..e07bf23 --- /dev/null +++ b/src/actions/EnsureUserFieldLayoutRow.php @@ -0,0 +1,57 @@ +uid; + throw_unless(is_string($uid) && $uid !== '', 'User field layout UID is missing.'); + + $db = Craft::$app->getDb(); + $row = (new \yii\db\Query()) + ->from(Table::FIELDLAYOUTS) + ->where(['uid' => $uid]) + ->one(); + + if (is_array($row)) { + $db->createCommand()->update(Table::FIELDLAYOUTS, [ + 'type' => User::class, + 'config' => $fieldLayout->getConfig(), + ], ['uid' => $uid])->execute(); + } + + $existingId = $row['id'] ?? null; + + if (!is_int($existingId) && !ctype_digit((string) $existingId)) { + $db->createCommand()->insert(Table::FIELDLAYOUTS, [ + 'type' => User::class, + 'uid' => $uid, + 'config' => $fieldLayout->getConfig(), + ])->execute(); + + $existingId = (new \yii\db\Query()) + ->from(Table::FIELDLAYOUTS) + ->where(['uid' => $uid]) + ->scalar(); + } + + throw_unless(is_int($existingId) || ctype_digit((string) $existingId), 'Failed to ensure user field layout row exists.'); + + $fieldLayout->id = (int) $existingId; + ($this->clearFieldLayoutCache)(); + + return $fieldLayout; + } +} diff --git a/src/actions/FormatAddress.php b/src/actions/FormatAddress.php new file mode 100644 index 0000000..1145741 --- /dev/null +++ b/src/actions/FormatAddress.php @@ -0,0 +1,65 @@ + + */ + public function __invoke(Address $address): array + { + $owner = $address->getOwner(); + $field = $address->getField(); + + return [ + 'addressId' => $address->id, + 'title' => $address->title, + 'fullName' => $address->fullName, + 'firstName' => $address->firstName, + 'lastName' => $address->lastName, + 'countryCode' => $address->countryCode, + 'administrativeArea' => $address->administrativeArea, + 'locality' => $address->locality, + 'dependentLocality' => $address->dependentLocality, + 'postalCode' => $address->postalCode, + 'sortingCode' => $address->sortingCode, + 'addressLine1' => $address->addressLine1, + 'addressLine2' => $address->addressLine2, + 'addressLine3' => $address->addressLine3, + 'organization' => $address->organization, + 'organizationTaxId' => $address->organizationTaxId, + 'latitude' => $address->latitude, + 'longitude' => $address->longitude, + 'fieldId' => $address->fieldId, + 'fieldHandle' => $field?->handle, + 'ownerId' => $address->getOwnerId(), + 'primaryOwnerId' => $address->getPrimaryOwnerId(), + 'ownerType' => $owner ? $owner::class : null, + 'ownerTitle' => $this->ownerTitle($owner), + 'dateCreated' => $address->dateCreated?->format('c'), + 'dateUpdated' => $address->dateUpdated?->format('c'), + 'url' => $owner ? ElementHelper::elementEditorUrl($owner) : null, + 'customFields' => $address->getSerializedFieldValues(), + ]; + } + + private function ownerTitle(?ElementInterface $owner): ?string + { + if ($owner === null) { + return null; + } + + if ($owner instanceof User) { + return $owner->friendlyName; + } + + return $owner->title ?? null; + } +} diff --git a/src/actions/FormatUser.php b/src/actions/FormatUser.php new file mode 100644 index 0000000..c870fce --- /dev/null +++ b/src/actions/FormatUser.php @@ -0,0 +1,72 @@ + + */ + public function __invoke(User $user): array + { + $groups = $user->getGroups(); + $groupPermissions = $user->id !== null ? $this->userPermissions->getGroupPermissionsByUserId($user->id) : []; + $permissions = $user->id !== null ? $this->userPermissions->getPermissionsByUserId($user->id) : []; + $site = $user->affiliatedSiteId !== null ? $this->sitesService->getSiteById($user->affiliatedSiteId) : null; + + return [ + 'id' => $user->id, + 'uid' => $user->uid, + 'username' => $user->username, + 'email' => $user->email, + 'fullName' => $user->fullName, + 'friendlyName' => $user->getFriendlyName(), + 'firstName' => $user->firstName, + 'lastName' => $user->lastName, + 'status' => $user->getStatus(), + 'active' => $user->active, + 'pending' => $user->pending, + 'locked' => $user->locked, + 'suspended' => $user->suspended, + 'admin' => $user->admin, + 'enabled' => $user->enabled, + 'photoId' => $user->photoId, + 'preferredLanguage' => $user->getPreferredLanguage(), + 'preferredLocale' => $user->getPreferredLocale(), + 'affiliatedSite' => $site !== null ? [ + 'id' => $site->id, + 'name' => $site->name, + 'handle' => $site->handle, + ] : null, + 'fieldLayoutId' => $user->getFieldLayout()?->id, + 'groups' => array_map(fn(UserGroup $group) => [ + 'id' => $group->id, + 'name' => $group->name, + 'handle' => $group->handle, + 'uid' => $group->uid, + ], $groups), + 'permissions' => $permissions, + 'groupPermissions' => $groupPermissions, + 'directPermissions' => array_values(array_diff($permissions, $groupPermissions)), + 'customFields' => $user->getSerializedFieldValues(), + 'cpEditUrl' => $user->id !== null ? UrlHelper::cpUrl("users/{$user->id}") : null, + 'settingsUrl' => UrlHelper::cpUrl('settings/users'), + 'dateCreated' => $user->dateCreated?->format('c'), + 'dateUpdated' => $user->dateUpdated?->format('c'), + ]; + } +} diff --git a/src/actions/FormatUserGroup.php b/src/actions/FormatUserGroup.php new file mode 100644 index 0000000..c4a65e4 --- /dev/null +++ b/src/actions/FormatUserGroup.php @@ -0,0 +1,38 @@ + + */ + public function __invoke(UserGroup $group): array + { + $groupId = $group->id; + + return [ + 'id' => $groupId, + 'uid' => $group->uid, + 'name' => $group->name, + 'handle' => $group->handle, + 'description' => $group->description, + 'permissions' => $groupId !== null ? $this->userPermissions->getPermissionsByGroupId($groupId) : [], + 'userCount' => $groupId !== null ? (new Query()) + ->from(Table::USERGROUPS_USERS) + ->where(['groupId' => $groupId]) + ->count() : 0, + 'cpEditUrl' => $group->getCpEditUrl(), + ]; + } +} diff --git a/src/actions/GetFreshAddressFieldLayout.php b/src/actions/GetFreshAddressFieldLayout.php new file mode 100644 index 0000000..4a74a4d --- /dev/null +++ b/src/actions/GetFreshAddressFieldLayout.php @@ -0,0 +1,18 @@ +resolvePersistedAddressFieldLayout)(); + } +} diff --git a/src/actions/GetFreshUserFieldLayout.php b/src/actions/GetFreshUserFieldLayout.php new file mode 100644 index 0000000..7e1ea70 --- /dev/null +++ b/src/actions/GetFreshUserFieldLayout.php @@ -0,0 +1,18 @@ +resolvePersistedUserFieldLayout)(); + } +} diff --git a/src/actions/NormalizeAddressFieldLayoutForSave.php b/src/actions/NormalizeAddressFieldLayoutForSave.php new file mode 100644 index 0000000..942ae14 --- /dev/null +++ b/src/actions/NormalizeAddressFieldLayoutForSave.php @@ -0,0 +1,42 @@ +setTabs($fieldLayout->getTabs()); + + if ($normalizedLayout->id === GetAddressFieldLayout::PLACEHOLDER_ID) { + $normalizedLayout->id = null; + } + + if ($normalizedLayout->id !== null && !$this->layoutExists($normalizedLayout->id)) { + $normalizedLayout->id = null; + } + + if ($normalizedLayout->id === null) { + $normalizedLayout = ($this->ensureAddressFieldLayoutRow)($normalizedLayout); + } + + return $normalizedLayout; + } + + private function layoutExists(int $fieldLayoutId): bool + { + return (new \yii\db\Query()) + ->from('{{%fieldlayouts}}') + ->where(['id' => $fieldLayoutId]) + ->exists(); + } +} diff --git a/src/actions/NormalizeUserFieldLayoutForSave.php b/src/actions/NormalizeUserFieldLayoutForSave.php new file mode 100644 index 0000000..51b5464 --- /dev/null +++ b/src/actions/NormalizeUserFieldLayoutForSave.php @@ -0,0 +1,42 @@ +setTabs($fieldLayout->getTabs()); + + if ($normalizedLayout->id === GetUserFieldLayout::PLACEHOLDER_ID) { + $normalizedLayout->id = null; + } + + if ($normalizedLayout->id !== null && !$this->layoutExists($normalizedLayout->id)) { + $normalizedLayout->id = null; + } + + if ($normalizedLayout->id === null) { + $normalizedLayout = ($this->ensureUserFieldLayoutRow)($normalizedLayout); + } + + return $normalizedLayout; + } + + private function layoutExists(int $fieldLayoutId): bool + { + return (new \yii\db\Query()) + ->from('{{%fieldlayouts}}') + ->where(['id' => $fieldLayoutId]) + ->exists(); + } +} diff --git a/src/actions/ResolveElementOwner.php b/src/actions/ResolveElementOwner.php new file mode 100644 index 0000000..1c88be9 --- /dev/null +++ b/src/actions/ResolveElementOwner.php @@ -0,0 +1,28 @@ +} + */ + public function __invoke(int $ownerId, string $ownerType): array + { + throw_unless(class_exists($ownerType), \InvalidArgumentException::class, "Owner type '{$ownerType}' does not exist"); + throw_unless(is_subclass_of($ownerType, ElementInterface::class), \InvalidArgumentException::class, "Owner type '{$ownerType}' is not a valid Craft element type"); + + /** @var class-string $resolvedOwnerType */ + $resolvedOwnerType = $ownerType; + + $owner = \Craft::$app->getElements()->getElementById($ownerId, $resolvedOwnerType, null, [ + 'siteId' => '*', + ]); + + throw_unless($owner instanceof ElementInterface, \InvalidArgumentException::class, "Owner {$resolvedOwnerType} with ID {$ownerId} not found"); + + return [$owner, $resolvedOwnerType]; + } +} diff --git a/src/actions/ResolveFieldLayout.php b/src/actions/ResolveFieldLayout.php new file mode 100644 index 0000000..58ad9e6 --- /dev/null +++ b/src/actions/ResolveFieldLayout.php @@ -0,0 +1,43 @@ +fieldsService->getLayoutById($fieldLayoutId); + + if ($fieldLayout instanceof FieldLayout) { + return $fieldLayout; + } + + $addressLayout = ($this->resolvePersistedAddressFieldLayout)(); + + if ($fieldLayoutId === GetAddressFieldLayout::PLACEHOLDER_ID && $addressLayout->type === Address::class) { + return $addressLayout; + } + + $userLayout = ($this->resolvePersistedUserFieldLayout)(); + + if ($fieldLayoutId === GetUserFieldLayout::PLACEHOLDER_ID && $userLayout->type === User::class) { + return $userLayout; + } + + return null; + } +} diff --git a/src/actions/ResolvePersistedAddressFieldLayout.php b/src/actions/ResolvePersistedAddressFieldLayout.php new file mode 100644 index 0000000..bbcedd1 --- /dev/null +++ b/src/actions/ResolvePersistedAddressFieldLayout.php @@ -0,0 +1,65 @@ +clearFieldLayoutCache)(); + + $layoutConfig = (new \yii\db\Query()) + ->from(Table::FIELDLAYOUTS) + ->where(['type' => Address::class]) + ->orderBy(['id' => SORT_DESC]) + ->one(); + + if (is_array($layoutConfig)) { + $rawConfig = $layoutConfig['config'] ?? null; + unset($layoutConfig['config'], $layoutConfig['dateCreated'], $layoutConfig['dateUpdated'], $layoutConfig['dateDeleted']); + /** @var FieldLayout $layout */ + $layout = \Craft::$app->getFields()->createLayout($layoutConfig); + + if (is_string($rawConfig) && $rawConfig !== '') { + $decodedConfig = \craft\helpers\Json::decode($rawConfig); + if (is_array($decodedConfig)) { + $tabs = []; + foreach (($decodedConfig['tabs'] ?? []) as $tabConfig) { + if (!is_array($tabConfig)) { + continue; + } + + $tabs[] = new FieldLayoutTab([ + 'layout' => $layout, + 'name' => $tabConfig['name'] ?? 'Content', + 'uid' => $tabConfig['uid'] ?? null, + 'elements' => $tabConfig['elements'] ?? [], + ]); + } + + if ($tabs !== []) { + $layout->setTabs($tabs); + } + } + } + + return $layout; + } + + $layout = Craft::$app->getFields()->getLayoutByType(Address::class); + throw_unless($layout instanceof FieldLayout, 'Address field layout could not be resolved.'); + + return $layout; + } +} diff --git a/src/actions/ResolvePersistedUserFieldLayout.php b/src/actions/ResolvePersistedUserFieldLayout.php new file mode 100644 index 0000000..a8bd22f --- /dev/null +++ b/src/actions/ResolvePersistedUserFieldLayout.php @@ -0,0 +1,65 @@ +clearFieldLayoutCache)(); + + $layoutConfig = (new \yii\db\Query()) + ->from(Table::FIELDLAYOUTS) + ->where(['type' => User::class]) + ->orderBy(['id' => SORT_DESC]) + ->one(); + + if (is_array($layoutConfig)) { + $rawConfig = $layoutConfig['config'] ?? null; + unset($layoutConfig['config'], $layoutConfig['dateCreated'], $layoutConfig['dateUpdated'], $layoutConfig['dateDeleted']); + /** @var FieldLayout $layout */ + $layout = Craft::$app->getFields()->createLayout($layoutConfig); + + if (is_string($rawConfig) && $rawConfig !== '') { + $decodedConfig = \craft\helpers\Json::decode($rawConfig); + if (is_array($decodedConfig)) { + $tabs = []; + foreach (($decodedConfig['tabs'] ?? []) as $tabConfig) { + if (!is_array($tabConfig)) { + continue; + } + + $tabs[] = new FieldLayoutTab([ + 'layout' => $layout, + 'name' => $tabConfig['name'] ?? 'Content', + 'uid' => $tabConfig['uid'] ?? null, + 'elements' => $tabConfig['elements'] ?? [], + ]); + } + + if ($tabs !== []) { + $layout->setTabs($tabs); + } + } + } + + return $layout; + } + + $layout = Craft::$app->getFields()->getLayoutByType(User::class); + throw_unless($layout instanceof FieldLayout, 'User field layout could not be resolved.'); + + return $layout; + } +} diff --git a/src/actions/ResolveUser.php b/src/actions/ResolveUser.php new file mode 100644 index 0000000..aabbe5f --- /dev/null +++ b/src/actions/ResolveUser.php @@ -0,0 +1,43 @@ + $userId !== null, + 'email' => $email !== null, + 'username' => $username !== null, + ]); + + throw_unless(count($provided) === 1, \InvalidArgumentException::class, 'Provide exactly one of userId, email, or username.'); + + if ($userId !== null) { + $user = $this->usersService->getUserById($userId); + throw_unless($user instanceof User, \InvalidArgumentException::class, "User with ID {$userId} not found."); + return $user; + } + + if ($email !== null) { + /** @var User|null $user */ + $user = User::find()->email($email)->status(null)->one(); + throw_unless($user instanceof User, \InvalidArgumentException::class, "User with email '{$email}' not found."); + return $user; + } + + /** @var User|null $user */ + $user = User::find()->username($username)->status(null)->one(); + throw_unless($user instanceof User, \InvalidArgumentException::class, "User with username '{$username}' not found."); + + return $user; + } +} diff --git a/src/actions/ResolveUserGroup.php b/src/actions/ResolveUserGroup.php new file mode 100644 index 0000000..3f8ca1a --- /dev/null +++ b/src/actions/ResolveUserGroup.php @@ -0,0 +1,33 @@ + $groupId !== null, + 'handle' => $handle !== null, + ]); + + throw_unless(count($provided) === 1, \InvalidArgumentException::class, 'Provide exactly one of groupId or handle.'); + + $group = $groupId !== null + ? $this->userGroupsService->getGroupById($groupId) + : $this->userGroupsService->getGroupByHandle((string) $handle); + + $identifier = $groupId !== null ? "ID {$groupId}" : "handle '{$handle}'"; + throw_unless($group instanceof UserGroup, \InvalidArgumentException::class, "User group with {$identifier} not found."); + + return $group; + } +} diff --git a/src/actions/ResolveUserGroupIds.php b/src/actions/ResolveUserGroupIds.php new file mode 100644 index 0000000..8e6df8f --- /dev/null +++ b/src/actions/ResolveUserGroupIds.php @@ -0,0 +1,45 @@ +|null $groupIds + * @param list|null $groupHandles + * @return list + */ + public function __invoke(?array $groupIds = null, ?array $groupHandles = null): array + { + if ($groupIds === null && $groupHandles === null) { + return []; + } + + throw_unless($groupIds === null || $groupHandles === null, \InvalidArgumentException::class, 'Provide either groupIds or groupHandles, not both.'); + + if ($groupIds !== null) { + foreach ($groupIds as $groupId) { + throw_unless($this->userGroupsService->getGroupById($groupId) instanceof UserGroup, \InvalidArgumentException::class, "User group with ID {$groupId} not found."); + } + + return array_values(array_unique($groupIds)); + } + + $resolvedGroupIds = []; + foreach ($groupHandles as $handle) { + $group = $this->userGroupsService->getGroupByHandle($handle); + throw_unless($group instanceof UserGroup && $group->id !== null, \InvalidArgumentException::class, "User group with handle '{$handle}' not found."); + $resolvedGroupIds[] = $group->id; + } + + return array_values(array_unique($resolvedGroupIds)); + } +} diff --git a/src/actions/SaveFieldLayout.php b/src/actions/SaveFieldLayout.php new file mode 100644 index 0000000..9f8b206 --- /dev/null +++ b/src/actions/SaveFieldLayout.php @@ -0,0 +1,38 @@ +type === Address::class) { + $addressFieldLayout = ($this->normalizeAddressFieldLayoutForSave)($fieldLayout); + + ($this->clearFieldLayoutCache)(); + return $this->fieldsService->saveLayout($addressFieldLayout); + } + + if ($fieldLayout->type === User::class) { + $userFieldLayout = ($this->normalizeUserFieldLayoutForSave)($fieldLayout); + + ($this->clearFieldLayoutCache)(); + return $this->fieldsService->saveLayout($userFieldLayout); + } + + return $this->fieldsService->saveLayout($fieldLayout); + } +} diff --git a/src/actions/SaveUserGroupPermissions.php b/src/actions/SaveUserGroupPermissions.php new file mode 100644 index 0000000..4b3b035 --- /dev/null +++ b/src/actions/SaveUserGroupPermissions.php @@ -0,0 +1,38 @@ +uid; + throw_unless(is_string($groupUid) && $groupUid !== '', 'User group UID is missing.'); + + $normalizedPermissions = array_values(array_unique(array_map('strtolower', $permissions))); + sort($normalizedPermissions); + + Craft::$app->getProjectConfig()->set( + ProjectConfig::PATH_USER_GROUPS . '.' . $groupUid . '.permissions', + $normalizedPermissions, + "Update permissions for user group '{$group->handle}'", + ); + + $this->userPermissions->reset(); + + return true; + } +} diff --git a/src/actions/SaveUserPermissions.php b/src/actions/SaveUserPermissions.php new file mode 100644 index 0000000..e52712a --- /dev/null +++ b/src/actions/SaveUserPermissions.php @@ -0,0 +1,50 @@ + $userId, + ]); + + if ($normalizedPermissions !== []) { + $userPermissionValues = []; + + foreach ($normalizedPermissions as $permissionName) { + $permissionRecord = UserPermissionRecord::findOne(['name' => $permissionName]) ?? new UserPermissionRecord(); + $permissionRecord->name = $permissionName; + $permissionRecord->save(false); + + $permissionId = $permissionRecord->id; + throw_unless($permissionId !== null, "Failed to resolve permission ID for '{$permissionName}'."); + + $userPermissionValues[] = [$permissionId, $userId]; + } + + Db::batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $userPermissionValues); + } + + $this->userPermissions->reset(); + + return true; + } +} diff --git a/src/cli/ArgumentParser.php b/src/cli/ArgumentParser.php index e5d568e..264dfd0 100644 --- a/src/cli/ArgumentParser.php +++ b/src/cli/ArgumentParser.php @@ -6,7 +6,6 @@ use function count; use function is_array; -use function is_numeric; use function json_decode; use function parse_str; use function preg_match; @@ -309,12 +308,17 @@ private function parseValue(string $value): mixed } /** - * Parse a scalar value (bool, int, or string). + * Parse a scalar value (bool or string). + * + * Numeric strings are intentionally kept as strings. Valinor's + * allowScalarValueCasting() handles string-to-int conversion when + * the target parameter expects an int, which avoids type mismatches + * when a numeric string is passed to a parameter expecting string|null. * * @param string $value The value to parse - * @return bool|int|string The parsed scalar value + * @return bool|string The parsed scalar value */ - private function parseScalar(string $value): bool|int|string + private function parseScalar(string $value): bool|string { // Check for boolean if ($value === 'true') { @@ -324,12 +328,7 @@ private function parseScalar(string $value): bool|int|string return false; } - // Check for integer - if (is_numeric($value) && (string) (int) $value === $value) { - return (int) $value; - } - - // Return as string + // Return as string — Valinor handles int casting when needed return $value; } diff --git a/src/cli/CommandMap.php b/src/cli/CommandMap.php index 49b3243..8e5ae1d 100644 --- a/src/cli/CommandMap.php +++ b/src/cli/CommandMap.php @@ -8,6 +8,7 @@ use happycog\craftmcp\tools\AddTabToFieldLayout; use happycog\craftmcp\tools\AddUiElementToFieldLayout; use happycog\craftmcp\tools\ApplyDraft; +use happycog\craftmcp\tools\CreateAddress; use happycog\craftmcp\tools\CreateAsset; use happycog\craftmcp\tools\CreateDraft; use happycog\craftmcp\tools\CreateEntry; @@ -15,13 +16,21 @@ use happycog\craftmcp\tools\CreateField; use happycog\craftmcp\tools\CreateFieldLayout; use happycog\craftmcp\tools\CreateSection; +use happycog\craftmcp\tools\CreateUser; +use happycog\craftmcp\tools\CreateUserGroup; use happycog\craftmcp\tools\DeleteAsset; +use happycog\craftmcp\tools\DeleteAddress; use happycog\craftmcp\tools\DeleteEntry; use happycog\craftmcp\tools\DeleteEntryType; use happycog\craftmcp\tools\DeleteField; use happycog\craftmcp\tools\DeleteSection; +use happycog\craftmcp\tools\DeleteUser; +use happycog\craftmcp\tools\DeleteUserGroup; use happycog\craftmcp\tools\GetEntry; use happycog\craftmcp\tools\GetEntryTypes; +use happycog\craftmcp\tools\GetAddress; +use happycog\craftmcp\tools\GetAddressFieldLayout; +use happycog\craftmcp\tools\GetAddresses; use happycog\craftmcp\tools\GetFieldLayout; use happycog\craftmcp\tools\GetFields; use happycog\craftmcp\tools\GetFieldTypes; @@ -29,16 +38,46 @@ use happycog\craftmcp\tools\GetSection; use happycog\craftmcp\tools\GetSections; use happycog\craftmcp\tools\GetSites; +use happycog\craftmcp\tools\GetAvailablePermissions; +use happycog\craftmcp\tools\GetUser; +use happycog\craftmcp\tools\GetUserFieldLayout; +use happycog\craftmcp\tools\GetUserGroup; +use happycog\craftmcp\tools\GetUserGroups; +use happycog\craftmcp\tools\GetUsers; use happycog\craftmcp\tools\GetVolumes; use happycog\craftmcp\tools\MoveElementInFieldLayout; use happycog\craftmcp\tools\RemoveElementFromFieldLayout; use happycog\craftmcp\tools\SearchContent; use happycog\craftmcp\tools\UpdateAsset; +use happycog\craftmcp\tools\UpdateAddress; use happycog\craftmcp\tools\UpdateDraft; use happycog\craftmcp\tools\UpdateEntry; use happycog\craftmcp\tools\UpdateEntryType; use happycog\craftmcp\tools\UpdateField; +use happycog\craftmcp\tools\UpdateOrder; +use happycog\craftmcp\tools\UpdateProduct; +use happycog\craftmcp\tools\UpdateProductType; use happycog\craftmcp\tools\UpdateSection; +use happycog\craftmcp\tools\UpdateUser; +use happycog\craftmcp\tools\UpdateUserGroup; +use happycog\craftmcp\tools\UpdateVariant; +use happycog\craftmcp\tools\CreateProduct; +use happycog\craftmcp\tools\CreateProductType; +use happycog\craftmcp\tools\CreateVariant; +use happycog\craftmcp\tools\DeleteProduct; +use happycog\craftmcp\tools\DeleteProductType; +use happycog\craftmcp\tools\DeleteVariant; +use happycog\craftmcp\tools\GetOrder; +use happycog\craftmcp\tools\GetOrderStatuses; +use happycog\craftmcp\tools\GetProduct; +use happycog\craftmcp\tools\GetProducts; +use happycog\craftmcp\tools\GetProductType; +use happycog\craftmcp\tools\GetProductTypes; +use happycog\craftmcp\tools\GetVariant; +use happycog\craftmcp\tools\GetStore; +use happycog\craftmcp\tools\GetStores; +use happycog\craftmcp\tools\SearchOrders; +use happycog\craftmcp\tools\UpdateStore; /** * Centralized command-to-tool mapping. @@ -57,6 +96,30 @@ class CommandMap 'assets/delete' => DeleteAsset::class, 'assets/update' => UpdateAsset::class, + // Addresses + 'addresses/list' => GetAddresses::class, + 'addresses/get' => GetAddress::class, + 'addresses/create' => CreateAddress::class, + 'addresses/update' => UpdateAddress::class, + 'addresses/delete' => DeleteAddress::class, + 'addresses/field-layout' => GetAddressFieldLayout::class, + + // Users + 'users/list' => GetUsers::class, + 'users/get' => GetUser::class, + 'users/create' => CreateUser::class, + 'users/permissions' => GetAvailablePermissions::class, + 'users/update' => UpdateUser::class, + 'users/delete' => DeleteUser::class, + 'users/field-layout' => GetUserFieldLayout::class, + + // User Groups + 'user-groups/list' => GetUserGroups::class, + 'user-groups/get' => GetUserGroup::class, + 'user-groups/create' => CreateUserGroup::class, + 'user-groups/update' => UpdateUserGroup::class, + 'user-groups/delete' => DeleteUserGroup::class, + // Drafts 'drafts/apply' => ApplyDraft::class, 'drafts/create' => CreateDraft::class, @@ -106,6 +169,35 @@ class CommandMap // Health 'health' => GetHealth::class, + + // Commerce: Products + 'products/create' => CreateProduct::class, + 'products/get' => GetProduct::class, + 'products/search' => GetProducts::class, + 'products/update' => UpdateProduct::class, + 'products/delete' => DeleteProduct::class, + 'product-types/list' => GetProductTypes::class, + 'product-types/get' => GetProductType::class, + 'product-types/create' => CreateProductType::class, + 'product-types/update' => UpdateProductType::class, + 'product-types/delete' => DeleteProductType::class, + + // Commerce: Variants + 'variants/create' => CreateVariant::class, + 'variants/get' => GetVariant::class, + 'variants/update' => UpdateVariant::class, + 'variants/delete' => DeleteVariant::class, + + // Commerce: Orders + 'orders/get' => GetOrder::class, + 'orders/search' => SearchOrders::class, + 'orders/update' => UpdateOrder::class, + 'order-statuses/list' => GetOrderStatuses::class, + + // Commerce: Stores + 'stores/list' => GetStores::class, + 'stores/get' => GetStore::class, + 'stores/update' => UpdateStore::class, ]; /** diff --git a/src/controllers/AddressesController.php b/src/controllers/AddressesController.php new file mode 100644 index 0000000..cf6f2a4 --- /dev/null +++ b/src/controllers/AddressesController.php @@ -0,0 +1,50 @@ +get(GetAddresses::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionGet(int $id): Response + { + $tool = \Craft::$container->get(GetAddress::class); + return $this->callTool($tool, ['addressId' => $id], useQueryParams: true); + } + + public function actionCreate(): Response + { + $tool = \Craft::$container->get(CreateAddress::class); + return $this->callTool($tool); + } + + public function actionUpdate(int $id): Response + { + $tool = \Craft::$container->get(UpdateAddress::class); + return $this->callTool($tool, ['addressId' => $id]); + } + + public function actionDelete(int $id): Response + { + $tool = \Craft::$container->get(DeleteAddress::class); + return $this->callTool($tool, ['addressId' => $id]); + } + + public function actionFieldLayout(): Response + { + $tool = \Craft::$container->get(GetAddressFieldLayout::class); + return $this->callTool($tool, useQueryParams: true); + } +} diff --git a/src/controllers/OrdersController.php b/src/controllers/OrdersController.php new file mode 100644 index 0000000..25fbf8e --- /dev/null +++ b/src/controllers/OrdersController.php @@ -0,0 +1,36 @@ +get(GetOrder::class); + return $this->callTool($tool, ['orderId' => $id], useQueryParams: true); + } + + public function actionSearch(): Response + { + $tool = \Craft::$container->get(SearchOrders::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionUpdate(int $id): Response + { + $tool = \Craft::$container->get(UpdateOrder::class); + return $this->callTool($tool, ['orderId' => $id]); + } + + public function actionStatuses(): Response + { + $tool = \Craft::$container->get(GetOrderStatuses::class); + return $this->callTool($tool, useQueryParams: true); + } +} diff --git a/src/controllers/ProductsController.php b/src/controllers/ProductsController.php new file mode 100644 index 0000000..28c61f6 --- /dev/null +++ b/src/controllers/ProductsController.php @@ -0,0 +1,78 @@ +get(CreateProduct::class); + return $this->callTool($tool); + } + + public function actionGet(int $id): Response + { + $tool = \Craft::$container->get(GetProduct::class); + return $this->callTool($tool, ['productId' => $id], useQueryParams: true); + } + + public function actionSearch(): Response + { + $tool = \Craft::$container->get(GetProducts::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionUpdate(int $id): Response + { + $tool = \Craft::$container->get(UpdateProduct::class); + return $this->callTool($tool, ['productId' => $id]); + } + + public function actionDelete(int $id): Response + { + $tool = \Craft::$container->get(DeleteProduct::class); + return $this->callTool($tool, ['productId' => $id]); + } + + public function actionTypes(): Response + { + $tool = \Craft::$container->get(GetProductTypes::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionCreateType(): Response + { + $tool = \Craft::$container->get(CreateProductType::class); + return $this->callTool($tool); + } + + public function actionGetType(int $id): Response + { + $tool = \Craft::$container->get(GetProductType::class); + return $this->callTool($tool, ['productTypeId' => $id], useQueryParams: true); + } + + public function actionUpdateType(int $id): Response + { + $tool = \Craft::$container->get(UpdateProductType::class); + return $this->callTool($tool, ['productTypeId' => $id]); + } + + public function actionDeleteType(int $id): Response + { + $tool = \Craft::$container->get(DeleteProductType::class); + return $this->callTool($tool, ['productTypeId' => $id]); + } +} diff --git a/src/controllers/StoresController.php b/src/controllers/StoresController.php new file mode 100644 index 0000000..f716ba1 --- /dev/null +++ b/src/controllers/StoresController.php @@ -0,0 +1,29 @@ +get(GetStores::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionGet(int $id): Response + { + $tool = \Craft::$container->get(GetStore::class); + return $this->callTool($tool, ['storeId' => $id], useQueryParams: true); + } + + public function actionUpdate(int $id): Response + { + $tool = \Craft::$container->get(UpdateStore::class); + return $this->callTool($tool, ['storeId' => $id]); + } +} diff --git a/src/controllers/UserGroupsController.php b/src/controllers/UserGroupsController.php new file mode 100644 index 0000000..5c86b80 --- /dev/null +++ b/src/controllers/UserGroupsController.php @@ -0,0 +1,43 @@ +get(GetUserGroups::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionGet(?int $id = null): Response + { + $tool = \Craft::$container->get(GetUserGroup::class); + return $this->callTool($tool, ['groupId' => $id], useQueryParams: true); + } + + public function actionCreate(): Response + { + $tool = \Craft::$container->get(CreateUserGroup::class); + return $this->callTool($tool); + } + + public function actionUpdate(?int $id = null): Response + { + $tool = \Craft::$container->get(UpdateUserGroup::class); + return $this->callTool($tool, ['groupId' => $id]); + } + + public function actionDelete(?int $id = null): Response + { + $tool = \Craft::$container->get(DeleteUserGroup::class); + return $this->callTool($tool, ['groupId' => $id]); + } +} diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php new file mode 100644 index 0000000..ad70cd9 --- /dev/null +++ b/src/controllers/UsersController.php @@ -0,0 +1,57 @@ +get(GetUsers::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionGet(?int $id = null): Response + { + $tool = \Craft::$container->get(GetUser::class); + return $this->callTool($tool, ['userId' => $id], useQueryParams: true); + } + + public function actionCreate(): Response + { + $tool = \Craft::$container->get(CreateUser::class); + return $this->callTool($tool); + } + + public function actionPermissions(): Response + { + $tool = \Craft::$container->get(GetAvailablePermissions::class); + return $this->callTool($tool, useQueryParams: true); + } + + public function actionUpdate(?int $id = null): Response + { + $tool = \Craft::$container->get(UpdateUser::class); + return $this->callTool($tool, ['userId' => $id]); + } + + public function actionDelete(?int $id = null): Response + { + $tool = \Craft::$container->get(DeleteUser::class); + return $this->callTool($tool, ['userId' => $id]); + } + + public function actionFieldLayout(): Response + { + $tool = \Craft::$container->get(GetUserFieldLayout::class); + return $this->callTool($tool, useQueryParams: true); + } +} diff --git a/src/controllers/VariantsController.php b/src/controllers/VariantsController.php new file mode 100644 index 0000000..aaf6dd0 --- /dev/null +++ b/src/controllers/VariantsController.php @@ -0,0 +1,36 @@ +get(CreateVariant::class); + return $this->callTool($tool); + } + + public function actionGet(int $id): Response + { + $tool = \Craft::$container->get(GetVariant::class); + return $this->callTool($tool, ['variantId' => $id], useQueryParams: true); + } + + public function actionUpdate(int $id): Response + { + $tool = \Craft::$container->get(UpdateVariant::class); + return $this->callTool($tool, ['variantId' => $id]); + } + + public function actionDelete(int $id): Response + { + $tool = \Craft::$container->get(DeleteVariant::class); + return $this->callTool($tool, ['variantId' => $id]); + } +} diff --git a/src/tools/AddFieldToFieldLayout.php b/src/tools/AddFieldToFieldLayout.php index 755ac8b..2c3e321 100644 --- a/src/tools/AddFieldToFieldLayout.php +++ b/src/tools/AddFieldToFieldLayout.php @@ -8,6 +8,8 @@ use craft\models\FieldLayout; use craft\models\FieldLayoutTab; use craft\services\Fields; +use happycog\craftmcp\actions\ResolveFieldLayout; +use happycog\craftmcp\actions\SaveFieldLayout; use happycog\craftmcp\exceptions\ModelSaveException; class AddFieldToFieldLayout @@ -15,6 +17,8 @@ class AddFieldToFieldLayout public function __construct( protected Fields $fieldsService, protected GetFieldLayout $getFieldLayout, + protected ResolveFieldLayout $resolveFieldLayout, + protected SaveFieldLayout $saveFieldLayout, ) { } @@ -62,7 +66,7 @@ public function __invoke( /** Field warning text */ ?string $warning = null, ): array { - $fieldLayout = $this->fieldsService->getLayoutById($fieldLayoutId); + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId); throw_unless($fieldLayout instanceof FieldLayout, "Field layout with ID {$fieldLayoutId} not found"); $field = $this->fieldsService->getFieldById($fieldId); @@ -139,7 +143,7 @@ public function __invoke( } $fieldLayout->setTabs($tabs); - throw_unless($this->fieldsService->saveLayout($fieldLayout), ModelSaveException::class, $fieldLayout); + throw_unless(($this->saveFieldLayout)($fieldLayout), ModelSaveException::class, $fieldLayout); return [ '_notes' => ['Field added successfully', 'Review the field layout in the control panel'], diff --git a/src/tools/AddTabToFieldLayout.php b/src/tools/AddTabToFieldLayout.php index 1f81701..098d6a5 100644 --- a/src/tools/AddTabToFieldLayout.php +++ b/src/tools/AddTabToFieldLayout.php @@ -6,13 +6,23 @@ use craft\models\FieldLayout; use craft\models\FieldLayoutTab; use craft\services\Fields; +use happycog\craftmcp\actions\NormalizeAddressFieldLayoutForSave; +use happycog\craftmcp\actions\NormalizeUserFieldLayoutForSave; +use happycog\craftmcp\actions\ResolveFieldLayout; +use happycog\craftmcp\actions\SaveFieldLayout; use happycog\craftmcp\exceptions\ModelSaveException; +use happycog\craftmcp\tools\GetAddressFieldLayout; +use happycog\craftmcp\tools\GetUserFieldLayout; class AddTabToFieldLayout { public function __construct( protected Fields $fieldsService, protected GetFieldLayout $getFieldLayout, + protected NormalizeAddressFieldLayoutForSave $normalizeAddressFieldLayoutForSave, + protected NormalizeUserFieldLayoutForSave $normalizeUserFieldLayoutForSave, + protected ResolveFieldLayout $resolveFieldLayout, + protected SaveFieldLayout $saveFieldLayout, ) { } @@ -39,7 +49,7 @@ public function __invoke( */ array $position, ): array { - $fieldLayout = $this->fieldsService->getLayoutById($fieldLayoutId); + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId); throw_unless($fieldLayout instanceof FieldLayout, "Field layout with ID {$fieldLayoutId} not found"); $positionType = $position['type'] ?? null; @@ -101,7 +111,16 @@ public function __invoke( } $fieldLayout->setTabs($newTabs); - throw_unless($this->fieldsService->saveLayout($fieldLayout), ModelSaveException::class, $fieldLayout); + + $fieldLayoutToSave = match ($fieldLayoutId) { + GetAddressFieldLayout::PLACEHOLDER_ID => ($this->normalizeAddressFieldLayoutForSave)($fieldLayout), + GetUserFieldLayout::PLACEHOLDER_ID => ($this->normalizeUserFieldLayoutForSave)($fieldLayout), + default => $fieldLayout, + }; + + throw_unless(($this->saveFieldLayout)($fieldLayoutToSave), ModelSaveException::class, $fieldLayoutToSave); + + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId) ?? $fieldLayout; return [ '_notes' => ['Tab added successfully', 'Review the field layout in the control panel'], diff --git a/src/tools/AddUiElementToFieldLayout.php b/src/tools/AddUiElementToFieldLayout.php index 425178d..23c93fe 100644 --- a/src/tools/AddUiElementToFieldLayout.php +++ b/src/tools/AddUiElementToFieldLayout.php @@ -3,6 +3,12 @@ namespace happycog\craftmcp\tools; use craft\base\FieldLayoutElement; +use craft\fieldlayoutelements\addresses\AddressField as AddressLayoutField; +use craft\fieldlayoutelements\addresses\CountryCodeField; +use craft\fieldlayoutelements\addresses\LabelField; +use craft\fieldlayoutelements\addresses\LatLongField; +use craft\fieldlayoutelements\addresses\OrganizationField; +use craft\fieldlayoutelements\addresses\OrganizationTaxIdField; use craft\fieldlayoutelements\entries\EntryTitleField; use craft\fieldlayoutelements\Heading; use craft\fieldlayoutelements\HorizontalRule; @@ -10,10 +16,17 @@ use craft\fieldlayoutelements\Markdown; use craft\fieldlayoutelements\Template; use craft\fieldlayoutelements\Tip; +use craft\helpers\StringHelper; use craft\models\FieldLayout; use craft\services\Fields; use happycog\craftmcp\actions\ManageEntryTitleField; +use happycog\craftmcp\actions\NormalizeAddressFieldLayoutForSave; +use happycog\craftmcp\actions\NormalizeUserFieldLayoutForSave; +use happycog\craftmcp\actions\ResolveFieldLayout; +use happycog\craftmcp\actions\SaveFieldLayout; use happycog\craftmcp\exceptions\ModelSaveException; +use happycog\craftmcp\tools\GetAddressFieldLayout; +use happycog\craftmcp\tools\GetUserFieldLayout; class AddUiElementToFieldLayout { @@ -21,6 +34,10 @@ public function __construct( protected Fields $fieldsService, protected GetFieldLayout $getFieldLayout, protected ManageEntryTitleField $manageEntryTitleField, + protected NormalizeAddressFieldLayoutForSave $normalizeAddressFieldLayoutForSave, + protected NormalizeUserFieldLayoutForSave $normalizeUserFieldLayoutForSave, + protected ResolveFieldLayout $resolveFieldLayout, + protected SaveFieldLayout $saveFieldLayout, ) { } @@ -50,6 +67,12 @@ public function __invoke( * - craft\fieldlayoutelements\Template * - craft\fieldlayoutelements\HorizontalRule * - craft\fieldlayoutelements\LineBreak + * - craft\fieldlayoutelements\addresses\LabelField + * - craft\fieldlayoutelements\addresses\CountryCodeField + * - craft\fieldlayoutelements\addresses\AddressField + * - craft\fieldlayoutelements\addresses\OrganizationField + * - craft\fieldlayoutelements\addresses\OrganizationTaxIdField + * - craft\fieldlayoutelements\addresses\LatLongField */ string $elementType, @@ -78,7 +101,7 @@ public function __invoke( */ array $config = [], ): array { - $fieldLayout = $this->fieldsService->getLayoutById($fieldLayoutId); + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId); throw_unless($fieldLayout instanceof FieldLayout, "Field layout with ID {$fieldLayoutId} not found"); $validTypes = [ @@ -89,6 +112,12 @@ public function __invoke( Template::class, HorizontalRule::class, LineBreak::class, + LabelField::class, + CountryCodeField::class, + AddressLayoutField::class, + OrganizationField::class, + OrganizationTaxIdField::class, + LatLongField::class, ]; throw_unless(in_array($elementType, $validTypes, true), "Invalid element type '{$elementType}'"); @@ -115,9 +144,11 @@ public function __invoke( } } throw_unless($targetTab !== null, "Tab with name '{$tabName}' not found. Create the tab first using add_tab_to_field_layout"); + $existingElementUids = array_map(fn(FieldLayoutElement $element) => $element->uid, $targetTab->getElements()); /** @var FieldLayoutElement $newElement */ $newElement = new $elementType(); + $newElement->uid ??= StringHelper::UUID(); $this->applyConfig($newElement, $elementType, $config); $width !== null && $newElement->width = $width; @@ -162,7 +193,13 @@ public function __invoke( } $fieldLayout->setTabs($tabs); - throw_unless($this->fieldsService->saveLayout($fieldLayout), ModelSaveException::class, $fieldLayout); + $fieldLayoutToSave = match ($fieldLayoutId) { + GetAddressFieldLayout::PLACEHOLDER_ID => ($this->normalizeAddressFieldLayoutForSave)($fieldLayout), + GetUserFieldLayout::PLACEHOLDER_ID => ($this->normalizeUserFieldLayoutForSave)($fieldLayout), + default => $fieldLayout, + }; + + throw_unless(($this->saveFieldLayout)($fieldLayoutToSave), ModelSaveException::class, $fieldLayoutToSave); $notes = ['UI element added successfully']; @@ -175,11 +212,32 @@ public function __invoke( $notes[] = 'Review the field layout in the control panel'; + $refreshedFieldLayout = ($this->resolveFieldLayout)($fieldLayoutId) ?? $fieldLayoutToSave; + $refreshedTab = null; + foreach ($refreshedFieldLayout->getTabs() as $tab) { + if ($tab->name === $tabName) { + $refreshedTab = $tab; + break; + } + } + + $persistedElement = null; + if ($refreshedTab !== null) { + foreach ($refreshedTab->getElements() as $element) { + if (!in_array($element->uid, $existingElementUids, true)) { + $persistedElement = $element; + break; + } + } + } + + $persistedElement ??= $newElement; + return [ '_notes' => $notes, - 'fieldLayout' => $this->getFieldLayout->formatFieldLayout($fieldLayout), + 'fieldLayout' => $this->getFieldLayout->formatFieldLayout($refreshedFieldLayout), 'addedElement' => [ - 'uid' => $newElement->uid, + 'uid' => $persistedElement->uid, 'type' => $elementType, ], ]; diff --git a/src/tools/CreateAddress.php b/src/tools/CreateAddress.php new file mode 100644 index 0000000..1068c25 --- /dev/null +++ b/src/tools/CreateAddress.php @@ -0,0 +1,175 @@ + $fields + * @return array + */ + public function __invoke( + int $ownerId, + string $ownerType, + ?int $fieldId = null, + ?string $fieldHandle = null, + ?string $title = null, + ?string $fullName = null, + ?string $firstName = null, + ?string $lastName = null, + ?string $countryCode = null, + ?string $administrativeArea = null, + ?string $locality = null, + ?string $dependentLocality = null, + ?string $postalCode = null, + ?string $sortingCode = null, + ?string $addressLine1 = null, + ?string $addressLine2 = null, + ?string $addressLine3 = null, + ?string $organization = null, + ?string $organizationTaxId = null, + ?string $latitude = null, + ?string $longitude = null, + array $fields = [], + ): array { + [$owner] = ($this->resolveElementOwner)($ownerId, $ownerType); + $field = $this->resolveAddressesField($fieldId, $fieldHandle, $owner); + + $address = new Address(); + $address->setOwner($owner); + $address->setPrimaryOwner($owner); + $address->siteId = $owner->siteId; + $address->setScenario(Address::SCENARIO_LIVE); + $address->fieldId = is_int($field?->id) ? $field->id : null; + + $this->applyAddressAttributes( + address: $address, + title: $title, + fullName: $fullName, + firstName: $firstName, + lastName: $lastName, + countryCode: $countryCode, + administrativeArea: $administrativeArea, + locality: $locality, + dependentLocality: $dependentLocality, + postalCode: $postalCode, + sortingCode: $sortingCode, + addressLine1: $addressLine1, + addressLine2: $addressLine2, + addressLine3: $addressLine3, + organization: $organization, + organizationTaxId: $organizationTaxId, + latitude: $latitude, + longitude: $longitude, + fields: $fields, + ); + + throw_unless( + \Craft::$app->getElements()->saveElement($address), + 'Failed to save address: ' . implode(', ', $address->getFirstErrors()), + ); + + return [ + '_notes' => 'The address was successfully created.', + ...($this->formatAddress)($address), + ]; + } + + private function resolveAddressesField(?int $fieldId, ?string $fieldHandle, ElementInterface $owner): ?AddressesField + { + if ($fieldId === null && $fieldHandle === null) { + return null; + } + + $field = null; + if ($fieldId !== null) { + $field = \Craft::$app->getFields()->getFieldById($fieldId); + } elseif ($fieldHandle !== null) { + $field = \Craft::$app->getFields()->getFieldByHandle($fieldHandle); + } + + $fieldIdentifier = $fieldId !== null ? "ID {$fieldId}" : "handle '{$fieldHandle}'"; + throw_unless($field instanceof AddressesField, \InvalidArgumentException::class, "Addresses field with {$fieldIdentifier} not found"); + + $resolvedFieldId = $field->id; + throw_unless(is_int($resolvedFieldId), \RuntimeException::class, 'Addresses field ID is missing.'); + + $ownerField = $owner->getFieldLayout()?->getFieldById($resolvedFieldId); + $ownerIdentifier = $owner::class . '#' . $owner->id; + throw_unless($ownerField instanceof AddressesField, \InvalidArgumentException::class, "Field {$field->handle} is not attached to owner {$ownerIdentifier}"); + + return $field; + } + + /** + * @param array $fields + */ + private function applyAddressAttributes( + Address $address, + ?string $title, + ?string $fullName, + ?string $firstName, + ?string $lastName, + ?string $countryCode, + ?string $administrativeArea, + ?string $locality, + ?string $dependentLocality, + ?string $postalCode, + ?string $sortingCode, + ?string $addressLine1, + ?string $addressLine2, + ?string $addressLine3, + ?string $organization, + ?string $organizationTaxId, + ?string $latitude, + ?string $longitude, + array $fields, + ): void { + $attributes = [ + 'title' => $title, + 'fullName' => $fullName, + 'firstName' => $firstName, + 'lastName' => $lastName, + 'countryCode' => $countryCode, + 'administrativeArea' => $administrativeArea, + 'locality' => $locality, + 'dependentLocality' => $dependentLocality, + 'postalCode' => $postalCode, + 'sortingCode' => $sortingCode, + 'addressLine1' => $addressLine1, + 'addressLine2' => $addressLine2, + 'addressLine3' => $addressLine3, + 'organization' => $organization, + 'organizationTaxId' => $organizationTaxId, + 'latitude' => $latitude, + 'longitude' => $longitude, + ]; + + $address->setAttributes(array_filter($attributes, fn(mixed $value) => $value !== null)); + + if ($fields !== []) { + $address->setFieldValues($fields); + } + } +} diff --git a/src/tools/CreateProduct.php b/src/tools/CreateProduct.php new file mode 100644 index 0000000..46452a0 --- /dev/null +++ b/src/tools/CreateProduct.php @@ -0,0 +1,106 @@ + $fields Custom field data keyed by field handle. + * @return array + */ + public function __invoke( + /** The product type ID. Use GetProductTypes to discover available types. */ + int $typeId, + + /** Product title. */ + string $title, + + /** SKU for the default variant. */ + string $sku, + + /** Price for the default variant. */ + float $price, + + /** Product slug. Auto-generated from title if not provided. */ + ?string $slug = null, + + /** Post date in ISO 8601 format. Defaults to now. */ + ?string $postDate = null, + + /** Expiry date in ISO 8601 format. Null means no expiry. */ + ?string $expiryDate = null, + + /** Whether the product is enabled. Default: true. */ + bool $enabled = true, + + /** Custom field data keyed by field handle. */ + array $fields = [], + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $productType = $commerce->getProductTypes()->getProductTypeById($typeId); + throw_unless($productType instanceof ProductType, \InvalidArgumentException::class, "Product type with ID {$typeId} not found"); + + $product = new Product(); + $product->typeId = $typeId; + $product->title = $title; + $product->enabled = $enabled; + + if ($slug !== null) { + $product->slug = $slug; + } + if ($postDate !== null) { + $product->postDate = new \DateTime($postDate); + } + if ($expiryDate !== null) { + $product->expiryDate = new \DateTime($expiryDate); + } + if (!empty($fields)) { + $product->setFieldValues($fields); + } + + // Create the default variant + $variant = new Variant(); + $variant->sku = $sku; + $variant->basePrice = $price; + $variant->isDefault = true; + + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + throw_unless( + Craft::$app->getElements()->saveElement($product), + "Failed to save product: " . implode(', ', $product->getFirstErrors()), + ); + + return [ + '_notes' => 'The product was successfully created.', + 'productId' => $product->id, + 'title' => $product->title, + 'slug' => $product->slug, + 'status' => $product->getStatus(), + 'typeId' => $product->typeId, + 'typeName' => $productType->name, + 'defaultSku' => $product->defaultSku, + 'defaultPrice' => $product->defaultPrice, + 'url' => ElementHelper::elementEditorUrl($product), + ]; + } +} diff --git a/src/tools/CreateProductType.php b/src/tools/CreateProductType.php new file mode 100644 index 0000000..96f436c --- /dev/null +++ b/src/tools/CreateProductType.php @@ -0,0 +1,285 @@ +|null $siteSettings + * @return array + */ + public function __invoke( + /** The display name for the product type */ + string $name, + + /** The product type handle (machine-readable name). Auto-generated from name if not provided. */ + ?string $handle = null, + + /** Whether products have a title field. If false, productTitleFormat is required. */ + bool $hasProductTitleField = true, + + /** Auto-generated title format for products when hasProductTitleField is false. */ + ?string $productTitleFormat = null, + + /** How product titles are translated: none, site, language, or custom. */ + string $productTitleTranslationMethod = 'site', + + /** Translation key format for custom product title translation. */ + ?string $productTitleTranslationKeyFormat = null, + + /** Whether variants have a title field. If false, variantTitleFormat is required. */ + bool $hasVariantTitleField = true, + + /** Auto-generated title format for variants when hasVariantTitleField is false. */ + ?string $variantTitleFormat = null, + + /** How variant titles are translated: none, site, language, or custom. */ + string $variantTitleTranslationMethod = 'site', + + /** Translation key format for custom variant title translation. */ + ?string $variantTitleTranslationKeyFormat = null, + + /** Whether to show the slug field in the admin UI. */ + bool $showSlugField = true, + + /** How slugs are translated: none, site, language, or custom. */ + string $slugTranslationMethod = 'site', + + /** Translation key format for custom slug translation. */ + ?string $slugTranslationKeyFormat = null, + + /** SKU format pattern. If set, SKUs are auto-generated (e.g., "{product.slug}"). */ + ?string $skuFormat = null, + + /** Description format for the variant description (e.g., "{product.title} - {title}"). */ + string $descriptionFormat = '{product.title} - {title}', + + /** Product page template path (e.g., "shop/products/_product"). */ + ?string $template = null, + + /** Whether products of this type track dimensions (weight, height, length, width). */ + bool $hasDimensions = false, + + /** Maximum number of variants per product. Null for unlimited. */ + ?int $maxVariants = null, + + /** Whether to enable entry versioning for products. */ + bool $enableVersioning = false, + + /** Whether products use a hierarchical structure (like structure sections). */ + bool $isStructure = false, + + /** Maximum hierarchy levels (only for structure product types). Null for unlimited. */ + ?int $maxLevels = null, + + /** Where new products are placed by default (only for structure product types): "beginning" or "end". */ + string $defaultPlacement = 'end', + + /** Field layout ID for product-level fields. Create one with field-layouts/create first. */ + ?int $fieldLayoutId = null, + + /** Field layout ID for variant-level fields. Create one with field-layouts/create first. */ + ?int $variantFieldLayoutId = null, + + /** + * Site-specific settings. If not provided, product type will be enabled for all sites. + * Each array entry contains: + * - siteId: Site ID (required) + * - enabledByDefault: Enable products by default for this site (optional, default true) + * - hasUrls: Whether products have URLs on this site (optional, default false) + * - uriFormat: URI format pattern, e.g., "shop/products/{slug}" (optional) + * - template: Template path for rendering products (optional) + */ + ?array $siteSettings = null, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + // Validate title format requirements + throw_if( + !$hasProductTitleField && empty($productTitleFormat), + \InvalidArgumentException::class, + "If 'hasProductTitleField' is false, 'productTitleFormat' must be set to define how product titles are automatically generated.", + ); + throw_if( + !$hasVariantTitleField && empty($variantTitleFormat), + \InvalidArgumentException::class, + "If 'hasVariantTitleField' is false, 'variantTitleFormat' must be set to define how variant titles are automatically generated.", + ); + + // Auto-generate handle if not provided + $handle ??= StringHelper::toHandle($name); + + $productTitleTranslationMethod = $this->getTranslationMethodConstant($productTitleTranslationMethod); + $variantTitleTranslationMethod = $this->getTranslationMethodConstant($variantTitleTranslationMethod); + $slugTranslationMethod = $this->getTranslationMethodConstant($slugTranslationMethod); + $defaultPlacement = $this->getDefaultPlacement($defaultPlacement); + + // Default variantTitleFormat when variant has a title field + $variantTitleFormat ??= '{product.title}'; + + // Create the product type + $productType = new ProductType(); + $productType->name = $name; + $productType->handle = $handle; + $productType->hasProductTitleField = $hasProductTitleField; + $productType->productTitleFormat = $productTitleFormat ?? ''; + $productType->productTitleTranslationMethod = $productTitleTranslationMethod; + $productType->productTitleTranslationKeyFormat = $productTitleTranslationKeyFormat; + $productType->hasVariantTitleField = $hasVariantTitleField; + $productType->variantTitleFormat = $variantTitleFormat; + $productType->variantTitleTranslationMethod = $variantTitleTranslationMethod; + $productType->variantTitleTranslationKeyFormat = $variantTitleTranslationKeyFormat; + $productType->showSlugField = $showSlugField; + $productType->slugTranslationMethod = $slugTranslationMethod; + $productType->slugTranslationKeyFormat = $slugTranslationKeyFormat; + $productType->skuFormat = $skuFormat; + $productType->descriptionFormat = $descriptionFormat; + $productType->template = $template; + $productType->hasDimensions = $hasDimensions; + $productType->maxVariants = $maxVariants; + $productType->enableVersioning = $enableVersioning; + $productType->isStructure = $isStructure; + $productType->defaultPlacement = $defaultPlacement; + + // Set structure-specific properties + if ($isStructure && $maxLevels !== null && $maxLevels > 0) { + $productType->maxLevels = $maxLevels; + } + + // Set field layouts if provided + if ($fieldLayoutId !== null) { + $fieldLayout = Craft::$app->getFields()->getLayoutById($fieldLayoutId); + throw_unless($fieldLayout, \InvalidArgumentException::class, "Field layout with ID {$fieldLayoutId} not found"); + $productType->fieldLayoutId = $fieldLayoutId; + } + + if ($variantFieldLayoutId !== null) { + $variantFieldLayout = Craft::$app->getFields()->getLayoutById($variantFieldLayoutId); + throw_unless($variantFieldLayout, \InvalidArgumentException::class, "Variant field layout with ID {$variantFieldLayoutId} not found"); + $productType->variantFieldLayoutId = $variantFieldLayoutId; + } + + // Configure site settings + $siteSettingsObjects = []; + + if ($siteSettings !== null) { + foreach ($siteSettings as $siteData) { + $siteId = $siteData['siteId']; + throw_unless(is_int($siteId), 'siteId must be an integer'); + + $site = Craft::$app->getSites()->getSiteById($siteId); + throw_unless($site, "Site with ID {$siteId} not found"); + + $siteSettingsObjects[$siteId] = new ProductTypeSite([ + 'siteId' => $siteId, + 'enabledByDefault' => $siteData['enabledByDefault'] ?? true, + 'hasUrls' => $siteData['hasUrls'] ?? false, + 'uriFormat' => $siteData['uriFormat'] ?? null, + 'template' => $siteData['template'] ?? null, + ]); + } + } else { + // Default: enable for all sites + foreach (Craft::$app->getSites()->getAllSites() as $site) { + $siteSettingsObjects[$site->id] = new ProductTypeSite([ + 'siteId' => $site->id, + 'enabledByDefault' => true, + 'hasUrls' => false, + ]); + } + } + + $productType->setSiteSettings($siteSettingsObjects); + + // Save the product type + throw_unless( + $commerce->getProductTypes()->saveProductType($productType), + ModelSaveException::class, + $productType, + ); + + return [ + '_notes' => 'The product type was successfully created. You can further configure it in the Craft control panel.', + 'id' => $productType->id, + 'name' => $productType->name, + 'handle' => $productType->handle, + 'fieldLayoutId' => $productType->fieldLayoutId, + 'variantFieldLayoutId' => $productType->variantFieldLayoutId, + 'hasProductTitleField' => $productType->hasProductTitleField, + 'productTitleFormat' => $productType->productTitleFormat, + 'hasVariantTitleField' => $productType->hasVariantTitleField, + 'variantTitleFormat' => $productType->variantTitleFormat, + 'skuFormat' => $productType->skuFormat, + 'hasDimensions' => $productType->hasDimensions, + 'maxVariants' => $productType->maxVariants, + 'enableVersioning' => $productType->enableVersioning, + 'editUrl' => $productType->getCpEditUrl(), + 'editVariantUrl' => $productType->getCpEditVariantUrl(), + ]; + } + + /** + * @return 'custom'|'language'|'none'|'site'|'siteGroup' + */ + private function getTranslationMethodConstant(string $method): string + { + $methodMap = [ + 'none' => \craft\base\Field::TRANSLATION_METHOD_NONE, + 'site' => \craft\base\Field::TRANSLATION_METHOD_SITE, + 'siteGroup' => \craft\base\Field::TRANSLATION_METHOD_SITE_GROUP, + 'language' => \craft\base\Field::TRANSLATION_METHOD_LANGUAGE, + 'custom' => \craft\base\Field::TRANSLATION_METHOD_CUSTOM, + ]; + + throw_unless( + isset($methodMap[$method]), + \InvalidArgumentException::class, + "Invalid translation method '{$method}'. Must be one of: " . implode(', ', array_keys($methodMap)), + ); + + return $methodMap[$method]; + } + + /** + * @return 'beginning'|'end' + */ + private function getDefaultPlacement(string $defaultPlacement): string + { + throw_unless( + in_array($defaultPlacement, ['beginning', 'end'], true), + \InvalidArgumentException::class, + 'defaultPlacement must be "beginning" or "end"', + ); + + return $defaultPlacement; + } +} diff --git a/src/tools/CreateUser.php b/src/tools/CreateUser.php new file mode 100644 index 0000000..dd15e6f --- /dev/null +++ b/src/tools/CreateUser.php @@ -0,0 +1,128 @@ + $fields + * @param list|null $groupIds + * @param list|null $groupHandles + * @param list|null $permissions + * @return array + */ + public function __invoke( + /** User email address. */ + string $email, + + /** Username to assign. Defaults to the email address. */ + ?string $username = null, + + /** Initial password for the user. */ + ?string $newPassword = null, + + /** Full name value. */ + ?string $fullName = null, + + /** First name value. */ + ?string $firstName = null, + + /** Last name value. */ + ?string $lastName = null, + + /** Whether the user should be an admin. */ + bool $admin = false, + + /** Whether the user should be active. */ + bool $active = true, + + /** Whether the user should be pending. */ + bool $pending = false, + + /** Whether the user should be suspended. */ + bool $suspended = false, + + /** Whether the user should be locked. */ + bool $locked = false, + + /** Affiliated site ID for the user. */ + ?int $affiliatedSiteId = null, + + /** User group IDs to assign. Requires Craft Team or Pro. */ + ?array $groupIds = null, + + /** User group handles to assign. Requires Craft Team or Pro. */ + ?array $groupHandles = null, + + /** Direct user permissions to assign. Requires Craft Pro. Custom names are allowed. */ + ?array $permissions = null, + + /** Custom field values from the global user field layout, keyed by field handle. */ + array $fields = [], + ): array { + throw_unless($this->usersService->canCreateUsers(), \InvalidArgumentException::class, 'The current Craft edition has reached its user limit, so an additional user cannot be created.'); + + $user = new User(); + $user->email = $email; + $user->username = $username ?? $email; + $user->admin = $admin; + $user->active = $active; + $user->pending = $pending; + $user->suspended = $suspended; + $user->locked = $locked; + $user->affiliatedSiteId = $affiliatedSiteId; + $user->fullName = $fullName; + $user->firstName = $firstName; + $user->lastName = $lastName; + + if ($newPassword !== null) { + $user->newPassword = $newPassword; + $user->setScenario(User::SCENARIO_REGISTRATION); + } + + if ($fields !== []) { + $user->setFieldValues($fields); + } + + throw_unless($this->elementsService->saveElement($user, false), 'Failed to save user: ' . implode(', ', $user->getFirstErrors())); + + $resolvedGroupIds = ($this->resolveUserGroupIds)($groupIds, $groupHandles); + if ($resolvedGroupIds !== []) { + throw_unless(Craft::$app->edition->value >= CmsEdition::Team->value, \InvalidArgumentException::class, 'Assigning users to groups requires Craft Team or Craft Pro.'); + throw_unless($this->usersService->assignUserToGroups((int) $user->id, $resolvedGroupIds), 'Failed to assign user groups.'); + } + + if ($permissions !== null && $user->id !== null) { + throw_unless(Craft::$app->edition->value >= CmsEdition::Pro->value, \InvalidArgumentException::class, 'Assigning direct user permissions requires Craft Pro.'); + throw_unless(($this->saveUserPermissions)($user->id, $permissions), 'Failed to save user permissions.'); + } + + return [ + '_notes' => 'The user was successfully created.', + ...($this->formatUser)($user), + ]; + } +} diff --git a/src/tools/CreateUserGroup.php b/src/tools/CreateUserGroup.php new file mode 100644 index 0000000..ef9cefa --- /dev/null +++ b/src/tools/CreateUserGroup.php @@ -0,0 +1,62 @@ +|null $permissions + * @return array + */ + public function __invoke( + /** Human-readable group name. */ + string $name, + + /** Machine-readable group handle. Auto-generated from the name if omitted. */ + ?string $handle = null, + + /** Optional group description. */ + ?string $description = null, + + /** Permissions to assign, including custom permission names. */ + ?array $permissions = null, + ): array { + throw_unless(Craft::$app->edition->value >= CmsEdition::Pro->value, \InvalidArgumentException::class, 'Managing user groups requires Craft Pro.'); + + $group = new UserGroup(); + $group->name = $name; + $group->handle = $handle ?? StringHelper::toHandle($name); + $group->description = $description; + + throw_unless($this->userGroupsService->saveGroup($group), 'Failed to save user group: ' . implode(', ', $group->getFirstErrors())); + + if ($permissions !== null) { + throw_unless(($this->saveUserGroupPermissions)($group, $permissions), 'Failed to save group permissions.'); + } + + return [ + '_notes' => 'The user group was successfully created.', + ...($this->formatUserGroup)($group), + ]; + } +} diff --git a/src/tools/CreateVariant.php b/src/tools/CreateVariant.php new file mode 100644 index 0000000..2149cd0 --- /dev/null +++ b/src/tools/CreateVariant.php @@ -0,0 +1,136 @@ + 1) for this + * to work — single-variant product types already have a default variant. + * + * After creating the variant, link the user to the parent product in the Craft control + * panel so they can review it. + * + * @param array $fields Custom field data keyed by field handle. + * @return array + */ + public function __invoke( + /** The parent product ID. */ + int $productId, + + /** Variant SKU. Must be unique. */ + string $sku, + + /** Variant price. */ + float $price, + + /** Variant title. */ + ?string $title = null, + + /** Minimum purchase quantity. */ + ?int $minQty = null, + + /** Maximum purchase quantity. */ + ?int $maxQty = null, + + /** Variant weight. */ + ?float $weight = null, + + /** Variant height. */ + ?float $height = null, + + /** Variant length. */ + ?float $length = null, + + /** Variant width. */ + ?float $width = null, + + /** Whether the variant qualifies for free shipping. */ + ?bool $freeShipping = null, + + /** Whether inventory is tracked for this variant. */ + ?bool $inventoryTracked = null, + + /** Custom field data keyed by field handle. */ + array $fields = [], + ): array { + $product = Craft::$app->getElements()->getElementById($productId, Product::class); + + throw_unless($product instanceof Product, \InvalidArgumentException::class, "Product with ID {$productId} not found"); + + $variant = new Variant(); + $variant->sku = $sku; + $variant->basePrice = $price; + + if ($title !== null) { + $variant->title = $title; + } + if ($minQty !== null) { + $variant->minQty = $minQty; + } + if ($maxQty !== null) { + $variant->maxQty = $maxQty; + } + if ($weight !== null) { + $variant->weight = $weight; + } + if ($height !== null) { + $variant->height = $height; + } + if ($length !== null) { + $variant->length = $length; + } + if ($width !== null) { + $variant->width = $width; + } + if ($freeShipping !== null) { + $variant->freeShipping = $freeShipping; + } + if ($inventoryTracked !== null) { + $variant->inventoryTracked = $inventoryTracked; + } + if (!empty($fields)) { + $variant->setFieldValues($fields); + } + + // Append the new variant to existing variants + $existingVariants = $product->getVariants()->all(); + $existingVariants[] = $variant; + $product->setVariants($existingVariants); + $product->setDirtyAttributes(['variants']); + + throw_unless( + Craft::$app->getElements()->saveElement($product), + "Failed to save product with new variant: " . implode(', ', $product->getFirstErrors()), + ); + + // Re-fetch to get the saved variant with its ID + $freshProduct = Craft::$app->getElements()->getElementById($productId, Product::class); + throw_unless($freshProduct instanceof Product, \RuntimeException::class, "Failed to reload product with ID {$productId} after creating variant"); + + $savedVariants = $freshProduct->getVariants()->all(); + $newVariant = end($savedVariants); + + throw_unless($newVariant instanceof Variant, \RuntimeException::class, 'Failed to determine the saved variant after creating it'); + + return [ + '_notes' => 'The variant was successfully created.', + 'variantId' => $newVariant->id, + 'title' => $newVariant->title, + 'sku' => $newVariant->sku, + 'price' => (float) $newVariant->price, + 'stock' => $newVariant->getStock(), + 'productId' => $freshProduct->id, + 'productTitle' => $freshProduct->title, + 'url' => ElementHelper::elementEditorUrl($freshProduct), + ]; + } +} diff --git a/src/tools/DeleteAddress.php b/src/tools/DeleteAddress.php new file mode 100644 index 0000000..25c7b84 --- /dev/null +++ b/src/tools/DeleteAddress.php @@ -0,0 +1,46 @@ + + */ + public function __invoke( + int $addressId, + bool $permanentlyDelete = false, + ): array { + $address = \Craft::$app->getElements()->getElementById($addressId, Address::class, null, [ + 'siteId' => '*', + ]); + + throw_unless($address instanceof Address, \InvalidArgumentException::class, "Address with ID {$addressId} not found"); + + $response = [ + '_notes' => 'The address was successfully deleted.', + ...($this->formatAddress)($address), + 'deletedPermanently' => $permanentlyDelete, + ]; + + throw_unless( + \Craft::$app->getElements()->deleteElement($address, $permanentlyDelete), + "Failed to delete address with ID {$addressId}.", + ); + + return $response; + } +} diff --git a/src/tools/DeleteProduct.php b/src/tools/DeleteProduct.php new file mode 100644 index 0000000..c36f388 --- /dev/null +++ b/src/tools/DeleteProduct.php @@ -0,0 +1,48 @@ + + */ + public function __invoke( + int $productId, + + /** Set to true to permanently delete the product. Default is false (soft delete). */ + bool $permanentlyDelete = false, + ): array { + $product = Craft::$app->getElements()->getElementById($productId, Product::class); + + throw_unless($product instanceof Product, \InvalidArgumentException::class, "Product with ID {$productId} not found"); + + $productType = $product->getType(); + $productInfo = [ + '_notes' => 'The product was successfully deleted.', + 'productId' => $product->id, + 'title' => $product->title, + 'slug' => $product->slug, + 'typeId' => $product->typeId, + 'typeName' => $productType->name, + 'deletedPermanently' => $permanentlyDelete, + ]; + + $elementsService = Craft::$app->getElements(); + throw_unless( + $elementsService->deleteElement($product, $permanentlyDelete), + "Failed to delete product with ID {$productId}.", + ); + + return $productInfo; + } +} diff --git a/src/tools/DeleteProductType.php b/src/tools/DeleteProductType.php new file mode 100644 index 0000000..84a0da6 --- /dev/null +++ b/src/tools/DeleteProductType.php @@ -0,0 +1,90 @@ + + */ + public function __invoke( + /** The ID of the product type to delete */ + int $productTypeId, + + /** Force deletion even if products exist (default: false) */ + bool $force = false, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $productType = $commerce->getProductTypes()->getProductTypeById($productTypeId); + + throw_unless( + $productType instanceof ProductType, + \InvalidArgumentException::class, + "Product type with ID {$productTypeId} not found", + ); + + // Analyze impact before deletion + $impact = $this->analyzeImpact($productType); + + // Check if force is required + if ($impact['hasContent'] && !$force) { + assert(is_int($impact['productCount']) || is_string($impact['productCount'])); + $productCount = (string) $impact['productCount']; + + throw new \RuntimeException( + "Product type '{$productType->name}' contains data and cannot be deleted without force=true.\n\n" . + "Impact Assessment:\n" . + "- Products: {$productCount}\n\n" . + "Set force=true to proceed with deletion. This action cannot be undone.", + ); + } + + // Store product type info for response + $productTypeInfo = [ + '_notes' => 'The product type was successfully deleted.', + 'id' => $productType->id, + 'name' => $productType->name, + 'handle' => $productType->handle, + 'impact' => $impact, + ]; + + // Delete the product type + throw_unless( + $commerce->getProductTypes()->deleteProductTypeById($productTypeId), + "Failed to delete product type with ID {$productTypeId}.", + ); + + return $productTypeInfo; + } + + /** + * @return array + */ + private function analyzeImpact(ProductType $productType): array + { + $productCount = Product::find() + ->typeId($productType->id) + ->status(null) + ->count(); + + return [ + 'hasContent' => $productCount > 0, + 'productCount' => $productCount, + ]; + } +} diff --git a/src/tools/DeleteUser.php b/src/tools/DeleteUser.php new file mode 100644 index 0000000..7ea97fe --- /dev/null +++ b/src/tools/DeleteUser.php @@ -0,0 +1,51 @@ + + */ + public function __invoke( + /** User ID to delete. */ + ?int $userId = null, + + /** Resolve the user by exact email address. */ + ?string $email = null, + + /** Resolve the user by exact username. */ + ?string $username = null, + + /** Permanently delete instead of soft deleting. */ + bool $permanentlyDelete = false, + ): array { + $user = ($this->resolveUser)($userId, $email, $username); + + $response = [ + '_notes' => 'The user was successfully deleted.', + ...($this->formatUser)($user), + 'deletedPermanently' => $permanentlyDelete, + ]; + + throw_unless($this->elementsService->deleteElement($user, $permanentlyDelete), 'Failed to delete user.'); + + return $response; + } +} diff --git a/src/tools/DeleteUserGroup.php b/src/tools/DeleteUserGroup.php new file mode 100644 index 0000000..6bf3703 --- /dev/null +++ b/src/tools/DeleteUserGroup.php @@ -0,0 +1,46 @@ + + */ + public function __invoke( + /** User group ID to delete. */ + ?int $groupId = null, + + /** User group handle to delete. */ + ?string $handle = null, + ): array + { + throw_unless(Craft::$app->edition->value >= CmsEdition::Pro->value, \InvalidArgumentException::class, 'Managing user groups requires Craft Pro.'); + + $group = ($this->resolveUserGroup)($groupId, $handle); + + $response = [ + '_notes' => 'The user group was successfully deleted.', + ...($this->formatUserGroup)($group), + ]; + + throw_unless(\Craft::$app->getUserGroups()->deleteGroup($group), 'Failed to delete user group.'); + + return $response; + } +} diff --git a/src/tools/DeleteVariant.php b/src/tools/DeleteVariant.php new file mode 100644 index 0000000..a9be8c1 --- /dev/null +++ b/src/tools/DeleteVariant.php @@ -0,0 +1,49 @@ + + */ + public function __invoke( + int $variantId, + + /** Set to true to permanently delete the variant. Default is false (soft delete). */ + bool $permanentlyDelete = false, + ): array { + $variant = Craft::$app->getElements()->getElementById($variantId, Variant::class); + + throw_unless($variant instanceof Variant, \InvalidArgumentException::class, "Variant with ID {$variantId} not found"); + + $product = $variant->getOwner(); + $variantInfo = [ + '_notes' => 'The variant was successfully deleted.', + 'variantId' => $variant->id, + 'title' => $variant->title, + 'sku' => $variant->sku, + 'productId' => $product instanceof Product ? $product->id : null, + 'productTitle' => $product instanceof Product ? $product->title : null, + 'deletedPermanently' => $permanentlyDelete, + ]; + + throw_unless( + Craft::$app->getElements()->deleteElement($variant, $permanentlyDelete), + "Failed to delete variant with ID {$variantId}.", + ); + + return $variantInfo; + } +} diff --git a/src/tools/GetAddress.php b/src/tools/GetAddress.php new file mode 100644 index 0000000..4b10b73 --- /dev/null +++ b/src/tools/GetAddress.php @@ -0,0 +1,36 @@ + + */ + public function __invoke(int $addressId): array + { + $address = \Craft::$app->getElements()->getElementById($addressId, Address::class, null, [ + 'siteId' => '*', + ]); + + throw_unless($address instanceof Address, \InvalidArgumentException::class, "Address with ID {$addressId} not found"); + + return [ + '_notes' => 'Retrieved address details.', + ...($this->formatAddress)($address), + ]; + } +} diff --git a/src/tools/GetAddressFieldLayout.php b/src/tools/GetAddressFieldLayout.php new file mode 100644 index 0000000..a80493d --- /dev/null +++ b/src/tools/GetAddressFieldLayout.php @@ -0,0 +1,41 @@ + + */ + public function __invoke(): array + { + $fieldLayout = ($this->getFreshAddressFieldLayout)(); + $formattedFieldLayout = $this->getFieldLayout->formatFieldLayout($fieldLayout); + $formattedFieldLayout['id'] = self::PLACEHOLDER_ID; + + return [ + '_notes' => 'Retrieved the global address field layout.', + 'fieldLayout' => $formattedFieldLayout, + 'settingsUrl' => UrlHelper::cpUrl('settings/addresses'), + 'elementType' => Address::class, + ]; + } +} diff --git a/src/tools/GetAddresses.php b/src/tools/GetAddresses.php new file mode 100644 index 0000000..21f063b --- /dev/null +++ b/src/tools/GetAddresses.php @@ -0,0 +1,101 @@ + + */ + public function __invoke( + ?int $ownerId = null, + ?string $ownerType = null, + ?int $fieldId = null, + ?string $fieldHandle = null, + ?string $countryCode = null, + ?string $postalCode = null, + ?string $locality = null, + int $limit = 10, + ): array { + $query = Address::find() + ->siteId('*') + ->status(null) + ->drafts(null) + ->provisionalDrafts(null) + ->revisions(null) + ->limit($limit); + + $notes = []; + + if ($ownerId !== null || $ownerType !== null) { + throw_unless($ownerId !== null && $ownerType !== null, \InvalidArgumentException::class, 'ownerId and ownerType must be provided together'); + [$owner] = ($this->resolveElementOwner)($ownerId, $ownerType); + $query->owner($owner); + $notes[] = "owner {$ownerType}#{$ownerId}"; + } + + if ($fieldId !== null || $fieldHandle !== null) { + $field = $this->resolveAddressesField($fieldId, $fieldHandle); + $query->fieldId($field->id); + $notes[] = "field {$field->handle}"; + } + + if ($countryCode !== null) { + $query->countryCode($countryCode); + $notes[] = "country {$countryCode}"; + } + + if ($postalCode !== null) { + $query->postalCode($postalCode); + $notes[] = "postal code {$postalCode}"; + } + + if ($locality !== null) { + $query->locality($locality); + $notes[] = "locality {$locality}"; + } + + $results = $query->all(); + + return [ + '_notes' => empty($notes) + ? 'The following addresses were found.' + : 'The following addresses were found matching ' . implode(' and ', $notes) . '.', + 'results' => Collection::make($results)->map(fn(Address $address) => ($this->formatAddress)($address)), + ]; + } + + private function resolveAddressesField(?int $fieldId, ?string $fieldHandle): AddressesField + { + $fields = \Craft::$app->getFields(); + + if ($fieldId !== null) { + $field = $fields->getFieldById($fieldId); + throw_unless($field instanceof AddressesField, \InvalidArgumentException::class, "Addresses field with ID {$fieldId} not found"); + return $field; + } + + assert($fieldHandle !== null); + $field = $fields->getFieldByHandle($fieldHandle); + throw_unless($field instanceof AddressesField, \InvalidArgumentException::class, "Addresses field with handle '{$fieldHandle}' not found"); + return $field; + } +} diff --git a/src/tools/GetAvailablePermissions.php b/src/tools/GetAvailablePermissions.php new file mode 100644 index 0000000..eb223d9 --- /dev/null +++ b/src/tools/GetAvailablePermissions.php @@ -0,0 +1,125 @@ + + */ + public function __invoke(): array + { + $permissionGroups = $this->userPermissions->getAllPermissions(); + $registeredPermissionNames = []; + $flattenedPermissions = []; + $formattedGroups = []; + + foreach ($permissionGroups as $group) { + $heading = $group['heading'] ?? null; + $permissions = $group['permissions'] ?? []; + + if (!is_string($heading) || !is_array($permissions)) { + continue; + } + + $formattedGroups[] = [ + 'heading' => $heading, + 'permissions' => $this->formatPermissions($permissions, $heading, $registeredPermissionNames, $flattenedPermissions), + ]; + } + + $storedPermissionNames = (new Query()) + ->select(['name']) + ->from(Table::USERPERMISSIONS) + ->orderBy(['name' => SORT_ASC]) + ->column(); + + $storedPermissionNames = array_values(array_filter($storedPermissionNames, 'is_string')); + $registeredPermissionNames = array_values(array_unique($registeredPermissionNames)); + $customPermissionNames = array_values(array_diff($storedPermissionNames, $registeredPermissionNames)); + $allPermissionNames = array_values(array_unique(array_merge($registeredPermissionNames, $storedPermissionNames))); + $customPermissions = array_map(fn(string $name) => [ + 'name' => $name, + 'label' => $name, + 'info' => null, + 'warning' => null, + 'groupHeading' => 'Custom Permissions', + 'isCustom' => true, + ], $customPermissionNames); + + sort($customPermissionNames); + sort($allPermissionNames); + + return [ + 'groups' => $formattedGroups, + 'allPermissions' => $flattenedPermissions, + 'allPermissionNames' => $allPermissionNames, + 'customPermissions' => $customPermissions, + 'customPermissionNames' => $customPermissionNames, + ]; + } + + /** + * @param array $permissions + * @param string $groupHeading + * @param string[] $registeredPermissionNames + * @param array> $flattenedPermissions + * @return array> + */ + private function formatPermissions( + array $permissions, + string $groupHeading, + array &$registeredPermissionNames, + array &$flattenedPermissions, + ): array + { + $formatted = []; + + foreach ($permissions as $name => $config) { + if (!is_string($name) || !is_array($config)) { + continue; + } + + $normalizedName = strtolower($name); + $registeredPermissionNames[] = $normalizedName; + $nested = $config['nested'] ?? []; + $label = is_string($config['label'] ?? null) ? $config['label'] : $name; + $info = is_string($config['info'] ?? null) ? $config['info'] : null; + $warning = is_string($config['warning'] ?? null) ? $config['warning'] : null; + + $formattedPermission = [ + 'name' => $normalizedName, + 'label' => $label, + 'info' => $info, + 'warning' => $warning, + 'nested' => is_array($nested) ? $this->formatPermissions($nested, $groupHeading, $registeredPermissionNames, $flattenedPermissions) : [], + ]; + + $formatted[] = $formattedPermission; + $flattenedPermissions[] = [ + 'name' => $normalizedName, + 'label' => $label, + 'info' => $info, + 'warning' => $warning, + 'groupHeading' => $groupHeading, + 'isCustom' => false, + ]; + } + + return $formatted; + } +} diff --git a/src/tools/GetHealth.php b/src/tools/GetHealth.php index cf64134..542e1f1 100644 --- a/src/tools/GetHealth.php +++ b/src/tools/GetHealth.php @@ -16,6 +16,8 @@ class GetHealth */ public function __invoke(): array { + $primarySite = Craft::$app->getSites()->getPrimarySite(); + return [ 'status' => 'ok', 'plugin' => [ @@ -28,8 +30,8 @@ public function __invoke(): array 'edition' => Craft::$app->getEditionName(), ], 'site' => [ - 'name' => Craft::$app->getSites()->getPrimarySite()->name, - 'baseUrl' => Craft::$app->getSites()->getPrimarySite()->getBaseUrl(), + 'name' => $primarySite->name, + 'baseUrl' => $primarySite->getBaseUrl() ?? '', ], ]; } diff --git a/src/tools/GetOrder.php b/src/tools/GetOrder.php new file mode 100644 index 0000000..4581f9d --- /dev/null +++ b/src/tools/GetOrder.php @@ -0,0 +1,97 @@ + + */ + public function __invoke( + int $orderId, + ): array { + $order = Craft::$app->getElements()->getElementById($orderId, Order::class); + + throw_unless($order instanceof Order, \InvalidArgumentException::class, "Order with ID {$orderId} not found"); + + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + // Resolve order status name + $orderStatusName = null; + if ($order->orderStatusId) { + $orderStatus = $commerce->getOrderStatuses()->getOrderStatusById($order->orderStatusId); + $orderStatusName = $orderStatus?->name; + } + + // Build line items + $lineItems = []; + foreach ($order->getLineItems() as $lineItem) { + $lineItems[] = [ + 'id' => $lineItem->id, + 'description' => $lineItem->getDescription(), + 'sku' => $lineItem->getSku(), + 'qty' => $lineItem->qty, + 'price' => (float) $lineItem->price, + 'subtotal' => (float) $lineItem->getSubtotal(), + 'total' => (float) $lineItem->getTotal(), + ]; + } + + // Build adjustments (discounts, shipping, tax, etc.) + $adjustments = []; + foreach ($order->getAdjustments() ?? [] as $adjustment) { + $adjustments[] = [ + 'id' => $adjustment->id, + 'type' => $adjustment->type, + 'name' => $adjustment->name, + 'description' => $adjustment->description, + 'amount' => (float) $adjustment->amount, + 'included' => $adjustment->included, + ]; + } + + // Format addresses + $shippingAddress = $order->getShippingAddress(); + $billingAddress = $order->getBillingAddress(); + + return [ + '_notes' => 'Retrieved order details.', + 'orderId' => $order->id, + 'number' => $order->number, + 'reference' => $order->reference, + 'email' => $order->email, + 'isCompleted' => $order->isCompleted, + 'dateOrdered' => $order->dateOrdered?->format('c'), + 'datePaid' => $order->datePaid?->format('c'), + 'currency' => $order->currency, + 'couponCode' => $order->couponCode, + 'orderStatusId' => $order->orderStatusId, + 'orderStatusName' => $orderStatusName, + 'paidStatus' => $order->getPaidStatus(), + 'origin' => $order->origin, + 'shippingMethodHandle' => $order->shippingMethodHandle, + 'itemTotal' => (float) $order->getItemTotal(), + 'totalShippingCost' => (float) $order->getTotalShippingCost(), + 'totalDiscount' => (float) $order->getTotalDiscount(), + 'totalTax' => (float) $order->getTotalTax(), + 'totalPaid' => (float) $order->getTotalPaid(), + 'total' => (float) $order->getTotal(), + 'lineItems' => $lineItems, + 'adjustments' => $adjustments, + 'shippingAddress' => $shippingAddress?->toArray(), + 'billingAddress' => $billingAddress?->toArray(), + 'url' => ElementHelper::elementEditorUrl($order), + ]; + } +} diff --git a/src/tools/GetOrderStatuses.php b/src/tools/GetOrderStatuses.php new file mode 100644 index 0000000..7fefcf3 --- /dev/null +++ b/src/tools/GetOrderStatuses.php @@ -0,0 +1,42 @@ + + */ + public function __invoke(): array + { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $orderStatuses = $commerce->getOrderStatuses()->getAllOrderStatuses(); + + $statuses = []; + foreach ($orderStatuses as $status) { + $statuses[] = [ + 'id' => $status->id, + 'name' => $status->name, + 'handle' => $status->handle, + 'color' => $status->color, + 'description' => $status->description, + 'isDefault' => (bool) $status->default, + 'sortOrder' => $status->sortOrder, + ]; + } + + return [ + '_notes' => 'Retrieved all Commerce order statuses.', + 'orderStatuses' => $statuses, + ]; + } +} diff --git a/src/tools/GetProduct.php b/src/tools/GetProduct.php new file mode 100644 index 0000000..5448d96 --- /dev/null +++ b/src/tools/GetProduct.php @@ -0,0 +1,73 @@ +getStock(); + } + + /** + * Get detailed information about a single Commerce product by ID. + * + * Returns the product's attributes, custom fields, and all associated variants + * with their pricing, SKU, and inventory details. + * + * @return array + */ + public function __invoke( + int $productId, + ): array { + $product = Craft::$app->getElements()->getElementById($productId, Product::class); + + throw_unless($product instanceof Product, \InvalidArgumentException::class, "Product with ID {$productId} not found"); + + $variants = []; + foreach ($product->getVariants() as $variant) { + $variants[] = [ + 'id' => $variant->id, + 'title' => $variant->title, + 'sku' => $variant->sku, + 'price' => (float) $variant->price, + 'isDefault' => $variant->isDefault, + 'stock' => $this->getVariantStock($variant), + 'minQty' => $variant->minQty, + 'maxQty' => $variant->maxQty, + 'weight' => $variant->weight, + 'height' => $variant->height, + 'length' => $variant->length, + 'width' => $variant->width, + 'freeShipping' => $variant->freeShipping, + 'inventoryTracked' => $variant->inventoryTracked, + 'sortOrder' => $variant->sortOrder, + ]; + } + + $productType = $product->getType(); + + return [ + '_notes' => 'Retrieved product details with variants.', + 'productId' => $product->id, + 'title' => $product->title, + 'slug' => $product->slug, + 'status' => $product->getStatus(), + 'typeId' => $product->typeId, + 'typeName' => $productType->name, + 'typeHandle' => $productType->handle, + 'postDate' => $product->postDate?->format('c'), + 'expiryDate' => $product->expiryDate?->format('c'), + 'defaultSku' => $product->defaultSku, + 'defaultPrice' => $product->defaultPrice, + 'url' => ElementHelper::elementEditorUrl($product), + 'variants' => $variants, + 'customFields' => $product->getSerializedFieldValues(), + ]; + } +} diff --git a/src/tools/GetProductType.php b/src/tools/GetProductType.php new file mode 100644 index 0000000..c82b8e5 --- /dev/null +++ b/src/tools/GetProductType.php @@ -0,0 +1,106 @@ + + */ + public function __invoke( + /** ID of the product type to retrieve */ + int $productTypeId, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $productType = $commerce->getProductTypes()->getProductTypeById($productTypeId); + + throw_unless( + $productType instanceof ProductType, + \InvalidArgumentException::class, + "Product type with ID {$productTypeId} not found", + ); + + // Format site settings + $siteSettings = []; + foreach ($productType->getSiteSettings() as $siteId => $siteSetting) { + $siteSettings[] = [ + 'siteId' => (int) $siteId, + 'hasUrls' => $siteSetting->hasUrls, + 'uriFormat' => $siteSetting->uriFormat, + 'template' => $siteSetting->template, + 'enabledByDefault' => $siteSetting->enabledByDefault, + ]; + } + + // Format product-level field layout fields + $productFields = []; + $productFieldLayout = $productType->getFieldLayout(); + if ($productFieldLayout->id !== null) { + $productFields = $this->fieldFormatter->formatFieldsForLayout($productFieldLayout); + } + + // Format variant-level field layout fields + $variantFields = []; + $variantFieldLayout = $productType->getVariantFieldLayout(); + if ($variantFieldLayout->id !== null) { + $variantFields = $this->fieldFormatter->formatFieldsForLayout($variantFieldLayout); + } + + return [ + '_notes' => 'Retrieved product type details with field layouts.', + 'id' => $productType->id, + 'name' => $productType->name, + 'handle' => $productType->handle, + 'fieldLayoutId' => $productType->fieldLayoutId, + 'variantFieldLayoutId' => $productType->variantFieldLayoutId, + 'hasDimensions' => $productType->hasDimensions, + 'hasProductTitleField' => $productType->hasProductTitleField, + 'productTitleFormat' => $productType->productTitleFormat, + 'productTitleTranslationMethod' => $productType->productTitleTranslationMethod, + 'productTitleTranslationKeyFormat' => $productType->productTitleTranslationKeyFormat, + 'hasVariantTitleField' => $productType->hasVariantTitleField, + 'variantTitleFormat' => $productType->variantTitleFormat, + 'variantTitleTranslationMethod' => $productType->variantTitleTranslationMethod, + 'variantTitleTranslationKeyFormat' => $productType->variantTitleTranslationKeyFormat, + 'showSlugField' => $productType->showSlugField, + 'slugTranslationMethod' => $productType->slugTranslationMethod, + 'slugTranslationKeyFormat' => $productType->slugTranslationKeyFormat, + 'skuFormat' => $productType->skuFormat, + 'descriptionFormat' => $productType->descriptionFormat, + 'template' => $productType->template, + 'maxVariants' => $productType->maxVariants, + 'enableVersioning' => $productType->enableVersioning, + 'isStructure' => $productType->isStructure, + 'maxLevels' => $productType->isStructure ? $productType->maxLevels : null, + 'defaultPlacement' => $productType->isStructure ? $productType->defaultPlacement : null, + 'propagationMethod' => $productType->propagationMethod->value, + 'siteSettings' => $siteSettings, + 'productFields' => $productFields, + 'variantFields' => $variantFields, + 'editUrl' => $productType->getCpEditUrl(), + 'editVariantUrl' => $productType->getCpEditVariantUrl(), + ]; + } +} diff --git a/src/tools/GetProductTypes.php b/src/tools/GetProductTypes.php new file mode 100644 index 0000000..aa937bc --- /dev/null +++ b/src/tools/GetProductTypes.php @@ -0,0 +1,63 @@ + + */ + public function __invoke(): array + { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + $types = []; + foreach ($productTypes as $productType) { + $siteSettings = []; + foreach ($productType->getSiteSettings() as $siteId => $siteSetting) { + $siteSettings[] = [ + 'siteId' => (int) $siteId, + 'hasUrls' => $siteSetting->hasUrls, + 'uriFormat' => $siteSetting->uriFormat, + 'template' => $siteSetting->template, + 'enabledByDefault' => $siteSetting->enabledByDefault, + ]; + } + + $types[] = [ + 'id' => $productType->id, + 'name' => $productType->name, + 'handle' => $productType->handle, + 'fieldLayoutId' => $productType->fieldLayoutId, + 'variantFieldLayoutId' => $productType->variantFieldLayoutId, + 'hasDimensions' => $productType->hasDimensions, + 'hasProductTitleField' => $productType->hasProductTitleField, + 'productTitleFormat' => $productType->productTitleFormat, + 'hasVariantTitleField' => $productType->hasVariantTitleField, + 'variantTitleFormat' => $productType->variantTitleFormat, + 'skuFormat' => $productType->skuFormat, + 'maxVariants' => $productType->maxVariants, + 'siteSettings' => $siteSettings, + ]; + } + + return [ + '_notes' => 'Retrieved all Commerce product types.', + 'productTypes' => $types, + ]; + } +} diff --git a/src/tools/GetProducts.php b/src/tools/GetProducts.php new file mode 100644 index 0000000..cf45d42 --- /dev/null +++ b/src/tools/GetProducts.php @@ -0,0 +1,91 @@ +|null $typeIds + * @return array + */ + public function __invoke( + ?string $query = null, + int $limit = 10, + + /** Product status filter. Options: live, pending, expired, disabled. Default: live. */ + string $status = Product::STATUS_LIVE, + + /** Optional array of product type IDs to filter results. */ + ?array $typeIds = null, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + // Validate product type IDs if provided + if ($typeIds !== null) { + foreach ($typeIds as $typeId) { + $type = $commerce->getProductTypes()->getProductTypeById($typeId); + throw_unless($type, "Product type with ID {$typeId} not found"); + } + } + + $queryBuilder = Product::find()->limit($limit)->status($status); + + if ($typeIds !== null) { + $queryBuilder->typeId($typeIds); + } + + if ($query !== null) { + $queryBuilder->search($query); + } + + $result = $queryBuilder->all(); + + // Generate descriptive notes + $notes = []; + if ($query !== null) { + $notes[] = "search query \"{$query}\""; + } + if ($typeIds !== null) { + $typeNames = []; + foreach ($typeIds as $typeId) { + $type = $commerce->getProductTypes()->getProductTypeById($typeId); + if ($type !== null) { + $typeNames[] = $type->name; + } + } + $notes[] = 'product type(s): ' . implode(', ', $typeNames); + } + + $notesText = empty($notes) + ? 'The following products were found.' + : 'The following products were found matching ' . implode(' and ', $notes) . '.'; + + return [ + '_notes' => $notesText, + 'results' => Collection::make($result)->map(function (Product $product) { + return [ + 'productId' => (int) $product->id, + 'title' => (string) $product->title, + 'slug' => $product->slug, + 'status' => $product->getStatus(), + 'typeId' => $product->typeId, + 'defaultSku' => $product->defaultSku, + 'defaultPrice' => $product->defaultPrice, + 'url' => ElementHelper::elementEditorUrl($product), + ]; + }), + ]; + } +} diff --git a/src/tools/GetStore.php b/src/tools/GetStore.php new file mode 100644 index 0000000..b407599 --- /dev/null +++ b/src/tools/GetStore.php @@ -0,0 +1,65 @@ + + */ + public function __invoke( + int $storeId, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $store = $commerce->getStores()->getStoreById($storeId); + + throw_unless($store instanceof Store, \InvalidArgumentException::class, "Store with ID {$storeId} not found"); + + $sites = []; + /** @var \craft\models\Site $site */ + foreach ($store->getSites() as $site) { + $sites[] = [ + 'id' => $site->id, + 'name' => $site->getName(), + 'handle' => $site->handle, + ]; + } + + return [ + '_notes' => 'Retrieved store details.', + 'id' => $store->id, + 'name' => $store->getName(), + 'handle' => $store->handle, + 'primary' => $store->primary, + 'currency' => $store->getCurrency()?->getCode(), + 'autoSetNewCartAddresses' => (bool) $store->getAutoSetNewCartAddresses(), + 'autoSetCartShippingMethodOption' => (bool) $store->getAutoSetCartShippingMethodOption(), + 'autoSetPaymentSource' => (bool) $store->getAutoSetPaymentSource(), + 'allowEmptyCartOnCheckout' => (bool) $store->getAllowEmptyCartOnCheckout(), + 'allowCheckoutWithoutPayment' => (bool) $store->getAllowCheckoutWithoutPayment(), + 'allowPartialPaymentOnCheckout' => (bool) $store->getAllowPartialPaymentOnCheckout(), + 'requireShippingAddressAtCheckout' => (bool) $store->getRequireShippingAddressAtCheckout(), + 'requireBillingAddressAtCheckout' => (bool) $store->getRequireBillingAddressAtCheckout(), + 'requireShippingMethodSelectionAtCheckout' => (bool) $store->getRequireShippingMethodSelectionAtCheckout(), + 'useBillingAddressForTax' => (bool) $store->getUseBillingAddressForTax(), + 'validateOrganizationTaxIdAsVatId' => (bool) $store->getValidateOrganizationTaxIdAsVatId(), + 'orderReferenceFormat' => $store->getOrderReferenceFormat(), + 'freeOrderPaymentStrategy' => $store->getFreeOrderPaymentStrategy(), + 'minimumTotalPriceStrategy' => $store->getMinimumTotalPriceStrategy(), + 'sortOrder' => $store->sortOrder, + 'sites' => $sites, + 'url' => $store->getStoreSettingsUrl(), + ]; + } +} diff --git a/src/tools/GetStores.php b/src/tools/GetStores.php new file mode 100644 index 0000000..eca02aa --- /dev/null +++ b/src/tools/GetStores.php @@ -0,0 +1,68 @@ + + */ + public function __invoke(): array + { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $allStores = $commerce->getStores()->getAllStores(); + + $stores = []; + foreach ($allStores as $store) { + $sites = []; + /** @var \craft\models\Site $site */ + foreach ($store->getSites() as $site) { + $sites[] = [ + 'id' => $site->id, + 'name' => $site->getName(), + 'handle' => $site->handle, + ]; + } + + $stores[] = [ + 'id' => $store->id, + 'name' => $store->getName(), + 'handle' => $store->handle, + 'primary' => $store->primary, + 'currency' => $store->getCurrency()?->getCode(), + 'autoSetNewCartAddresses' => (bool) $store->getAutoSetNewCartAddresses(), + 'autoSetCartShippingMethodOption' => (bool) $store->getAutoSetCartShippingMethodOption(), + 'autoSetPaymentSource' => (bool) $store->getAutoSetPaymentSource(), + 'allowEmptyCartOnCheckout' => (bool) $store->getAllowEmptyCartOnCheckout(), + 'allowCheckoutWithoutPayment' => (bool) $store->getAllowCheckoutWithoutPayment(), + 'allowPartialPaymentOnCheckout' => (bool) $store->getAllowPartialPaymentOnCheckout(), + 'requireShippingAddressAtCheckout' => (bool) $store->getRequireShippingAddressAtCheckout(), + 'requireBillingAddressAtCheckout' => (bool) $store->getRequireBillingAddressAtCheckout(), + 'requireShippingMethodSelectionAtCheckout' => (bool) $store->getRequireShippingMethodSelectionAtCheckout(), + 'useBillingAddressForTax' => (bool) $store->getUseBillingAddressForTax(), + 'validateOrganizationTaxIdAsVatId' => (bool) $store->getValidateOrganizationTaxIdAsVatId(), + 'orderReferenceFormat' => $store->getOrderReferenceFormat(), + 'freeOrderPaymentStrategy' => $store->getFreeOrderPaymentStrategy(), + 'minimumTotalPriceStrategy' => $store->getMinimumTotalPriceStrategy(), + 'sortOrder' => $store->sortOrder, + 'sites' => $sites, + 'url' => $store->getStoreSettingsUrl(), + ]; + } + + return [ + '_notes' => 'Retrieved all Commerce stores.', + 'stores' => $stores, + ]; + } +} diff --git a/src/tools/GetUser.php b/src/tools/GetUser.php new file mode 100644 index 0000000..af5998a --- /dev/null +++ b/src/tools/GetUser.php @@ -0,0 +1,41 @@ + + */ + public function __invoke( + /** User ID to look up. */ + ?int $userId = null, + + /** Resolve the user by exact email address. */ + ?string $email = null, + + /** Resolve the user by exact username. */ + ?string $username = null, + ): array { + $user = ($this->resolveUser)($userId, $email, $username); + + return [ + '_notes' => 'Retrieved user details.', + ...($this->formatUser)($user), + ]; + } +} diff --git a/src/tools/GetUserFieldLayout.php b/src/tools/GetUserFieldLayout.php new file mode 100644 index 0000000..46ca570 --- /dev/null +++ b/src/tools/GetUserFieldLayout.php @@ -0,0 +1,40 @@ + + */ + public function __invoke(): array + { + $fieldLayout = ($this->getFreshUserFieldLayout)(); + $formattedFieldLayout = $this->getFieldLayout->formatFieldLayout($fieldLayout); + $formattedFieldLayout['id'] = self::PLACEHOLDER_ID; + + return [ + '_notes' => 'Retrieved the global user field layout.', + 'fieldLayout' => $formattedFieldLayout, + 'settingsUrl' => UrlHelper::cpUrl('settings/users'), + 'elementType' => User::class, + ]; + } +} diff --git a/src/tools/GetUserGroup.php b/src/tools/GetUserGroup.php new file mode 100644 index 0000000..9a56da5 --- /dev/null +++ b/src/tools/GetUserGroup.php @@ -0,0 +1,42 @@ + + */ + public function __invoke( + /** User group ID to look up. */ + ?int $groupId = null, + + /** User group handle to look up. */ + ?string $handle = null, + ): array + { + throw_unless(Craft::$app->edition->value >= CmsEdition::Pro->value, \InvalidArgumentException::class, 'Managing user groups requires Craft Pro.'); + + $group = ($this->resolveUserGroup)($groupId, $handle); + + return [ + '_notes' => 'Retrieved user group details.', + ...($this->formatUserGroup)($group), + ]; + } +} diff --git a/src/tools/GetUserGroups.php b/src/tools/GetUserGroups.php new file mode 100644 index 0000000..2bb2d61 --- /dev/null +++ b/src/tools/GetUserGroups.php @@ -0,0 +1,39 @@ + + */ + public function __invoke(): array + { + throw_unless(Craft::$app->edition->value >= CmsEdition::Pro->value, \InvalidArgumentException::class, 'Managing user groups requires Craft Pro.'); + + $groups = Craft::$app->getUserGroups()->getAllGroups(); + + return [ + '_notes' => 'The following user groups were found.', + 'results' => Collection::make($groups) + ->map(fn($group) => ($this->formatUserGroup)($group)) + ->values() + ->all(), + ]; + } +} diff --git a/src/tools/GetUsers.php b/src/tools/GetUsers.php new file mode 100644 index 0000000..887607a --- /dev/null +++ b/src/tools/GetUsers.php @@ -0,0 +1,100 @@ + + */ + public function __invoke( + /** Search text passed to Craft's user element query. */ + ?string $query = null, + + /** Exact email filter. */ + ?string $email = null, + + /** Exact username filter. */ + ?string $username = null, + + /** Filter by user group ID. Requires Craft Team or Pro. */ + ?int $groupId = null, + + /** Filter by user group handle. Requires Craft Team or Pro. */ + ?string $groupHandle = null, + + /** Craft user status such as active, pending, suspended, locked, or inactive. */ + ?string $status = null, + + /** Maximum number of users to return. */ + int $limit = 25, + ): array { + $userQuery = User::find() + ->status(null) + ->site('*') + ->limit($limit); + + $notes = []; + + if ($query !== null) { + $userQuery->search($query); + $notes[] = "query '{$query}'"; + } + + if ($email !== null) { + $userQuery->email($email); + $notes[] = "email {$email}"; + } + + if ($username !== null) { + $userQuery->username($username); + $notes[] = "username {$username}"; + } + + if ($groupId !== null || $groupHandle !== null) { + throw_unless($groupId === null || $groupHandle === null, \InvalidArgumentException::class, 'Provide either groupId or groupHandle, not both.'); + throw_unless(Craft::$app->edition->value >= CmsEdition::Team->value, \InvalidArgumentException::class, 'Filtering users by group requires Craft Team or Craft Pro.'); + $group = $groupId !== null + ? $this->userGroupsService->getGroupById($groupId) + : $this->userGroupsService->getGroupByHandle((string) $groupHandle); + + throw_unless($group instanceof UserGroup, \InvalidArgumentException::class, 'User group filter not found.'); + $userQuery->group($group); + $notes[] = "group {$group->handle}"; + } + + if ($status !== null) { + $userQuery->status($status); + $notes[] = "status {$status}"; + } + + return [ + '_notes' => $notes === [] + ? 'The following users were found.' + : 'The following users were found matching ' . implode(' and ', $notes) . '.', + 'results' => Collection::make($userQuery->all()) + ->map(fn(User $user) => ($this->formatUser)($user)) + ->values() + ->all(), + ]; + } +} diff --git a/src/tools/GetVariant.php b/src/tools/GetVariant.php new file mode 100644 index 0000000..0c27965 --- /dev/null +++ b/src/tools/GetVariant.php @@ -0,0 +1,57 @@ +getStock(); + } + + /** + * Get detailed information about a single Commerce product variant by ID. + * + * Returns the variant's pricing, inventory, dimensions, and custom field data, + * along with information about its parent product. + * + * @return array + */ + public function __invoke( + int $variantId, + ): array { + $variant = Craft::$app->getElements()->getElementById($variantId, Variant::class); + + throw_unless($variant instanceof Variant, \InvalidArgumentException::class, "Variant with ID {$variantId} not found"); + + $product = $variant->getOwner(); + + return [ + '_notes' => 'Retrieved variant details.', + 'variantId' => $variant->id, + 'title' => $variant->title, + 'sku' => $variant->sku, + 'price' => (float) $variant->price, + 'isDefault' => $variant->isDefault, + 'sortOrder' => $variant->sortOrder, + 'stock' => $this->getVariantStock($variant), + 'minQty' => $variant->minQty, + 'maxQty' => $variant->maxQty, + 'weight' => $variant->weight, + 'height' => $variant->height, + 'length' => $variant->length, + 'width' => $variant->width, + 'freeShipping' => $variant->freeShipping, + 'inventoryTracked' => $variant->inventoryTracked, + 'productId' => $product instanceof Product ? $product->id : null, + 'productTitle' => $product instanceof Product ? $product->title : null, + 'url' => $product instanceof Product ? ElementHelper::elementEditorUrl($product) : null, + 'customFields' => $variant->getSerializedFieldValues(), + ]; + } +} diff --git a/src/tools/MoveElementInFieldLayout.php b/src/tools/MoveElementInFieldLayout.php index 5502fa0..db97362 100644 --- a/src/tools/MoveElementInFieldLayout.php +++ b/src/tools/MoveElementInFieldLayout.php @@ -7,13 +7,23 @@ use craft\models\FieldLayout; use craft\models\FieldLayoutTab; use craft\services\Fields; +use happycog\craftmcp\actions\NormalizeAddressFieldLayoutForSave; +use happycog\craftmcp\actions\NormalizeUserFieldLayoutForSave; +use happycog\craftmcp\actions\ResolveFieldLayout; +use happycog\craftmcp\actions\SaveFieldLayout; use happycog\craftmcp\exceptions\ModelSaveException; +use happycog\craftmcp\tools\GetAddressFieldLayout; +use happycog\craftmcp\tools\GetUserFieldLayout; class MoveElementInFieldLayout { public function __construct( protected Fields $fieldsService, protected GetFieldLayout $getFieldLayout, + protected NormalizeAddressFieldLayoutForSave $normalizeAddressFieldLayoutForSave, + protected NormalizeUserFieldLayoutForSave $normalizeUserFieldLayoutForSave, + protected ResolveFieldLayout $resolveFieldLayout, + protected SaveFieldLayout $saveFieldLayout, ) { } @@ -43,7 +53,7 @@ public function __invoke( */ array $position, ): array { - $fieldLayout = $this->fieldsService->getLayoutById($fieldLayoutId); + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId); throw_unless($fieldLayout instanceof FieldLayout, "Field layout with ID {$fieldLayoutId} not found"); $positionType = $position['type'] ?? null; @@ -140,7 +150,16 @@ public function __invoke( } $fieldLayout->setTabs($finalTabs); - throw_unless($this->fieldsService->saveLayout($fieldLayout), ModelSaveException::class, $fieldLayout); + + $fieldLayoutToSave = match ($fieldLayoutId) { + GetAddressFieldLayout::PLACEHOLDER_ID => ($this->normalizeAddressFieldLayoutForSave)($fieldLayout), + GetUserFieldLayout::PLACEHOLDER_ID => ($this->normalizeUserFieldLayoutForSave)($fieldLayout), + default => $fieldLayout, + }; + + throw_unless(($this->saveFieldLayout)($fieldLayoutToSave), ModelSaveException::class, $fieldLayoutToSave); + + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId) ?? $fieldLayoutToSave; return [ '_notes' => ['Element moved successfully', 'Review the field layout in the control panel'], diff --git a/src/tools/RemoveElementFromFieldLayout.php b/src/tools/RemoveElementFromFieldLayout.php index 44f1239..fb2b68d 100644 --- a/src/tools/RemoveElementFromFieldLayout.php +++ b/src/tools/RemoveElementFromFieldLayout.php @@ -7,7 +7,15 @@ use craft\models\FieldLayoutTab; use craft\services\Fields; use happycog\craftmcp\actions\ManageEntryTitleField; +use happycog\craftmcp\actions\NormalizeAddressFieldLayoutForSave; +use happycog\craftmcp\actions\NormalizeUserFieldLayoutForSave; +use happycog\craftmcp\actions\ResolveFieldLayout; +use happycog\craftmcp\actions\ResolvePersistedAddressFieldLayout; +use happycog\craftmcp\actions\ResolvePersistedUserFieldLayout; +use happycog\craftmcp\actions\SaveFieldLayout; use happycog\craftmcp\exceptions\ModelSaveException; +use happycog\craftmcp\tools\GetAddressFieldLayout; +use happycog\craftmcp\tools\GetUserFieldLayout; class RemoveElementFromFieldLayout { @@ -15,6 +23,12 @@ public function __construct( protected Fields $fieldsService, protected GetFieldLayout $getFieldLayout, protected ManageEntryTitleField $manageEntryTitleField, + protected NormalizeAddressFieldLayoutForSave $normalizeAddressFieldLayoutForSave, + protected NormalizeUserFieldLayoutForSave $normalizeUserFieldLayoutForSave, + protected ResolveFieldLayout $resolveFieldLayout, + protected ResolvePersistedAddressFieldLayout $resolvePersistedAddressFieldLayout, + protected ResolvePersistedUserFieldLayout $resolvePersistedUserFieldLayout, + protected SaveFieldLayout $saveFieldLayout, ) { } @@ -36,7 +50,7 @@ public function __invoke( /** The UID of the element to remove */ string $elementUid, ): array { - $fieldLayout = $this->fieldsService->getLayoutById($fieldLayoutId); + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId); throw_unless($fieldLayout instanceof FieldLayout, "Field layout with ID {$fieldLayoutId} not found"); $elementFound = false; @@ -65,7 +79,22 @@ public function __invoke( throw_unless($elementFound, "Element with UID '{$elementUid}' not found in field layout"); $fieldLayout->setTabs($newTabs); - throw_unless($this->fieldsService->saveLayout($fieldLayout), ModelSaveException::class, $fieldLayout); + + $fieldLayoutToSave = match ($fieldLayoutId) { + GetAddressFieldLayout::PLACEHOLDER_ID => ($this->normalizeAddressFieldLayoutForSave)($fieldLayout), + GetUserFieldLayout::PLACEHOLDER_ID => ($this->normalizeUserFieldLayoutForSave)($fieldLayout), + default => $fieldLayout, + }; + + throw_unless(($this->saveFieldLayout)($fieldLayoutToSave), ModelSaveException::class, $fieldLayoutToSave); + + if ($fieldLayoutId === GetAddressFieldLayout::PLACEHOLDER_ID) { + $fieldLayout = ($this->resolvePersistedAddressFieldLayout)(); + } elseif ($fieldLayoutId === GetUserFieldLayout::PLACEHOLDER_ID) { + $fieldLayout = ($this->resolvePersistedUserFieldLayout)(); + } else { + $fieldLayout = ($this->resolveFieldLayout)($fieldLayoutId) ?? $fieldLayout; + } $notes = ['Element removed successfully']; diff --git a/src/tools/SearchOrders.php b/src/tools/SearchOrders.php new file mode 100644 index 0000000..96ab5c8 --- /dev/null +++ b/src/tools/SearchOrders.php @@ -0,0 +1,123 @@ + + */ + public function __invoke( + ?string $query = null, + int $limit = 10, + + /** Filter by customer email address. */ + ?string $email = null, + + /** Filter by order status ID. Use GetOrder or the Commerce CP to find status IDs. */ + ?int $orderStatusId = null, + + /** Filter by completion state. True for completed orders, false for carts. */ + ?bool $isCompleted = null, + + /** Filter by paid status: paid, unpaid, partial, overPaid. */ + ?string $paidStatus = null, + + /** Filter orders placed on or after this date (ISO 8601 format). */ + ?string $dateOrderedAfter = null, + + /** Filter orders placed on or before this date (ISO 8601 format). */ + ?string $dateOrderedBefore = null, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $queryBuilder = Order::find()->limit($limit); + + if ($email !== null) { + $queryBuilder->email($email); + } + if ($orderStatusId !== null) { + $queryBuilder->orderStatusId($orderStatusId); + } + if ($isCompleted !== null) { + $queryBuilder->isCompleted($isCompleted); + } + if ($query !== null) { + $queryBuilder->search($query); + } + if ($dateOrderedAfter !== null) { + $queryBuilder->dateOrdered('>= ' . $dateOrderedAfter); + } + if ($dateOrderedBefore !== null) { + // If both are set, use the range; otherwise just the before date + if ($dateOrderedAfter !== null) { + $queryBuilder->dateOrdered(['and', '>= ' . $dateOrderedAfter, '<= ' . $dateOrderedBefore]); + } else { + $queryBuilder->dateOrdered('<= ' . $dateOrderedBefore); + } + } + + $result = $queryBuilder->all(); + + // Filter by paid status in PHP since it's a computed property + if ($paidStatus !== null) { + $result = array_filter($result, function (Order $order) use ($paidStatus) { + return $order->getPaidStatus() === $paidStatus; + }); + $result = array_values($result); + } + + // Generate descriptive notes + $filters = []; + if ($query !== null) { + $filters[] = "search query \"{$query}\""; + } + if ($email !== null) { + $filters[] = "email \"{$email}\""; + } + if ($orderStatusId !== null) { + $status = $commerce->getOrderStatuses()->getOrderStatusById($orderStatusId); + $filters[] = 'status: ' . ($status?->name ?? "ID {$orderStatusId}"); + } + if ($isCompleted !== null) { + $filters[] = $isCompleted ? 'completed orders' : 'active carts'; + } + if ($paidStatus !== null) { + $filters[] = "paid status: {$paidStatus}"; + } + + $notesText = empty($filters) + ? 'The following orders were found.' + : 'The following orders were found matching ' . implode(' and ', $filters) . '.'; + + return [ + '_notes' => $notesText, + 'results' => Collection::make($result)->map(function (Order $order) { + return [ + 'orderId' => (int) $order->id, + 'number' => $order->number, + 'reference' => $order->reference, + 'email' => $order->email, + 'isCompleted' => $order->isCompleted, + 'dateOrdered' => $order->dateOrdered?->format('c'), + 'total' => (float) $order->getTotal(), + 'totalPaid' => (float) $order->getTotalPaid(), + 'paidStatus' => $order->getPaidStatus(), + 'currency' => $order->currency, + 'url' => ElementHelper::elementEditorUrl($order), + ]; + }), + ]; + } +} diff --git a/src/tools/UpdateAddress.php b/src/tools/UpdateAddress.php new file mode 100644 index 0000000..371dad5 --- /dev/null +++ b/src/tools/UpdateAddress.php @@ -0,0 +1,88 @@ + $fields + * @return array + */ + public function __invoke( + int $addressId, + ?string $title = null, + ?string $fullName = null, + ?string $firstName = null, + ?string $lastName = null, + ?string $countryCode = null, + ?string $administrativeArea = null, + ?string $locality = null, + ?string $dependentLocality = null, + ?string $postalCode = null, + ?string $sortingCode = null, + ?string $addressLine1 = null, + ?string $addressLine2 = null, + ?string $addressLine3 = null, + ?string $organization = null, + ?string $organizationTaxId = null, + ?string $latitude = null, + ?string $longitude = null, + array $fields = [], + ): array { + $address = \Craft::$app->getElements()->getElementById($addressId, Address::class, null, [ + 'siteId' => '*', + ]); + + throw_unless($address instanceof Address, \InvalidArgumentException::class, "Address with ID {$addressId} not found"); + + $attributes = [ + 'title' => $title, + 'fullName' => $fullName, + 'firstName' => $firstName, + 'lastName' => $lastName, + 'countryCode' => $countryCode, + 'administrativeArea' => $administrativeArea, + 'locality' => $locality, + 'dependentLocality' => $dependentLocality, + 'postalCode' => $postalCode, + 'sortingCode' => $sortingCode, + 'addressLine1' => $addressLine1, + 'addressLine2' => $addressLine2, + 'addressLine3' => $addressLine3, + 'organization' => $organization, + 'organizationTaxId' => $organizationTaxId, + 'latitude' => $latitude, + 'longitude' => $longitude, + ]; + + $address->setScenario(Address::SCENARIO_LIVE); + $address->setAttributes(array_filter($attributes, fn(mixed $value) => $value !== null)); + + if ($fields !== []) { + $address->setFieldValues($fields); + } + + throw_unless( + \Craft::$app->getElements()->saveElement($address), + 'Failed to save address: ' . implode(', ', $address->getFirstErrors()), + ); + + return [ + '_notes' => 'The address was successfully updated.', + ...($this->formatAddress)($address), + ]; + } +} diff --git a/src/tools/UpdateOrder.php b/src/tools/UpdateOrder.php new file mode 100644 index 0000000..d2c00b3 --- /dev/null +++ b/src/tools/UpdateOrder.php @@ -0,0 +1,71 @@ + + */ + public function __invoke( + int $orderId, + + /** New order status ID. Use SearchOrders or Commerce CP to find valid status IDs. */ + ?int $orderStatusId = null, + + /** Order message or internal notes. */ + ?string $message = null, + ): array { + $order = Craft::$app->getElements()->getElementById($orderId, Order::class); + + throw_unless($order instanceof Order, \InvalidArgumentException::class, "Order with ID {$orderId} not found"); + + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + if ($orderStatusId !== null) { + $statusObj = $commerce->getOrderStatuses()->getOrderStatusById($orderStatusId); + throw_unless($statusObj, \InvalidArgumentException::class, "Order status with ID {$orderStatusId} not found"); + $order->orderStatusId = $orderStatusId; + } + if ($message !== null) { + $order->message = $message; + } + + throw_unless( + Craft::$app->getElements()->saveElement($order), + "Failed to save order: " . implode(', ', $order->getFirstErrors()), + ); + + // Resolve status name + $orderStatusName = null; + if ($order->orderStatusId) { + $statusObj = $commerce->getOrderStatuses()->getOrderStatusById($order->orderStatusId); + $orderStatusName = $statusObj?->name; + } + + return [ + '_notes' => 'The order was successfully updated.', + 'orderId' => $order->id, + 'number' => $order->number, + 'reference' => $order->reference, + 'orderStatusId' => $order->orderStatusId, + 'orderStatusName' => $orderStatusName, + 'message' => $order->message, + 'url' => ElementHelper::elementEditorUrl($order), + ]; + } +} diff --git a/src/tools/UpdateProduct.php b/src/tools/UpdateProduct.php new file mode 100644 index 0000000..cfa446a --- /dev/null +++ b/src/tools/UpdateProduct.php @@ -0,0 +1,82 @@ + $fields Custom field data keyed by field handle. + * @return array + */ + public function __invoke( + int $productId, + + /** Product title. */ + ?string $title = null, + + /** Product slug. */ + ?string $slug = null, + + /** Post date in ISO 8601 format (e.g. 2025-01-01T00:00:00+00:00). */ + ?string $postDate = null, + + /** Expiry date in ISO 8601 format, or null to remove. */ + ?string $expiryDate = null, + + /** Whether the product is enabled. */ + ?bool $enabled = null, + + /** Custom field data keyed by field handle. */ + array $fields = [], + ): array { + $product = Craft::$app->getElements()->getElementById($productId, Product::class); + + throw_unless($product instanceof Product, \InvalidArgumentException::class, "Product with ID {$productId} not found"); + + if ($title !== null) { + $product->title = $title; + } + if ($slug !== null) { + $product->slug = $slug; + } + if ($postDate !== null) { + $product->postDate = new \DateTime($postDate); + } + if ($expiryDate !== null) { + $product->expiryDate = new \DateTime($expiryDate); + } + if ($enabled !== null) { + $product->enabled = $enabled; + } + if (!empty($fields)) { + $product->setFieldValues($fields); + } + + throw_unless( + Craft::$app->getElements()->saveElement($product), + "Failed to save product: " . implode(', ', $product->getFirstErrors()), + ); + + return [ + '_notes' => 'The product was successfully updated.', + 'productId' => $product->id, + 'title' => $product->title, + 'slug' => $product->slug, + 'status' => $product->getStatus(), + 'url' => ElementHelper::elementEditorUrl($product), + ]; + } +} diff --git a/src/tools/UpdateProductType.php b/src/tools/UpdateProductType.php new file mode 100644 index 0000000..73e18da --- /dev/null +++ b/src/tools/UpdateProductType.php @@ -0,0 +1,306 @@ +|null $siteSettings + * @return array + */ + public function __invoke( + /** The ID of the product type to update */ + int $productTypeId, + + /** The display name for the product type */ + ?string $name = null, + + /** The product type handle (machine-readable name) */ + ?string $handle = null, + + /** Whether products have a title field. If set to false, productTitleFormat is required. */ + ?bool $hasProductTitleField = null, + + /** Auto-generated title format for products when hasProductTitleField is false. */ + ?string $productTitleFormat = null, + + /** How product titles are translated: none, site, language, or custom. */ + ?string $productTitleTranslationMethod = null, + + /** Translation key format for custom product title translation. */ + ?string $productTitleTranslationKeyFormat = null, + + /** Whether variants have a title field. If set to false, variantTitleFormat is required. */ + ?bool $hasVariantTitleField = null, + + /** Auto-generated title format for variants when hasVariantTitleField is false. */ + ?string $variantTitleFormat = null, + + /** How variant titles are translated: none, site, language, or custom. */ + ?string $variantTitleTranslationMethod = null, + + /** Translation key format for custom variant title translation. */ + ?string $variantTitleTranslationKeyFormat = null, + + /** Whether to show the slug field in the admin UI. */ + ?bool $showSlugField = null, + + /** How slugs are translated: none, site, language, or custom. */ + ?string $slugTranslationMethod = null, + + /** Translation key format for custom slug translation. */ + ?string $slugTranslationKeyFormat = null, + + /** SKU format pattern. If set, SKUs are auto-generated. */ + ?string $skuFormat = null, + + /** Description format for the variant description. */ + ?string $descriptionFormat = null, + + /** Product page template path. */ + ?string $template = null, + + /** Whether products of this type track dimensions. */ + ?bool $hasDimensions = null, + + /** Maximum number of variants per product. Null for unlimited. */ + ?int $maxVariants = null, + + /** Whether to enable entry versioning for products. */ + ?bool $enableVersioning = null, + + /** Whether products use a hierarchical structure. */ + ?bool $isStructure = null, + + /** Maximum hierarchy levels (only for structure product types). */ + ?int $maxLevels = null, + + /** Where new products are placed by default (only for structure product types). */ + ?string $defaultPlacement = null, + + /** Field layout ID for product-level fields. */ + ?int $fieldLayoutId = null, + + /** Field layout ID for variant-level fields. */ + ?int $variantFieldLayoutId = null, + + /** + * Site-specific settings. Replaces existing site settings if provided. + * Each array entry contains: + * - siteId: Site ID (required) + * - enabledByDefault: Enable products by default for this site (optional) + * - hasUrls: Whether products have URLs on this site (optional) + * - uriFormat: URI format pattern (optional) + * - template: Template path for rendering products (optional) + */ + ?array $siteSettings = null, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $productType = $commerce->getProductTypes()->getProductTypeById($productTypeId); + + throw_unless( + $productType instanceof ProductType, + \InvalidArgumentException::class, + "Product type with ID {$productTypeId} not found", + ); + + // Update basic properties only if provided + if ($name !== null) { + $productType->name = $name; + } + if ($handle !== null) { + $productType->handle = $handle; + } + if ($hasProductTitleField !== null) { + $productType->hasProductTitleField = $hasProductTitleField; + } + if ($productTitleFormat !== null) { + $productType->productTitleFormat = $productTitleFormat; + } + if ($productTitleTranslationMethod !== null) { + $productType->productTitleTranslationMethod = $this->getTranslationMethodConstant($productTitleTranslationMethod); + } + if ($productTitleTranslationKeyFormat !== null) { + $productType->productTitleTranslationKeyFormat = $productTitleTranslationKeyFormat; + } + if ($hasVariantTitleField !== null) { + $productType->hasVariantTitleField = $hasVariantTitleField; + } + if ($variantTitleFormat !== null) { + $productType->variantTitleFormat = $variantTitleFormat; + } + if ($variantTitleTranslationMethod !== null) { + $productType->variantTitleTranslationMethod = $this->getTranslationMethodConstant($variantTitleTranslationMethod); + } + if ($variantTitleTranslationKeyFormat !== null) { + $productType->variantTitleTranslationKeyFormat = $variantTitleTranslationKeyFormat; + } + if ($showSlugField !== null) { + $productType->showSlugField = $showSlugField; + } + if ($slugTranslationMethod !== null) { + $productType->slugTranslationMethod = $this->getTranslationMethodConstant($slugTranslationMethod); + } + if ($slugTranslationKeyFormat !== null) { + $productType->slugTranslationKeyFormat = $slugTranslationKeyFormat; + } + if ($skuFormat !== null) { + $productType->skuFormat = $skuFormat; + } + if ($descriptionFormat !== null) { + $productType->descriptionFormat = $descriptionFormat; + } + if ($template !== null) { + $productType->template = $template; + } + if ($hasDimensions !== null) { + $productType->hasDimensions = $hasDimensions; + } + if ($maxVariants !== null) { + $productType->maxVariants = $maxVariants; + } + if ($enableVersioning !== null) { + $productType->enableVersioning = $enableVersioning; + } + if ($isStructure !== null) { + $productType->isStructure = $isStructure; + } + if ($maxLevels !== null && $productType->isStructure) { + $productType->maxLevels = $maxLevels > 0 ? $maxLevels : null; + } + if ($defaultPlacement !== null && $productType->isStructure) { + $productType->defaultPlacement = $this->getDefaultPlacement($defaultPlacement); + } + + // Validate title format requirements after updates + throw_if( + !$productType->hasProductTitleField && empty($productType->productTitleFormat), + \InvalidArgumentException::class, + "Product title format is required when hasProductTitleField is false.", + ); + throw_if( + !$productType->hasVariantTitleField && empty($productType->variantTitleFormat), + \InvalidArgumentException::class, + "Variant title format is required when hasVariantTitleField is false.", + ); + + // Update field layouts if provided + if ($fieldLayoutId !== null) { + $fieldLayout = Craft::$app->getFields()->getLayoutById($fieldLayoutId); + throw_unless($fieldLayout, \InvalidArgumentException::class, "Field layout with ID {$fieldLayoutId} not found"); + $productType->fieldLayoutId = $fieldLayoutId; + } + if ($variantFieldLayoutId !== null) { + $variantFieldLayout = Craft::$app->getFields()->getLayoutById($variantFieldLayoutId); + throw_unless($variantFieldLayout, \InvalidArgumentException::class, "Variant field layout with ID {$variantFieldLayoutId} not found"); + $productType->variantFieldLayoutId = $variantFieldLayoutId; + } + + // Update site settings if provided + if ($siteSettings !== null) { + $siteSettingsObjects = []; + foreach ($siteSettings as $siteData) { + assert(is_array($siteData), 'Site data must be an array'); + assert(is_int($siteData['siteId']), 'Site ID must be an integer'); + + $siteId = $siteData['siteId']; + $site = Craft::$app->getSites()->getSiteById($siteId); + throw_unless($site, "Site with ID {$siteId} not found"); + + $siteSettingsObjects[$siteId] = new ProductTypeSite([ + 'productTypeId' => $productType->id, + 'siteId' => $siteId, + 'enabledByDefault' => $siteData['enabledByDefault'] ?? true, + 'hasUrls' => $siteData['hasUrls'] ?? false, + 'uriFormat' => $siteData['uriFormat'] ?? null, + 'template' => $siteData['template'] ?? null, + ]); + } + + $productType->setSiteSettings($siteSettingsObjects); + } + + // Save the product type + throw_unless( + $commerce->getProductTypes()->saveProductType($productType), + ModelSaveException::class, + $productType, + ); + + return [ + '_notes' => 'The product type was successfully updated.', + 'id' => $productType->id, + 'name' => $productType->name, + 'handle' => $productType->handle, + 'fieldLayoutId' => $productType->fieldLayoutId, + 'variantFieldLayoutId' => $productType->variantFieldLayoutId, + 'hasProductTitleField' => $productType->hasProductTitleField, + 'productTitleFormat' => $productType->productTitleFormat, + 'hasVariantTitleField' => $productType->hasVariantTitleField, + 'variantTitleFormat' => $productType->variantTitleFormat, + 'skuFormat' => $productType->skuFormat, + 'hasDimensions' => $productType->hasDimensions, + 'maxVariants' => $productType->maxVariants, + 'enableVersioning' => $productType->enableVersioning, + 'editUrl' => $productType->getCpEditUrl(), + 'editVariantUrl' => $productType->getCpEditVariantUrl(), + ]; + } + + /** + * @return 'custom'|'language'|'none'|'site'|'siteGroup' + */ + private function getTranslationMethodConstant(string $method): string + { + $methodMap = [ + 'none' => \craft\base\Field::TRANSLATION_METHOD_NONE, + 'site' => \craft\base\Field::TRANSLATION_METHOD_SITE, + 'siteGroup' => \craft\base\Field::TRANSLATION_METHOD_SITE_GROUP, + 'language' => \craft\base\Field::TRANSLATION_METHOD_LANGUAGE, + 'custom' => \craft\base\Field::TRANSLATION_METHOD_CUSTOM, + ]; + + throw_unless( + isset($methodMap[$method]), + \InvalidArgumentException::class, + "Invalid translation method '{$method}'. Must be one of: " . implode(', ', array_keys($methodMap)), + ); + + return $methodMap[$method]; + } + + /** + * @return 'beginning'|'end' + */ + private function getDefaultPlacement(string $defaultPlacement): string + { + throw_unless( + in_array($defaultPlacement, ['beginning', 'end'], true), + \InvalidArgumentException::class, + 'defaultPlacement must be "beginning" or "end"', + ); + + return $defaultPlacement; + } +} diff --git a/src/tools/UpdateStore.php b/src/tools/UpdateStore.php new file mode 100644 index 0000000..fd6aebd --- /dev/null +++ b/src/tools/UpdateStore.php @@ -0,0 +1,147 @@ + + */ + public function __invoke( + int $storeId, + + /** Store display name. */ + ?string $name = null, + + /** Store currency code (e.g. USD, EUR). Cannot be changed after orders are placed. */ + ?string $currency = null, + + /** Whether to auto-set the user's primary addresses on new carts. */ + ?bool $autoSetNewCartAddresses = null, + + /** Whether to auto-set the first available shipping method on carts. */ + ?bool $autoSetCartShippingMethodOption = null, + + /** Whether to auto-set the user's primary payment source on new carts. */ + ?bool $autoSetPaymentSource = null, + + /** Whether carts are allowed to be empty on checkout. */ + ?bool $allowEmptyCartOnCheckout = null, + + /** Whether orders can be completed without a payment. */ + ?bool $allowCheckoutWithoutPayment = null, + + /** Whether partial payments are allowed from the front end. */ + ?bool $allowPartialPaymentOnCheckout = null, + + /** Whether a shipping address is required before payment. */ + ?bool $requireShippingAddressAtCheckout = null, + + /** Whether a billing address is required before payment. */ + ?bool $requireBillingAddressAtCheckout = null, + + /** Whether shipping method selection is required before payment. */ + ?bool $requireShippingMethodSelectionAtCheckout = null, + + /** Whether to use the billing address (instead of shipping) for tax calculations. */ + ?bool $useBillingAddressForTax = null, + + /** Whether to validate organizationTaxId as a VAT ID. */ + ?bool $validateOrganizationTaxIdAsVatId = null, + + /** Order reference number format template (e.g. "{{number[:7]}}"). */ + ?string $orderReferenceFormat = null, + + /** How free orders are handled: "complete" (immediately) or "process" (via gateway). */ + ?string $freeOrderPaymentStrategy = null, + + /** Minimum total price strategy: "default", "zero", or "shipping". */ + ?string $minimumTotalPriceStrategy = null, + ): array { + $commerce = Commerce::getInstance(); + throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + + $store = $commerce->getStores()->getStoreById($storeId); + + throw_unless($store instanceof Store, \InvalidArgumentException::class, "Store with ID {$storeId} not found"); + + if ($name !== null) { + $store->setName($name); + } + if ($currency !== null) { + $store->setCurrency($currency); + } + if ($autoSetNewCartAddresses !== null) { + $store->setAutoSetNewCartAddresses($autoSetNewCartAddresses); + } + if ($autoSetCartShippingMethodOption !== null) { + $store->setAutoSetCartShippingMethodOption($autoSetCartShippingMethodOption); + } + if ($autoSetPaymentSource !== null) { + $store->setAutoSetPaymentSource($autoSetPaymentSource); + } + if ($allowEmptyCartOnCheckout !== null) { + $store->setAllowEmptyCartOnCheckout($allowEmptyCartOnCheckout); + } + if ($allowCheckoutWithoutPayment !== null) { + $store->setAllowCheckoutWithoutPayment($allowCheckoutWithoutPayment); + } + if ($allowPartialPaymentOnCheckout !== null) { + $store->setAllowPartialPaymentOnCheckout($allowPartialPaymentOnCheckout); + } + if ($requireShippingAddressAtCheckout !== null) { + $store->setRequireShippingAddressAtCheckout($requireShippingAddressAtCheckout); + } + if ($requireBillingAddressAtCheckout !== null) { + $store->setRequireBillingAddressAtCheckout($requireBillingAddressAtCheckout); + } + if ($requireShippingMethodSelectionAtCheckout !== null) { + $store->setRequireShippingMethodSelectionAtCheckout($requireShippingMethodSelectionAtCheckout); + } + if ($useBillingAddressForTax !== null) { + $store->setUseBillingAddressForTax($useBillingAddressForTax); + } + if ($validateOrganizationTaxIdAsVatId !== null) { + $store->setValidateOrganizationTaxIdAsVatId($validateOrganizationTaxIdAsVatId); + } + if ($orderReferenceFormat !== null) { + $store->setOrderReferenceFormat($orderReferenceFormat); + } + if ($freeOrderPaymentStrategy !== null) { + $store->setFreeOrderPaymentStrategy($freeOrderPaymentStrategy); + } + if ($minimumTotalPriceStrategy !== null) { + $store->setMinimumTotalPriceStrategy($minimumTotalPriceStrategy); + } + + throw_unless( + $commerce->getStores()->saveStore($store), + "Failed to save store: " . implode(', ', $store->getFirstErrors()), + ); + + return [ + '_notes' => 'The store was successfully updated.', + 'id' => $store->id, + 'name' => $store->getName(), + 'handle' => $store->handle, + 'primary' => $store->primary, + 'currency' => $store->getCurrency()?->getCode(), + 'url' => $store->getStoreSettingsUrl(), + ]; + } +} diff --git a/src/tools/UpdateUser.php b/src/tools/UpdateUser.php new file mode 100644 index 0000000..e6b325a --- /dev/null +++ b/src/tools/UpdateUser.php @@ -0,0 +1,161 @@ + $fields + * @param list|null $groupIds + * @param list|null $groupHandles + * @param list|null $permissions + * @return array + */ + public function __invoke( + /** User ID to update. */ + ?int $userId = null, + + /** Resolve the user by current email address. */ + ?string $email = null, + + /** Resolve the user by current username. */ + ?string $username = null, + + /** New email address to set. */ + ?string $newEmail = null, + + /** New username to set. */ + ?string $newUsername = null, + + /** New password to set. */ + ?string $newPassword = null, + + /** Updated full name. */ + ?string $fullName = null, + + /** Updated first name. */ + ?string $firstName = null, + + /** Updated last name. */ + ?string $lastName = null, + + /** Updated admin flag. */ + ?bool $admin = null, + + /** Activate or deactivate the user. */ + ?bool $active = null, + + /** Updated pending flag. */ + ?bool $pending = null, + + /** Suspend or unsuspend the user. */ + ?bool $suspended = null, + + /** Set to false to unlock the user. */ + ?bool $locked = null, + + /** Updated affiliated site ID. */ + ?int $affiliatedSiteId = null, + + /** Replacement user group IDs. Requires Craft Team or Pro. */ + ?array $groupIds = null, + + /** Replacement user group handles. Requires Craft Team or Pro. */ + ?array $groupHandles = null, + + /** Replacement direct user permissions. Requires Craft Pro. Custom names are allowed. */ + ?array $permissions = null, + + /** Updated custom field values keyed by field handle. */ + array $fields = [], + ): array { + $user = ($this->resolveUser)($userId, $email, $username); + $wasActive = $user->active; + $wasLocked = $user->locked; + $wasSuspended = $user->suspended; + + $newEmail !== null && $user->email = $newEmail; + $newUsername !== null && $user->username = $newUsername; + $fullName !== null && $user->fullName = $fullName; + $firstName !== null && $user->firstName = $firstName; + $lastName !== null && $user->lastName = $lastName; + $admin !== null && $user->admin = $admin; + if ($pending !== null) { + $user->pending = $pending; + } + + if ($locked !== null) { + $user->locked = $locked; + } + + $affiliatedSiteId !== null && $user->affiliatedSiteId = $affiliatedSiteId; + + if ($newPassword !== null) { + $user->newPassword = $newPassword; + $user->setScenario(User::SCENARIO_PASSWORD); + } + + if ($fields !== []) { + $user->setFieldValues($fields); + } + + throw_unless($this->elementsService->saveElement($user, false), 'Failed to save user: ' . implode(', ', $user->getFirstErrors())); + + if ($locked === false && $wasLocked) { + $this->usersService->unlockUser($user); + } + + if ($suspended === true && !$wasSuspended) { + $this->usersService->suspendUser($user); + } elseif ($suspended === false && $wasSuspended) { + $this->usersService->unsuspendUser($user); + } + + if ($active === true && !$wasActive) { + $this->usersService->activateUser($user); + } elseif ($active === false && $wasActive) { + $this->usersService->deactivateUser($user); + } + + if ($groupIds !== null || $groupHandles !== null) { + throw_unless(Craft::$app->edition->value >= CmsEdition::Team->value, \InvalidArgumentException::class, 'Assigning users to groups requires Craft Team or Craft Pro.'); + $resolvedGroupIds = ($this->resolveUserGroupIds)($groupIds, $groupHandles); + throw_unless($this->usersService->assignUserToGroups((int) $user->id, $resolvedGroupIds), 'Failed to update user groups.'); + } + + if ($permissions !== null && $user->id !== null) { + throw_unless(Craft::$app->edition->value >= CmsEdition::Pro->value, \InvalidArgumentException::class, 'Assigning direct user permissions requires Craft Pro.'); + throw_unless(($this->saveUserPermissions)($user->id, $permissions), 'Failed to save user permissions.'); + } + + return [ + '_notes' => 'The user was successfully updated.', + ...($this->formatUser)($user), + ]; + } +} diff --git a/src/tools/UpdateUserGroup.php b/src/tools/UpdateUserGroup.php new file mode 100644 index 0000000..add313c --- /dev/null +++ b/src/tools/UpdateUserGroup.php @@ -0,0 +1,69 @@ +|null $permissions + * @return array + */ + public function __invoke( + /** User group ID to update. */ + ?int $groupId = null, + + /** User group handle to update. */ + ?string $handle = null, + + /** New display name. */ + ?string $newName = null, + + /** New handle. */ + ?string $newHandle = null, + + /** New description. */ + ?string $description = null, + + /** Replacement permissions, including custom permission names. */ + ?array $permissions = null, + ): array { + throw_unless(Craft::$app->edition->value >= CmsEdition::Pro->value, \InvalidArgumentException::class, 'Managing user groups requires Craft Pro.'); + + $group = ($this->resolveUserGroup)($groupId, $handle); + + $newName !== null && $group->name = $newName; + $newHandle !== null && $group->handle = $newHandle; + $description !== null && $group->description = $description; + + throw_unless($this->userGroupsService->saveGroup($group), 'Failed to save user group: ' . implode(', ', $group->getFirstErrors())); + + if ($permissions !== null) { + throw_unless(($this->saveUserGroupPermissions)($group, $permissions), 'Failed to save group permissions.'); + } + + return [ + '_notes' => 'The user group was successfully updated.', + ...($this->formatUserGroup)($group), + ]; + } +} diff --git a/src/tools/UpdateVariant.php b/src/tools/UpdateVariant.php new file mode 100644 index 0000000..71532fc --- /dev/null +++ b/src/tools/UpdateVariant.php @@ -0,0 +1,129 @@ +getStock(); + } + + /** + * Update an existing Commerce product variant. + * + * Updates the variant's pricing, SKU, inventory, dimensions, and custom field values. + * Use bracket notation for field data on the CLI: + * agent-craft variants/update 456 --price=29.99 --sku="WIDGET-LG" + * + * @param array $fields Custom field data keyed by field handle. + * @return array + */ + public function __invoke( + int $variantId, + + /** Variant SKU. */ + ?string $sku = null, + + /** Variant price. */ + ?float $price = null, + + /** Variant title. */ + ?string $title = null, + + /** Minimum purchase quantity. */ + ?int $minQty = null, + + /** Maximum purchase quantity. */ + ?int $maxQty = null, + + /** Variant weight. */ + ?float $weight = null, + + /** Variant height. */ + ?float $height = null, + + /** Variant length. */ + ?float $length = null, + + /** Variant width. */ + ?float $width = null, + + /** Whether the variant qualifies for free shipping. */ + ?bool $freeShipping = null, + + /** Whether inventory is tracked for this variant. */ + ?bool $inventoryTracked = null, + + /** Custom field data keyed by field handle. */ + array $fields = [], + ): array { + $variant = Craft::$app->getElements()->getElementById($variantId, Variant::class); + + throw_unless($variant instanceof Variant, \InvalidArgumentException::class, "Variant with ID {$variantId} not found"); + + if ($sku !== null) { + $variant->sku = $sku; + } + if ($price !== null) { + $variant->basePrice = $price; + } + if ($title !== null) { + $variant->title = $title; + } + if ($minQty !== null) { + $variant->minQty = $minQty; + } + if ($maxQty !== null) { + $variant->maxQty = $maxQty; + } + if ($weight !== null) { + $variant->weight = $weight; + } + if ($height !== null) { + $variant->height = $height; + } + if ($length !== null) { + $variant->length = $length; + } + if ($width !== null) { + $variant->width = $width; + } + if ($freeShipping !== null) { + $variant->freeShipping = $freeShipping; + } + if ($inventoryTracked !== null) { + $variant->inventoryTracked = $inventoryTracked; + } + if (!empty($fields)) { + $variant->setFieldValues($fields); + } + + throw_unless( + Craft::$app->getElements()->saveElement($variant), + "Failed to save variant: " . implode(', ', $variant->getFirstErrors()), + ); + + // Re-fetch to get fresh values (price getter uses cached _price that may be stale) + $variant = Craft::$app->getElements()->getElementById($variantId, Variant::class); + throw_unless($variant instanceof Variant, \RuntimeException::class, "Failed to reload variant with ID {$variantId} after update"); + + $product = $variant->getOwner(); + + return [ + '_notes' => 'The variant was successfully updated.', + 'variantId' => $variant->id, + 'title' => $variant->title, + 'sku' => $variant->sku, + 'price' => (float) $variant->price, + 'stock' => $this->getVariantStock($variant), + 'productId' => $product instanceof Product ? $product->id : null, + 'url' => $product instanceof Product ? ElementHelper::elementEditorUrl($product) : null, + ]; + } +} diff --git a/stubs/project/project.yaml b/stubs/project/project.yaml index 3b10f44..626e23d 100644 --- a/stubs/project/project.yaml +++ b/stubs/project/project.yaml @@ -1,40 +1,142 @@ +commerce: + gateways: + a1b2c3d4-0001-4000-8000-000000000001: + billingAddressCondition: + class: craft\elements\conditions\addresses\AddressCondition + conditionRules: [] + handle: dummy + isFrontendEnabled: true + name: Dummy + orderCondition: + class: craft\commerce\elements\conditions\orders\OrderCondition + conditionRules: [] + paymentType: purchase + settings: [] + shippingAddressCondition: + class: craft\elements\conditions\addresses\AddressCondition + conditionRules: [] + sortOrder: 99 + type: craft\commerce\gateways\Dummy + orderStatuses: + a1b2c3d4-0004-4000-8000-000000000004: + color: green + default: true + description: "" + emails: [] + handle: new + name: New + sortOrder: 1 + store: a1b2c3d4-0002-4000-8000-000000000002 + productTypes: + a1b2c3d4-0005-4000-8000-000000000005: + defaultPlacement: end + descriptionFormat: "" + enableVersioning: true + hasDimensions: false + hasProductTitleField: true + hasVariantTitleField: false + isStructure: false + maxLevels: null + maxVariants: 1 + name: General + handle: general + productFieldLayouts: + a1b2c3d4-0006-4000-8000-000000000006: + tabs: + - elements: [] + name: Content + uid: a1b2c3d4-0008-4000-8000-000000000008 + productTitleFormat: "" + productTitleTranslationKeyFormat: null + productTitleTranslationMethod: site + propagationMethod: all + showSlugField: true + siteSettings: + 08cf53bc-9c73-4602-aad2-ce7a0f84c80a: + enabledByDefault: true + hasUrls: true + template: "" + uriFormat: "products/{slug}" + skuFormat: "" + slugTranslationKeyFormat: null + slugTranslationMethod: site + variantFieldLayouts: + a1b2c3d4-0007-4000-8000-000000000007: + tabs: + - elements: [] + name: Content + uid: a1b2c3d4-0009-4000-8000-000000000009 + variantTitleFormat: "{product.title}" + variantTitleTranslationKeyFormat: null + variantTitleTranslationMethod: site + sitestores: + 08cf53bc-9c73-4602-aad2-ce7a0f84c80a: + store: a1b2c3d4-0002-4000-8000-000000000002 + stores: + a1b2c3d4-0002-4000-8000-000000000002: + allowCheckoutWithoutPayment: false + allowEmptyCartOnCheckout: false + allowPartialPaymentOnCheckout: false + autoSetCartShippingMethodOption: false + autoSetNewCartAddresses: false + autoSetPaymentSource: false + currency: USD + freeOrderPaymentStrategy: complete + handle: primary + minimumTotalPriceStrategy: default + name: Primary + orderReferenceFormat: "{{number[:7]}}" + primary: true + requireBillingAddressAtCheckout: false + requireShippingAddressAtCheckout: false + requireShippingMethodSelectionAtCheckout: false + sortOrder: 1 + useBillingAddressForTax: false + validateOrganizationTaxIdAsVatId: false dateModified: 1755877515 email: fromEmail: mark@markhuot.com - fromName: 'Craft MCP' + fromName: "Craft MCP" transportType: craft\mail\transportadapters\Sendmail fs: local: hasUrls: true name: Local settings: - path: '@webroot/uploads' + path: "@webroot/uploads" type: craft\fs\Local url: /uploads meta: __names__: - 1f9526e6-4d44-4be8-92cd-a0cfb70068d7: 'Call to Action' # Call to Action - 7d37625f-d2aa-4360-b455-6d18c157e09b: 'Content Builder' # Content Builder - 08cf53bc-9c73-4602-aad2-ce7a0f84c80a: 'Craft MCP' # Craft MCP + 1f9526e6-4d44-4be8-92cd-a0cfb70068d7: "Call to Action" # Call to Action + 7d37625f-d2aa-4360-b455-6d18c157e09b: "Content Builder" # Content Builder + 08cf53bc-9c73-4602-aad2-ce7a0f84c80a: "Craft MCP" # Craft MCP 335d438b-81cf-4ec9-ad33-c9b7ba132834: Pages # Pages 382d8072-8308-439f-9afd-b875b7ee9ecd: Heading # Heading - 892e4ee8-6251-45a9-9ad9-04d29a9373e2: 'Craft MCP' # Craft MCP + 892e4ee8-6251-45a9-9ad9-04d29a9373e2: "Craft MCP" # Craft MCP 7790cda1-f85b-4b5c-89c0-f8e238766abf: Pages # Pages 13160d5b-88a8-44a7-b6d0-4c2c68ec8ca1: Uploads # Uploads - 20570e66-f61c-404f-943f-84834172db54: 'Plain Text' # Plain Text + 20570e66-f61c-404f-943f-84834172db54: "Plain Text" # Plain Text becc7b32-bddb-48a6-baa7-187821980912: News # News c719c9ad-801a-4271-99c8-b44760c4e5f6: News # News df0b8488-6354-4289-8d3d-ba10a9650e2e: Body # Body + a1b2c3d4-0001-4000-8000-000000000001: Dummy # Dummy gateway + a1b2c3d4-0002-4000-8000-000000000002: Primary # Primary store + a1b2c3d4-0005-4000-8000-000000000005: General # General product type plugins: + commerce: + edition: pro + enabled: true + schemaVersion: 5.6.0.0 skills: edition: standard enabled: true schemaVersion: 1.0.0 system: - edition: solo + edition: pro live: true - name: 'Craft MCP' - schemaVersion: 5.8.0.3 + name: "Craft MCP" + schemaVersion: 5.9.0.8 timeZone: America/Los_Angeles users: allowPublicRegistration: false diff --git a/tests/AddressFieldLayoutMutationTest.php b/tests/AddressFieldLayoutMutationTest.php new file mode 100644 index 0000000..abc40a4 --- /dev/null +++ b/tests/AddressFieldLayoutMutationTest.php @@ -0,0 +1,78 @@ +getLayout = Craft::$container->get(GetAddressFieldLayout::class); + $this->addTab = Craft::$container->get(AddTabToFieldLayout::class); + $this->addUiElement = Craft::$container->get(AddUiElementToFieldLayout::class); + $this->removeElement = Craft::$container->get(RemoveElementFromFieldLayout::class); +}); + +it('retrieves address layout id that can be reused by field layout tools', function () { + $response = $this->getLayout->__invoke(); + + expect($response['fieldLayout']['id'])->toBe(GetAddressFieldLayout::PLACEHOLDER_ID); + expect($response['fieldLayout']['type'])->toBe(Address::class); +}); + +it('can add a tab to the address field layout', function () { + $layout = $this->getLayout->__invoke(); + $fieldLayoutId = $layout['fieldLayout']['id']; + + $result = $this->addTab->__invoke( + fieldLayoutId: $fieldLayoutId, + name: 'Address Test Tab', + position: ['type' => 'append'], + ); + + expect(collect($result['fieldLayout']['tabs'])->pluck('name'))->toContain('Address Test Tab'); +}); + +it('can add an address-native ui element to the address field layout', function () { + $layout = $this->getLayout->__invoke(); + $fieldLayoutId = $layout['fieldLayout']['id']; + + $tabNames = collect($layout['fieldLayout']['tabs'])->pluck('name'); + $tabName = $tabNames->first() ?? 'Content'; + + $result = $this->addUiElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementType: LabelField::class, + tabName: $tabName, + position: ['type' => 'append'], + ); + + $addedUid = $result['addedElement']['uid']; + expect($addedUid)->toBeString(); + + $updatedLayout = $this->getLayout->__invoke(); + $elements = collect($updatedLayout['fieldLayout']['tabs'])->flatMap(fn(array $tab) => $tab['elements']); + expect($elements->pluck('uid'))->toContain($addedUid); +}); + +it('can remove an address layout element after adding it', function () { + $layout = $this->getLayout->__invoke(); + $fieldLayoutId = $layout['fieldLayout']['id']; + $tabName = collect($layout['fieldLayout']['tabs'])->pluck('name')->first() ?? 'Content'; + + $added = $this->addUiElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementType: LabelField::class, + tabName: $tabName, + position: ['type' => 'append'], + ); + + $result = $this->removeElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementUid: $added['addedElement']['uid'], + ); + + $elements = collect($result['fieldLayout']['tabs'])->flatMap(fn(array $tab) => $tab['elements']); + expect($elements->pluck('uid'))->not->toContain($added['addedElement']['uid']); +}); diff --git a/tests/AddressTestHelpers.php b/tests/AddressTestHelpers.php new file mode 100644 index 0000000..9ea4604 --- /dev/null +++ b/tests/AddressTestHelpers.php @@ -0,0 +1,108 @@ +status(null) + ->site('*') + ->one(); + + if ($existingUser instanceof User) { + return $existingUser; + } + + /** @var User $user */ + $user = UserFactory::factory()->make(); + $user->admin = true; + $user->active = true; + $user->pending = false; + $user->newPassword = 'Password123!'; + $user->setScenario(User::SCENARIO_REGISTRATION); + + $saved = Craft::$app->getElements()->saveElement($user, false); + if (!$saved) { + throw new RuntimeException(implode(' ', $user->getErrorSummary(false)) ?: 'Failed to create test user owner.'); + } + + expect($user->id)->toBeInt(); + + return $user; +} + +/** + * @return array{entry: Entry, field: AddressesField} + */ +function createTestEntryOwnerWithAddressesField(): array +{ + /** @var AddressesField $field */ + $field = FieldFactory::factory() + ->type(AddressesField::class) + ->name('Address Book') + ->handle('addressBook') + ->create(); + + $section = SectionFactory::factory()->fields($field)->create(); + + /** @var Entry $entry */ + $entry = EntryFactory::factory() + ->section($section) + ->type($section->getEntryTypes()[0]) + ->create(); + + return [ + 'entry' => $entry, + 'field' => $field, + ]; +} + +function createUserOwnedAddress(User $user): Address +{ + $address = new Address(); + $address->setOwner($user); + $address->setPrimaryOwner($user); + $address->siteId = $user->siteId; + $address->setScenario(Address::SCENARIO_LIVE); + $address->countryCode = 'US'; + $address->title = 'Home'; + $address->fullName = 'Test User'; + $address->addressLine1 = '123 Main St'; + $address->locality = 'Portland'; + $address->administrativeArea = 'OR'; + $address->postalCode = '97205'; + + $saved = Craft::$app->getElements()->saveElement($address); + expect($saved)->toBeTrue(); + + return $address; +} + +function createFieldOwnedAddress(Entry $entry, AddressesField $field): Address +{ + $address = new Address(); + $address->fieldId = $field->id; + $address->setOwner($entry); + $address->setPrimaryOwner($entry); + $address->siteId = $entry->siteId; + $address->setScenario(Address::SCENARIO_LIVE); + $address->countryCode = 'US'; + $address->title = 'Office'; + $address->fullName = 'Entry Owner'; + $address->addressLine1 = '500 Market St'; + $address->locality = 'San Francisco'; + $address->administrativeArea = 'CA'; + $address->postalCode = '94105'; + + $saved = Craft::$app->getElements()->saveElement($address); + expect($saved)->toBeTrue(); + + return $address; +} diff --git a/tests/ApplyDraftTest.php b/tests/ApplyDraftTest.php index 38e71c3..ff69a80 100644 --- a/tests/ApplyDraftTest.php +++ b/tests/ApplyDraftTest.php @@ -6,7 +6,8 @@ use happycog\craftmcp\tools\UpdateDraft; beforeEach(function () { - $this->section = Craft::$app->getEntries()->getAllSections()[0]; + $this->section = Craft::$app->getEntries()->getSectionByHandle('news'); + expect($this->section)->not->toBeNull(); $this->sectionId = $this->section->id; $this->entryTypeId = $this->section->getEntryTypes()[0]->id; @@ -171,4 +172,4 @@ expect(fn() => $applyDraft->__invoke($draftId)) ->toThrow(\InvalidArgumentException::class, 'Draft with ID ' . $draftId . ' does not exist'); -}); \ No newline at end of file +}); diff --git a/tests/ArgumentParserTest.php b/tests/ArgumentParserTest.php index 0bce654..0148882 100644 --- a/tests/ArgumentParserTest.php +++ b/tests/ArgumentParserTest.php @@ -31,7 +31,7 @@ $result = $parser->parse(['script', 'entries/get', '123']); expect($result['command'])->toBe('entries/get'); - expect($result['positional'])->toBe([123]); + expect($result['positional'])->toBe(['123']); expect($result['flags'])->toBe([]); }); @@ -43,23 +43,23 @@ expect($result['positional'])->toBe(['foo', 'bar', 'baz']); }); -test('detects numeric positional arguments as integers', function () { +test('keeps numeric positional arguments as strings', function () { $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '123', '456']); - expect($result['positional'])->toBe([123, 456]); - expect($result['positional'][0])->toBeInt(); - expect($result['positional'][1])->toBeInt(); + expect($result['positional'])->toBe(['123', '456']); + expect($result['positional'][0])->toBeString(); + expect($result['positional'][1])->toBeString(); }); test('handles mixed string and numeric positional arguments', function () { $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '123', 'foo', '456']); - expect($result['positional'])->toBe([123, 'foo', 456]); - expect($result['positional'][0])->toBeInt(); + expect($result['positional'])->toBe(['123', 'foo', '456']); + expect($result['positional'][0])->toBeString(); expect($result['positional'][1])->toBeString(); - expect($result['positional'][2])->toBeInt(); + expect($result['positional'][2])->toBeString(); }); // Test group 3: Simple flags @@ -71,12 +71,12 @@ expect($result['flags'])->toBe(['title' => 'Test Entry']); }); -test('parses numeric flag as integer', function () { +test('keeps numeric flag as string', function () { $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '--id=123']); - expect($result['flags'])->toBe(['id' => 123]); - expect($result['flags']['id'])->toBeInt(); + expect($result['flags'])->toBe(['id' => '123']); + expect($result['flags']['id'])->toBeString(); }); test('parses boolean flag with true value', function () { @@ -109,7 +109,7 @@ expect($result['flags'])->toBe([ 'title' => 'Test', - 'id' => 123, + 'id' => '123', 'enabled' => true, ]); }); @@ -163,7 +163,7 @@ $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '--ids=1,2,3']); - expect($result['flags']['ids'])->toBe([1, 2, 3]); + expect($result['flags']['ids'])->toBe(['1', '2', '3']); expect($result['flags']['ids'])->toBeArray(); }); @@ -179,8 +179,8 @@ $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '--values=1,foo,true,false']); - expect($result['flags']['values'])->toBe([1, 'foo', true, false]); - expect($result['flags']['values'][0])->toBeInt(); + expect($result['flags']['values'])->toBe(['1', 'foo', true, false]); + expect($result['flags']['values'][0])->toBeString(); expect($result['flags']['values'][1])->toBeString(); expect($result['flags']['values'][2])->toBeTrue(); expect($result['flags']['values'][3])->toBeFalse(); @@ -397,8 +397,8 @@ ]); expect($result['command'])->toBe('entries/create'); - expect($result['flags']['sectionId'])->toBe(1); - expect($result['flags']['entryTypeId'])->toBe(2); + expect($result['flags']['sectionId'])->toBe('1'); + expect($result['flags']['entryTypeId'])->toBe('2'); expect($result['flags']['title'])->toBe('Test Entry'); expect($result['flags']['fields']['body'])->toBe('

HTML content

'); expect($result['flags']['tags'])->toBe(['tag1', 'tag2', 'tag3']); @@ -423,7 +423,7 @@ $result = $parser->parse(['script', 'entries/get', '123']); expect($result['command'])->toBe('entries/get'); - expect($result['positional'])->toBe([123]); + expect($result['positional'])->toBe(['123']); }); test('parses complex command with all flag types', function () { @@ -442,12 +442,12 @@ ]); expect($result['command'])->toBe('entries/update'); - expect($result['positional'])->toBe([456]); + expect($result['positional'])->toBe(['456']); expect($result['flags']['title'])->toBe('Updated Title'); expect($result['flags']['enabled'])->toBeTrue(); - expect($result['flags']['siteId'])->toBe(1); + expect($result['flags']['siteId'])->toBe('1'); expect($result['flags']['fields']['body'])->toBe('New content'); - expect($result['flags']['relatedEntries'])->toBe([1, 2, 3]); + expect($result['flags']['relatedEntries'])->toBe(['1', '2', '3']); expect($result['verbosity'])->toBe(2); expect($result['path'])->toBe('/custom/craft'); }); @@ -480,7 +480,7 @@ expect($result['command'])->toBeNull(); expect($result['flags'])->toBe([ 'foo' => 'bar', - 'baz' => 123, + 'baz' => '123', ]); }); @@ -489,7 +489,7 @@ $result = $parser->parse(['script', 'cmd', '123', '--title=Test', '456', '--enabled']); expect($result['command'])->toBe('cmd'); - expect($result['positional'])->toBe([123, 456]); + expect($result['positional'])->toBe(['123', '456']); expect($result['flags'])->toBe([ 'title' => 'Test', 'enabled' => true, @@ -520,20 +520,20 @@ expect($result['flags']['value'])->toBeString(); }); -test('handles zero as numeric value', function () { +test('handles zero as string value', function () { $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '--count=0']); - expect($result['flags']['count'])->toBe(0); - expect($result['flags']['count'])->toBeInt(); + expect($result['flags']['count'])->toBe('0'); + expect($result['flags']['count'])->toBeString(); }); -test('handles negative numbers', function () { +test('handles negative numbers as strings', function () { $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '--offset=-10']); - expect($result['flags']['offset'])->toBe(-10); - expect($result['flags']['offset'])->toBeInt(); + expect($result['flags']['offset'])->toBe('-10'); + expect($result['flags']['offset'])->toBeString(); }); test('handles URL as string value', function () { @@ -570,7 +570,7 @@ $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '--id', '123']); - expect($result['flags']['id'])->toBe(123); + expect($result['flags']['id'])->toBe('123'); }); test('parses space-separated flag with boolean string value', function () { @@ -594,7 +594,7 @@ expect($result['flags']['type'])->toBe('single'); expect($result['flags']['name'])->toBe('Test'); - expect($result['flags']['id'])->toBe(123); + expect($result['flags']['id'])->toBe('123'); }); test('treats flag without value as boolean when next arg is flag', function () { @@ -618,8 +618,8 @@ $result = $parser->parse(['script', 'entries/get', '--siteId', '2', '123']); expect($result['command'])->toBe('entries/get'); - expect($result['flags']['siteId'])->toBe(2); - expect($result['positional'])->toBe([123]); + expect($result['flags']['siteId'])->toBe('2'); + expect($result['positional'])->toBe(['123']); }); test('parses space-separated flag after positional args', function () { @@ -627,15 +627,15 @@ $result = $parser->parse(['script', 'entries/get', '123', '--siteId', '2']); expect($result['command'])->toBe('entries/get'); - expect($result['positional'])->toBe([123]); - expect($result['flags']['siteId'])->toBe(2); + expect($result['positional'])->toBe(['123']); + expect($result['flags']['siteId'])->toBe('2'); }); test('parses space-separated flag with comma-separated value', function () { $parser = new ArgumentParser(); $result = $parser->parse(['script', 'cmd', '--ids', '1,2,3']); - expect($result['flags']['ids'])->toBe([1, 2, 3]); + expect($result['flags']['ids'])->toBe(['1', '2', '3']); }); test('parses space-separated flag with bracket notation', function () { diff --git a/tests/CommerceNotInstalledTest.php b/tests/CommerceNotInstalledTest.php new file mode 100644 index 0000000..03bc5ba --- /dev/null +++ b/tests/CommerceNotInstalledTest.php @@ -0,0 +1,208 @@ +markTestSkipped('Craft Commerce is not installed.'); + } +}); + +it('throw_unless guard produces RuntimeException with correct message', function () { + // Verify the guard pattern used by all Commerce tools: + // throw_unless($commerce, 'Craft Commerce is not installed or enabled.'); + // When $commerce is null, throw_unless throws a RuntimeException. + expect(fn () => throw_unless(null, 'Craft Commerce is not installed or enabled.')) + ->toThrow(\RuntimeException::class, 'Craft Commerce is not installed or enabled.'); +}); + +// Test each tool class that has the Commerce guard can be instantiated +// and works correctly when Commerce IS installed (no false positive guard triggers) + +it('GetStores works when Commerce is installed', function () { + $tool = Craft::$container->get(GetStores::class); + $response = $tool->__invoke(); + expect($response)->toHaveKey('stores'); +}); + +it('GetStore works when Commerce is installed', function () { + $tool = Craft::$container->get(GetStore::class); + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getPrimaryStore(); + + $response = $tool->__invoke(storeId: $store->id); + expect($response)->toHaveKey('id'); +}); + +it('UpdateStore works when Commerce is installed', function () { + $tool = Craft::$container->get(UpdateStore::class); + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getPrimaryStore(); + + $response = $tool->__invoke(storeId: $store->id); + expect($response)->toHaveKey('id'); +}); + +it('GetProductTypes works when Commerce is installed', function () { + $tool = Craft::$container->get(GetProductTypes::class); + $response = $tool->__invoke(); + expect($response)->toHaveKey('productTypes'); +}); + +it('GetProductType works when Commerce is installed', function () { + $tool = Craft::$container->get(GetProductType::class); + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $response = $tool->__invoke(productTypeId: $productTypes[0]->id); + expect($response)->toHaveKey('id'); +}); + +it('GetOrderStatuses works when Commerce is installed', function () { + $tool = Craft::$container->get(GetOrderStatuses::class); + $response = $tool->__invoke(); + expect($response)->toHaveKey('orderStatuses'); +}); + +it('GetProducts works when Commerce is installed', function () { + $tool = Craft::$container->get(GetProducts::class); + $response = $tool->__invoke(); + expect($response)->toHaveKey('results'); +}); + +it('SearchOrders works when Commerce is installed', function () { + $tool = Craft::$container->get(SearchOrders::class); + $response = $tool->__invoke(); + expect($response)->toHaveKey('results'); +}); + +// Tools with required parameters — test they don't throw the Commerce guard error +// (they should throw parameter-related errors, not the "not installed" error) + +it('CreateProduct does not throw Commerce guard when Commerce is installed', function () { + $tool = Craft::$container->get(CreateProduct::class); + + try { + // This will likely fail due to missing required data, but should NOT fail + // with "Craft Commerce is not installed or enabled" + $tool->__invoke(productTypeId: 1, title: 'test', sku: 'test-sku', price: 9.99); + } catch (\RuntimeException $e) { + expect($e->getMessage())->not->toBe('Craft Commerce is not installed or enabled.'); + } catch (\Throwable $e) { + // Any other error is fine — it means the Commerce guard passed + expect(true)->toBeTrue(); + } +}); + +it('CreateProductType does not throw Commerce guard when Commerce is installed', function () { + $tool = Craft::$container->get(CreateProductType::class); + + try { + $tool->__invoke(name: 'Guard Test Type', handle: 'guardTestType' . random_int(10000, 99999)); + } catch (\RuntimeException $e) { + expect($e->getMessage())->not->toBe('Craft Commerce is not installed or enabled.'); + } catch (\Throwable $e) { + expect(true)->toBeTrue(); + } +}); + +it('UpdateProductType does not throw Commerce guard when Commerce is installed', function () { + $tool = Craft::$container->get(UpdateProductType::class); + + try { + $tool->__invoke(productTypeId: 99999); + } catch (\RuntimeException $e) { + expect($e->getMessage())->not->toBe('Craft Commerce is not installed or enabled.'); + } catch (\Throwable $e) { + expect(true)->toBeTrue(); + } +}); + +it('DeleteProductType does not throw Commerce guard when Commerce is installed', function () { + $tool = Craft::$container->get(DeleteProductType::class); + + try { + $tool->__invoke(productTypeId: 99999); + } catch (\RuntimeException $e) { + expect($e->getMessage())->not->toBe('Craft Commerce is not installed or enabled.'); + } catch (\Throwable $e) { + expect(true)->toBeTrue(); + } +}); + +it('GetOrder does not throw Commerce guard when Commerce is installed', function () { + $tool = Craft::$container->get(GetOrder::class); + + try { + $tool->__invoke(orderId: 99999); + } catch (\RuntimeException $e) { + expect($e->getMessage())->not->toBe('Craft Commerce is not installed or enabled.'); + } catch (\Throwable $e) { + expect(true)->toBeTrue(); + } +}); + +it('UpdateOrder does not throw Commerce guard when Commerce is installed', function () { + $tool = Craft::$container->get(UpdateOrder::class); + + try { + $tool->__invoke(orderId: 99999); + } catch (\RuntimeException $e) { + expect($e->getMessage())->not->toBe('Craft Commerce is not installed or enabled.'); + } catch (\Throwable $e) { + expect(true)->toBeTrue(); + } +}); + +// Verify all Commerce tool classes can be resolved from the DI container +it('all Commerce tool classes are resolvable from container', function () { + $commerceTools = [ + GetStores::class, + GetStore::class, + UpdateStore::class, + GetProductTypes::class, + GetProductType::class, + CreateProductType::class, + UpdateProductType::class, + DeleteProductType::class, + GetOrderStatuses::class, + GetProducts::class, + SearchOrders::class, + GetOrder::class, + UpdateOrder::class, + CreateProduct::class, + ]; + + foreach ($commerceTools as $toolClass) { + $tool = Craft::$container->get($toolClass); + expect($tool)->toBeInstanceOf($toolClass); + } +}); diff --git a/tests/CreateAddressTest.php b/tests/CreateAddressTest.php new file mode 100644 index 0000000..e17133b --- /dev/null +++ b/tests/CreateAddressTest.php @@ -0,0 +1,69 @@ +tool = Craft::$container->get(CreateAddress::class); +}); + +it('creates a user-owned address', function () { + $user = createTestUserOwner(); + + $response = $this->tool->__invoke( + ownerId: $user->id, + ownerType: $user::class, + title: 'Home', + countryCode: 'US', + addressLine1: '123 Main St', + locality: 'Portland', + administrativeArea: 'OR', + postalCode: '97205', + ); + + expect($response['_notes'])->toBe('The address was successfully created.'); + expect($response['addressId'])->toBeInt(); + expect($response['ownerId'])->toBe($user->id); + expect($response['fieldId'])->toBeNull(); +}); + +it('creates an address attached to an addresses field on an entry', function () { + ['entry' => $entry, 'field' => $field] = createTestEntryOwnerWithAddressesField(); + + $response = $this->tool->__invoke( + ownerId: $entry->id, + ownerType: $entry::class, + fieldId: $field->id, + title: 'Office', + countryCode: 'US', + addressLine1: '500 Market St', + locality: 'San Francisco', + administrativeArea: 'CA', + postalCode: '94105', + ); + + expect($response['addressId'])->toBeInt(); + expect($response['ownerId'])->toBe($entry->id); + expect($response['fieldId'])->toBe($field->id); + expect($response['fieldHandle'])->toBe($field->handle); +}); + +it('throws when owner is not found', function () { + expect(fn() => $this->tool->__invoke( + ownerId: 99999, + ownerType: \craft\elements\User::class, + countryCode: 'US', + ))->toThrow(\InvalidArgumentException::class, 'Owner craft\\elements\\User with ID 99999 not found'); +}); + +it('throws when addresses field is not attached to owner', function () { + $user = createTestUserOwner(); + ['field' => $field] = createTestEntryOwnerWithAddressesField(); + $expectedMessage = "Field {$field->handle} is not attached to owner " . $user::class . "#{$user->id}"; + + expect(fn() => $this->tool->__invoke( + ownerId: $user->id, + ownerType: $user::class, + fieldId: $field->id, + countryCode: 'US', + ))->toThrow(\InvalidArgumentException::class, $expectedMessage); +}); diff --git a/tests/CreateDraftTest.php b/tests/CreateDraftTest.php index 09ae2a3..efe3f55 100644 --- a/tests/CreateDraftTest.php +++ b/tests/CreateDraftTest.php @@ -4,7 +4,8 @@ use happycog\craftmcp\tools\CreateEntry; beforeEach(function () { - $this->section = Craft::$app->getEntries()->getAllSections()[0]; + $this->section = Craft::$app->getEntries()->getSectionByHandle('news'); + expect($this->section)->not->toBeNull(); $this->sectionId = $this->section->id; $this->entryTypeId = $this->section->getEntryTypes()[0]->id; @@ -126,4 +127,4 @@ ); expect($response['siteId'])->toBe($primarySiteId); -}); \ No newline at end of file +}); diff --git a/tests/CreateEntryTest.php b/tests/CreateEntryTest.php index e0538b5..4452b3a 100644 --- a/tests/CreateEntryTest.php +++ b/tests/CreateEntryTest.php @@ -63,7 +63,8 @@ }); it('throws exception for invalid siteId', function () { - $section = Craft::$app->getEntries()->getAllSections()[0]; + $section = Craft::$app->getEntries()->getSectionByHandle('news'); + expect($section)->not->toBeNull(); $sectionId = $section->id; $entryTypeId = $section->getEntryTypes()[0]->id; diff --git a/tests/CreateFieldTest.php b/tests/CreateFieldTest.php index 3772887..4aea49b 100644 --- a/tests/CreateFieldTest.php +++ b/tests/CreateFieldTest.php @@ -269,11 +269,12 @@ expect($field->minEntries)->toBe(2); expect($field->maxEntries)->toBe(20); expect($field->viewMode)->toBe('blocks'); - expect($field->showCardsInGrid)->toBeTrue(); + // removed in 5.9 + //expect($field->showCardsInGrid)->toBeTrue(); expect($field->createButtonLabel)->toBe('Add New Block'); // Verify entry type is attached $entryTypes = $field->getEntryTypes(); expect($entryTypes)->toHaveCount(1); expect($entryTypes[0]->handle)->toBe('contentBlock'); -}); \ No newline at end of file +}); diff --git a/tests/CreateProductTest.php b/tests/CreateProductTest.php new file mode 100644 index 0000000..ef2ec82 --- /dev/null +++ b/tests/CreateProductTest.php @@ -0,0 +1,154 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(CreateProduct::class); + + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $this->productType = $productTypes[0]; +}); + +it('creates a product with required fields', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'New Test Product', + sku: 'CREATE-PROD-001', + price: 29.99, + ); + + expect($response['_notes'])->toBe('The product was successfully created.'); + expect($response['productId'])->toBeInt(); + expect($response['title'])->toBe('New Test Product'); + expect($response['status'])->toBe('live'); + expect($response['typeId'])->toBe($this->productType->id); + expect($response['typeName'])->toBe($this->productType->name); + expect($response['defaultSku'])->toBe('CREATE-PROD-001'); + expect($response['defaultPrice'])->toBeNumeric(); + expect($response['url'])->toBeString(); +}); + +it('creates a product with custom slug', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'Slug Test Product', + sku: 'CREATE-PROD-SLUG', + price: 10.00, + slug: 'my-custom-slug', + ); + + expect($response['slug'])->toBe('my-custom-slug'); +}); + +it('creates a product with postDate', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'PostDate Product', + sku: 'CREATE-PROD-PD', + price: 10.00, + postDate: '2025-06-15T12:00:00+00:00', + ); + + expect($response['productId'])->toBeInt(); + + // Verify the date was set (use midday to avoid timezone date-shift issues) + $product = Craft::$app->getElements()->getElementById($response['productId'], \craft\commerce\elements\Product::class); + expect($product->postDate->format('Y-m-d'))->toBe('2025-06-15'); +}); + +it('creates a product with expiryDate', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'ExpiryDate Product', + sku: 'CREATE-PROD-EXP', + price: 10.00, + expiryDate: '2030-12-31T23:59:59+00:00', + ); + + $product = Craft::$app->getElements()->getElementById($response['productId'], \craft\commerce\elements\Product::class); + expect($product->expiryDate)->not->toBeNull(); + expect($product->expiryDate->format('Y-m-d'))->toBe('2030-12-31'); +}); + +it('creates a disabled product', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'Disabled Product', + sku: 'CREATE-PROD-DIS', + price: 10.00, + enabled: false, + ); + + expect($response['status'])->toBe('disabled'); +}); + +it('creates a product with a default variant', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'Variant Check Product', + sku: 'CREATE-PROD-VAR', + price: 49.99, + ); + + // Verify the variant was created + $product = Craft::$app->getElements()->getElementById($response['productId'], \craft\commerce\elements\Product::class); + $variants = $product->getVariants()->all(); + + expect($variants)->not->toBeEmpty(); + expect($variants[0]->sku)->toBe('CREATE-PROD-VAR'); + expect((float) $variants[0]->price)->toBe(49.99); + expect($variants[0]->isDefault)->toBeTrue(); +}); + +it('returns proper response structure', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'Structure Test', + sku: 'CREATE-PROD-STR', + price: 10.00, + ); + + expect($response)->toHaveKeys([ + '_notes', + 'productId', + 'title', + 'slug', + 'status', + 'typeId', + 'typeName', + 'defaultSku', + 'defaultPrice', + 'url', + ]); +}); + +it('throws exception for invalid product type ID', function () { + expect(fn () => $this->tool->__invoke( + typeId: 99999, + title: 'Bad Type', + sku: 'CREATE-PROD-BAD', + price: 10.00, + ))->toThrow(\InvalidArgumentException::class, 'Product type with ID 99999 not found'); +}); + +it('auto-generates slug from title', function () { + $response = $this->tool->__invoke( + typeId: $this->productType->id, + title: 'Auto Slug Generation Test', + sku: 'CREATE-PROD-AUTOSLUG', + price: 10.00, + ); + + expect($response['slug'])->not->toBeNull(); + expect($response['slug'])->not->toBeEmpty(); +}); diff --git a/tests/CreateProductTypeTest.php b/tests/CreateProductTypeTest.php new file mode 100644 index 0000000..c958353 --- /dev/null +++ b/tests/CreateProductTypeTest.php @@ -0,0 +1,149 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(CreateProductType::class); +}); + +it('creates a product type with minimal required fields', function () { + $response = $this->tool->__invoke( + name: 'Test Product Type', + ); + + expect($response['_notes'])->toContain('successfully created'); + expect($response['id'])->toBeInt(); + expect($response['name'])->toBe('Test Product Type'); + expect($response['handle'])->toBe('testProductType'); + expect($response['hasProductTitleField'])->toBeTrue(); + expect($response['hasVariantTitleField'])->toBeTrue(); + expect($response['editUrl'])->toBeString(); + expect($response['editVariantUrl'])->toBeString(); +}); + +it('creates a product type with custom handle', function () { + $response = $this->tool->__invoke( + name: 'Custom Handle Type', + handle: 'myCustomHandle', + ); + + expect($response['handle'])->toBe('myCustomHandle'); +}); + +it('creates a product type with dimensions enabled', function () { + $response = $this->tool->__invoke( + name: 'Physical Product Type', + hasDimensions: true, + ); + + expect($response['hasDimensions'])->toBeTrue(); +}); + +it('creates a product type with max variants', function () { + $response = $this->tool->__invoke( + name: 'Limited Variants Type', + maxVariants: 5, + ); + + expect($response['maxVariants'])->toBe(5); +}); + +it('creates a product type with versioning enabled', function () { + $response = $this->tool->__invoke( + name: 'Versioned Product Type', + enableVersioning: true, + ); + + expect($response['enableVersioning'])->toBeTrue(); +}); + +it('creates a product type without product title field', function () { + $response = $this->tool->__invoke( + name: 'Auto Title Type', + hasProductTitleField: false, + productTitleFormat: '{dateCreated|date}', + ); + + expect($response['hasProductTitleField'])->toBeFalse(); + expect($response['productTitleFormat'])->toBe('{dateCreated|date}'); +}); + +it('creates a product type without variant title field', function () { + $response = $this->tool->__invoke( + name: 'Auto Variant Title Type', + hasVariantTitleField: false, + variantTitleFormat: '{product.title} - {sku}', + ); + + expect($response['hasVariantTitleField'])->toBeFalse(); + expect($response['variantTitleFormat'])->toBe('{product.title} - {sku}'); +}); + +it('creates a product type with SKU format', function () { + $response = $this->tool->__invoke( + name: 'Auto SKU Type', + skuFormat: '{product.slug}', + ); + + expect($response['skuFormat'])->toBe('{product.slug}'); +}); + +it('throws exception when product title format missing but title field disabled', function () { + expect(fn () => $this->tool->__invoke( + name: 'Bad Title Config', + hasProductTitleField: false, + ))->toThrow(\InvalidArgumentException::class, "hasProductTitleField"); +}); + +it('throws exception when variant title format missing but title field disabled', function () { + expect(fn () => $this->tool->__invoke( + name: 'Bad Variant Title Config', + hasVariantTitleField: false, + ))->toThrow(\InvalidArgumentException::class, "hasVariantTitleField"); +}); + +it('returns proper response structure', function () { + $response = $this->tool->__invoke( + name: 'Structure Check Type', + ); + + expect($response)->toHaveKeys([ + '_notes', + 'id', + 'name', + 'handle', + 'fieldLayoutId', + 'variantFieldLayoutId', + 'hasProductTitleField', + 'productTitleFormat', + 'hasVariantTitleField', + 'variantTitleFormat', + 'skuFormat', + 'hasDimensions', + 'maxVariants', + 'enableVersioning', + 'editUrl', + 'editVariantUrl', + ]); +}); + +it('returns control panel edit URLs', function () { + $response = $this->tool->__invoke( + name: 'URL Check Type', + ); + + expect($response['editUrl'])->toContain('commerce/settings/producttypes/'); + expect($response['editVariantUrl'])->toContain('commerce/settings/producttypes/'); +}); + +it('auto-generates handle from name', function () { + $response = $this->tool->__invoke( + name: 'My Cool Product Type', + ); + + expect($response['handle'])->toBe('myCoolProductType'); +}); diff --git a/tests/CreateUserGroupTest.php b/tests/CreateUserGroupTest.php new file mode 100644 index 0000000..0032cf6 --- /dev/null +++ b/tests/CreateUserGroupTest.php @@ -0,0 +1,19 @@ +markTestSkipped('User groups require Craft Pro in this environment.'); + } + + $tool = Craft::$container->get(CreateUserGroup::class); + + $response = $tool->__invoke( + name: 'Publishers', + permissions: ['accesscp', 'custompermission:publish'], + ); + + expect($response['name'])->toBe('Publishers') + ->and($response['permissions'])->toContain('custompermission:publish'); +}); diff --git a/tests/CreateUserTest.php b/tests/CreateUserTest.php new file mode 100644 index 0000000..fb48ad4 --- /dev/null +++ b/tests/CreateUserTest.php @@ -0,0 +1,46 @@ +tool = Craft::$container->get(CreateUser::class); +}); + +it('creates a user', function () { + if (!craftCanCreateAdditionalUsers()) { + $this->markTestSkipped('The current Craft edition has already reached its user limit.'); + } + + $response = $this->tool->__invoke( + email: 'created-' . uniqid() . '@example.com', + newPassword: 'Password123!', + fullName: 'Created User', + ); + + expect($response['email'])->toBeString() + ->and($response['fullName'])->toBe('Created User'); +}); + +it('creates a user with group handles and permissions when supported', function () { + if (!craftCanCreateAdditionalUsers()) { + $this->markTestSkipped('The current Craft edition has already reached its user limit.'); + } + + if (!craftSupportsUserGroups() || !craftSupportsUserPermissionAssignment()) { + $this->markTestSkipped('User groups and direct permissions require Craft Pro in this environment.'); + } + + $group = createTestUserGroup('Editors'); + + $response = $this->tool->__invoke( + email: 'created-' . uniqid() . '@example.com', + newPassword: 'Password123!', + fullName: 'Created User', + groupHandles: [$group->handle], + permissions: ['accesscp', 'custompermission:test'], + ); + + expect($response['email'])->toBeString() + ->and($response['groups'][0]['handle'])->toBe($group->handle) + ->and($response['permissions'])->toContain('custompermission:test'); +}); diff --git a/tests/CreateVariantTest.php b/tests/CreateVariantTest.php new file mode 100644 index 0000000..fdb0b38 --- /dev/null +++ b/tests/CreateVariantTest.php @@ -0,0 +1,179 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(CreateVariant::class); + + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + // Ensure product type allows multiple variants for these tests + $productType = $productTypes[0]; + if ($productType->maxVariants <= 1) { + $productType->maxVariants = 10; + $commerce->getProductTypes()->saveProductType($productType); + } + + // Create a product to add variants to + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productType->id; + $product->title = 'Product for CreateVariant Tests'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'CRVAR-DEFAULT'; + $variant->basePrice = 10.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + $success = Craft::$app->getElements()->saveElement($product); + expect($success)->toBeTrue(); + + $this->product = $product; +}); + +it('creates a variant with required fields', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-NEW-001', + price: 25.00, + ); + + expect($response['_notes'])->toBe('The variant was successfully created.'); + expect($response['variantId'])->toBeInt(); + expect($response['sku'])->toBe('CRVAR-NEW-001'); + expect($response['price'])->toBe(25.00); + expect($response['productId'])->toBe($this->product->id); + expect($response['productTitle'])->toBe('Product for CreateVariant Tests'); + expect($response['url'])->toBeString(); +}); + +it('creates a variant with title', function () { + // Enable variant title field on the product type so titles can be set + $commerce = \craft\commerce\Plugin::getInstance(); + $productType = $this->product->getType(); + if (!$productType->hasVariantTitleField) { + $productType->hasVariantTitleField = true; + $commerce->getProductTypes()->saveProductType($productType); + } + + $response = $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-TITLE', + price: 30.00, + title: 'Large Size', + ); + + expect($response['title'])->toBe('Large Size'); +}); + +it('creates a variant with dimensions', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-DIM', + price: 20.00, + weight: 2.5, + height: 10.0, + length: 20.0, + width: 15.0, + ); + + // Verify dimensions were saved + $variant = Craft::$app->getElements()->getElementById($response['variantId'], \craft\commerce\elements\Variant::class); + expect((float) $variant->weight)->toBe(2.5); + expect((float) $variant->height)->toBe(10.0); + expect((float) $variant->length)->toBe(20.0); + expect((float) $variant->width)->toBe(15.0); +}); + +it('creates a variant with quantity limits', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-QTY', + price: 20.00, + minQty: 2, + maxQty: 10, + ); + + $variant = Craft::$app->getElements()->getElementById($response['variantId'], \craft\commerce\elements\Variant::class); + expect($variant->minQty)->toBe(2); + expect($variant->maxQty)->toBe(10); +}); + +it('creates a variant with free shipping', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-FREESHIP', + price: 100.00, + freeShipping: true, + ); + + $variant = Craft::$app->getElements()->getElementById($response['variantId'], \craft\commerce\elements\Variant::class); + expect($variant->freeShipping)->toBeTrue(); +}); + +it('returns proper response structure', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-STRUCT', + price: 15.00, + ); + + expect($response)->toHaveKeys([ + '_notes', + 'variantId', + 'title', + 'sku', + 'price', + 'stock', + 'productId', + 'productTitle', + 'url', + ]); +}); + +it('throws exception for non-existent product', function () { + expect(fn () => $this->tool->__invoke( + productId: 99999, + sku: 'CRVAR-BAD', + price: 10.00, + ))->toThrow(\InvalidArgumentException::class, 'Product with ID 99999 not found'); +}); + +it('appends variant to existing variants', function () { + // Get initial variant count + $freshProduct = Craft::$app->getElements()->getElementById($this->product->id, \craft\commerce\elements\Product::class); + $initialCount = count($freshProduct->getVariants()->all()); + + $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-APPEND', + price: 35.00, + ); + + // Re-fetch and verify count increased + $updatedProduct = Craft::$app->getElements()->getElementById($this->product->id, \craft\commerce\elements\Product::class); + $newCount = count($updatedProduct->getVariants()->all()); + + expect($newCount)->toBe($initialCount + 1); +}); + +it('returns stock as integer', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + sku: 'CRVAR-STOCK', + price: 10.00, + ); + + expect($response['stock'])->toBeInt(); +}); diff --git a/tests/DeleteAddressTest.php b/tests/DeleteAddressTest.php new file mode 100644 index 0000000..70bcd62 --- /dev/null +++ b/tests/DeleteAddressTest.php @@ -0,0 +1,34 @@ +tool = Craft::$container->get(DeleteAddress::class); +}); + +it('soft deletes a user-owned address', function () { + $user = createTestUserOwner(); + $address = createUserOwnedAddress($user); + + $response = $this->tool->__invoke(addressId: $address->id); + + expect($response['_notes'])->toBe('The address was successfully deleted.'); + expect($response['addressId'])->toBe($address->id); + expect($response['deletedPermanently'])->toBeFalse(); +}); + +it('permanently deletes a field-owned address', function () { + ['entry' => $entry, 'field' => $field] = createTestEntryOwnerWithAddressesField(); + $address = createFieldOwnedAddress($entry, $field); + + $response = $this->tool->__invoke(addressId: $address->id, permanentlyDelete: true); + + expect($response['addressId'])->toBe($address->id); + expect($response['fieldId'])->toBe($field->id); + expect($response['deletedPermanently'])->toBeTrue(); +}); + +it('throws when address is not found', function () { + expect(fn() => $this->tool->__invoke(addressId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Address with ID 99999 not found'); +}); diff --git a/tests/DeleteProductTest.php b/tests/DeleteProductTest.php new file mode 100644 index 0000000..052141c --- /dev/null +++ b/tests/DeleteProductTest.php @@ -0,0 +1,144 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(DeleteProduct::class); + + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $this->productType = $productTypes[0]; +}); + +it('can soft delete a product (default behavior)', function () { + $product = new \craft\commerce\elements\Product(); + $product->typeId = $this->productType->id; + $product->title = 'Product to Soft Delete'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-DEL-SOFT'; + $variant->basePrice = 9.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(productId: $product->id); + + expect($response['productId'])->toBe($product->id); + expect($response['title'])->toBe('Product to Soft Delete'); + expect($response['deletedPermanently'])->toBeFalse(); + + // Product should be soft deleted (trashed) + $trashed = \craft\commerce\elements\Product::find() + ->id($product->id) + ->trashed() + ->one(); + expect($trashed)->not->toBeNull(); + + // Product should not be found in normal queries + $live = \craft\commerce\elements\Product::find()->id($product->id)->one(); + expect($live)->toBeNull(); +}); + +it('can permanently delete a product', function () { + $product = new \craft\commerce\elements\Product(); + $product->typeId = $this->productType->id; + $product->title = 'Product to Permanently Delete'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-DEL-PERM'; + $variant->basePrice = 9.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke( + productId: $product->id, + permanentlyDelete: true, + ); + + expect($response['productId'])->toBe($product->id); + expect($response['deletedPermanently'])->toBeTrue(); + + // Product should be completely removed + $trashed = \craft\commerce\elements\Product::find() + ->id($product->id) + ->trashed() + ->one(); + expect($trashed)->toBeNull(); + + $live = \craft\commerce\elements\Product::find()->id($product->id)->one(); + expect($live)->toBeNull(); +}); + +it('returns proper response format after deletion', function () { + $product = new \craft\commerce\elements\Product(); + $product->typeId = $this->productType->id; + $product->title = 'Delete Format Test'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-DEL-FMT'; + $variant->basePrice = 9.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(productId: $product->id); + + expect($response)->toHaveKeys([ + '_notes', + 'productId', + 'title', + 'slug', + 'typeId', + 'typeName', + 'deletedPermanently', + ]); + expect($response['_notes'])->toBe('The product was successfully deleted.'); + expect($response['typeName'])->toBe($this->productType->name); + expect($response['deletedPermanently'])->toBeBool(); +}); + +it('throws exception when product not found', function () { + expect(fn () => $this->tool->__invoke(productId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Product with ID 99999 not found'); +}); + +it('includes product type information in response', function () { + $product = new \craft\commerce\elements\Product(); + $product->typeId = $this->productType->id; + $product->title = 'Type Info Test'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-DEL-TYPE'; + $variant->basePrice = 9.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(productId: $product->id); + + expect($response['typeId'])->toBe($this->productType->id); + expect($response['typeName'])->toBe($this->productType->name); +}); diff --git a/tests/DeleteProductTypeTest.php b/tests/DeleteProductTypeTest.php new file mode 100644 index 0000000..9625d4e --- /dev/null +++ b/tests/DeleteProductTypeTest.php @@ -0,0 +1,117 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->createTool = Craft::$container->get(CreateProductType::class); + $this->createProductTool = Craft::$container->get(CreateProduct::class); + $this->tool = Craft::$container->get(DeleteProductType::class); +}); + +it('deletes an empty product type', function () { + $created = $this->createTool->__invoke( + name: 'Delete Test Type', + handle: 'deleteTestType' . random_int(1000, 9999), + ); + + $response = $this->tool->__invoke( + productTypeId: $created['id'], + ); + + expect($response['_notes'])->toBe('The product type was successfully deleted.'); + expect($response['id'])->toBe($created['id']); + expect($response['name'])->toBe('Delete Test Type'); + expect($response['impact'])->toHaveKey('hasContent'); + expect($response['impact']['hasContent'])->toBeFalse(); + expect($response['impact']['productCount'])->toBe(0); +}); + +it('returns proper response structure after deletion', function () { + $created = $this->createTool->__invoke( + name: 'Structure Delete Type', + handle: 'structDeleteType' . random_int(1000, 9999), + ); + + $response = $this->tool->__invoke( + productTypeId: $created['id'], + ); + + expect($response)->toHaveKeys([ + '_notes', + 'id', + 'name', + 'handle', + 'impact', + ]); + expect($response['impact'])->toHaveKeys([ + 'hasContent', + 'productCount', + ]); +}); + +it('throws exception for non-existent product type', function () { + expect(fn () => $this->tool->__invoke(productTypeId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Product type with ID 99999 not found'); +}); + +it('deletes product type with force when products exist', function () { + // Create a product type + $created = $this->createTool->__invoke( + name: 'Force Delete Type', + handle: 'forceDeleteType' . random_int(1000, 9999), + ); + + // Create a product in this type + $this->createProductTool->__invoke( + typeId: $created['id'], + title: 'Test Product for Deletion', + sku: 'DELETE-TEST-SKU-' . random_int(1000, 9999), + price: 10.00, + ); + + // Try to delete without force — should throw + expect(fn () => $this->tool->__invoke( + productTypeId: $created['id'], + ))->toThrow(\RuntimeException::class, 'cannot be deleted without force=true'); + + // Delete with force — should succeed + $response = $this->tool->__invoke( + productTypeId: $created['id'], + force: true, + ); + + expect($response['_notes'])->toBe('The product type was successfully deleted.'); + expect($response['impact']['hasContent'])->toBeTrue(); + expect($response['impact']['productCount'])->toBeGreaterThan(0); +}); + +it('blocks deletion of product type with products when force is false', function () { + // Create a product type + $created = $this->createTool->__invoke( + name: 'Block Delete Type', + handle: 'blockDeleteType' . random_int(1000, 9999), + ); + + // Create a product in this type + $this->createProductTool->__invoke( + typeId: $created['id'], + title: 'Block Delete Product', + sku: 'BLOCK-DEL-SKU-' . random_int(1000, 9999), + price: 5.00, + ); + + try { + $this->tool->__invoke(productTypeId: $created['id']); + $this->fail('Expected RuntimeException was not thrown'); + } catch (\RuntimeException $e) { + expect($e->getMessage())->toContain('contains data'); + expect($e->getMessage())->toContain('force=true'); + expect($e->getMessage())->toContain('Impact Assessment'); + } +}); diff --git a/tests/DeleteUserGroupTest.php b/tests/DeleteUserGroupTest.php new file mode 100644 index 0000000..2f2cc6e --- /dev/null +++ b/tests/DeleteUserGroupTest.php @@ -0,0 +1,17 @@ +markTestSkipped('User groups require Craft Pro in this environment.'); + } + + $group = createTestUserGroup('Temporary Group'); + $tool = Craft::$container->get(DeleteUserGroup::class); + + $response = $tool->__invoke(handle: $group->handle); + + expect($response['handle'])->toBe($group->handle) + ->and(Craft::$app->getUserGroups()->getGroupById($group->id))->toBeNull(); +}); diff --git a/tests/DeleteUserTest.php b/tests/DeleteUserTest.php new file mode 100644 index 0000000..4e8a115 --- /dev/null +++ b/tests/DeleteUserTest.php @@ -0,0 +1,19 @@ +tool = Craft::$container->get(DeleteUser::class); +}); + +it('soft deletes a user by email', function () { + $user = createTestUser(); + + $response = $this->tool->__invoke(email: $user->email); + + expect($response['id'])->toBe($user->id); + + $deletedUser = Craft::$app->getElements()->getElementById($user->id, User::class, null, ['status' => null]); + expect($deletedUser)->toBeNull(); +}); diff --git a/tests/DeleteVariantTest.php b/tests/DeleteVariantTest.php new file mode 100644 index 0000000..4a26d2e --- /dev/null +++ b/tests/DeleteVariantTest.php @@ -0,0 +1,127 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(DeleteVariant::class); + + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $this->productType = $productTypes[0]; +}); + +/** + * Helper to create a product with a variant and return both. + * + * @return array{product: \craft\commerce\elements\Product, variant: \craft\commerce\elements\Variant} + */ +function createProductWithVariant(string $sku = 'DEL-VAR-001', float $price = 9.99): array +{ + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Product for Delete Variant'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = $sku; + $variant->basePrice = $price; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $freshProduct = Craft::$app->getElements()->getElementById($product->id, \craft\commerce\elements\Product::class); + $savedVariant = $freshProduct->getVariants()->first(); + + return ['product' => $freshProduct, 'variant' => $savedVariant]; +} + +it('can soft delete a variant (default behavior)', function () { + $fixtures = createProductWithVariant('DEL-VAR-SOFT'); + $variant = $fixtures['variant']; + + $response = $this->tool->__invoke(variantId: $variant->id); + + expect($response['variantId'])->toBe($variant->id); + expect($response['sku'])->toBe('DEL-VAR-SOFT'); + expect($response['deletedPermanently'])->toBeFalse(); + expect($response['_notes'])->toBe('The variant was successfully deleted.'); + + // Variant should be soft deleted (trashed) + $trashed = \craft\commerce\elements\Variant::find() + ->id($variant->id) + ->trashed() + ->one(); + expect($trashed)->not->toBeNull(); + + // Variant should not be found in normal queries + $live = \craft\commerce\elements\Variant::find()->id($variant->id)->one(); + expect($live)->toBeNull(); +}); + +it('can permanently delete a variant', function () { + $fixtures = createProductWithVariant('DEL-VAR-PERM'); + $variant = $fixtures['variant']; + + $response = $this->tool->__invoke( + variantId: $variant->id, + permanentlyDelete: true, + ); + + expect($response['deletedPermanently'])->toBeTrue(); + + // Variant should be completely gone + $trashed = \craft\commerce\elements\Variant::find() + ->id($variant->id) + ->trashed() + ->one(); + expect($trashed)->toBeNull(); + + $live = \craft\commerce\elements\Variant::find()->id($variant->id)->one(); + expect($live)->toBeNull(); +}); + +it('returns proper response format after deletion', function () { + $fixtures = createProductWithVariant('DEL-VAR-FMT'); + + $response = $this->tool->__invoke(variantId: $fixtures['variant']->id); + + expect($response)->toHaveKeys([ + '_notes', + 'variantId', + 'title', + 'sku', + 'productId', + 'productTitle', + 'deletedPermanently', + ]); + expect($response['productId'])->toBe($fixtures['product']->id); + expect($response['productTitle'])->toBe('Product for Delete Variant'); +}); + +it('throws exception when variant not found', function () { + expect(fn () => $this->tool->__invoke(variantId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Variant with ID 99999 not found'); +}); + +it('includes parent product information in response', function () { + $fixtures = createProductWithVariant('DEL-VAR-PARENT'); + + $response = $this->tool->__invoke(variantId: $fixtures['variant']->id); + + expect($response['productId'])->toBe($fixtures['product']->id); + expect($response['productTitle'])->toBe($fixtures['product']->title); +}); diff --git a/tests/GetAddressFieldLayoutTest.php b/tests/GetAddressFieldLayoutTest.php new file mode 100644 index 0000000..0a36c1d --- /dev/null +++ b/tests/GetAddressFieldLayoutTest.php @@ -0,0 +1,20 @@ +tool = Craft::$container->get(GetAddressFieldLayout::class); +}); + +it('returns the global address field layout', function () { + $response = $this->tool->__invoke(); + + expect($response)->toHaveKeys(['_notes', 'fieldLayout', 'settingsUrl', 'elementType']); + expect($response['_notes'])->toBe('Retrieved the global address field layout.'); + expect($response['elementType'])->toBe(Address::class); + expect($response['fieldLayout']['id'])->toBe(GetAddressFieldLayout::PLACEHOLDER_ID); + expect($response['fieldLayout']['type'])->toBe(Address::class); + expect($response['fieldLayout']['tabs'])->toBeArray(); + expect($response['settingsUrl'])->toContain('/settings/addresses'); +}); diff --git a/tests/GetAddressTest.php b/tests/GetAddressTest.php new file mode 100644 index 0000000..71ae1a4 --- /dev/null +++ b/tests/GetAddressTest.php @@ -0,0 +1,43 @@ +tool = Craft::$container->get(GetAddress::class); +}); + +it('gets a user-owned address', function () { + $user = createTestUserOwner(); + $address = createUserOwnedAddress($user); + + $response = $this->tool->__invoke(addressId: $address->id); + + expect($response)->toHaveKeys([ + '_notes', 'addressId', 'title', 'fullName', 'countryCode', 'addressLine1', + 'locality', 'administrativeArea', 'postalCode', 'fieldId', 'fieldHandle', + 'ownerId', 'primaryOwnerId', 'ownerType', 'ownerTitle', 'url', 'customFields', + ]); + expect($response['_notes'])->toBe('Retrieved address details.'); + expect($response['addressId'])->toBe($address->id); + expect($response['ownerId'])->toBe($user->id); + expect($response['ownerType'])->toBe($user::class); + expect($response['fieldId'])->toBeNull(); +}); + +it('gets a field-owned address', function () { + ['entry' => $entry, 'field' => $field] = createTestEntryOwnerWithAddressesField(); + $address = createFieldOwnedAddress($entry, $field); + + $response = $this->tool->__invoke(addressId: $address->id); + + expect($response['addressId'])->toBe($address->id); + expect($response['ownerId'])->toBe($entry->id); + expect($response['ownerType'])->toBe($entry::class); + expect($response['fieldId'])->toBe($field->id); + expect($response['fieldHandle'])->toBe($field->handle); +}); + +it('throws when address is not found', function () { + expect(fn() => $this->tool->__invoke(addressId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Address with ID 99999 not found'); +}); diff --git a/tests/GetAddressesTest.php b/tests/GetAddressesTest.php new file mode 100644 index 0000000..14954c3 --- /dev/null +++ b/tests/GetAddressesTest.php @@ -0,0 +1,58 @@ +tool = Craft::$container->get(GetAddresses::class); +}); + +it('lists addresses without filters', function () { + $user = createTestUserOwner(); + createUserOwnedAddress($user); + + $response = $this->tool->__invoke(); + + expect($response)->toHaveKeys(['_notes', 'results']); + expect($response['results'])->toBeIterable(); +}); + +it('filters addresses by user owner', function () { + $user = createTestUserOwner(); + $address = createUserOwnedAddress($user); + + $response = $this->tool->__invoke(ownerId: $user->id, ownerType: $user::class); + $results = $response['results']->values()->all(); + + expect($results)->not->toBeEmpty(); + expect(collect($results)->pluck('addressId'))->toContain($address->id); +}); + +it('filters addresses by field ownership', function () { + ['entry' => $entry, 'field' => $field] = createTestEntryOwnerWithAddressesField(); + $address = createFieldOwnedAddress($entry, $field); + + $response = $this->tool->__invoke( + ownerId: $entry->id, + ownerType: $entry::class, + fieldId: $field->id, + ); + $results = $response['results']->values()->all(); + + expect($results)->not->toBeEmpty(); + expect(collect($results)->pluck('addressId'))->toContain($address->id); +}); + +it('filters by country and locality', function () { + $user = createTestUserOwner(); + $address = createUserOwnedAddress($user); + + $response = $this->tool->__invoke(countryCode: 'US', locality: 'Portland'); + $results = $response['results']->values()->all(); + + expect(collect($results)->pluck('addressId'))->toContain($address->id); +}); + +it('requires ownerId and ownerType together', function () { + expect(fn() => $this->tool->__invoke(ownerId: 1)) + ->toThrow(\InvalidArgumentException::class, 'ownerId and ownerType must be provided together'); +}); diff --git a/tests/GetAvailablePermissionsTest.php b/tests/GetAvailablePermissionsTest.php new file mode 100644 index 0000000..faaa013 --- /dev/null +++ b/tests/GetAvailablePermissionsTest.php @@ -0,0 +1,32 @@ +get(\happycog\craftmcp\tools\UpdateUser::class); + $tool = Craft::$container->get(GetAvailablePermissions::class); + + $updateUser->__invoke( + userId: $user->id, + permissions: ['accesscp', 'custompermission:discoverable'], + ); + + $response = $tool->__invoke(); + + expect($response)->toHaveKeys(['groups', 'allPermissions', 'allPermissionNames', 'customPermissions', 'customPermissionNames']) + ->and($response['allPermissionNames'])->toContain('accesscp') + ->and($response['customPermissionNames'])->toContain('custompermission:discoverable') + ->and(collect($response['allPermissions'])->firstWhere('name', 'accesscp'))->toMatchArray([ + 'name' => 'accesscp', + 'label' => 'Access the control panel', + 'isCustom' => false, + ]) + ->and(collect($response['customPermissions'])->firstWhere('name', 'custompermission:discoverable'))->toMatchArray([ + 'name' => 'custompermission:discoverable', + 'label' => 'custompermission:discoverable', + 'isCustom' => true, + ]); +}); diff --git a/tests/GetOrderStatusesTest.php b/tests/GetOrderStatusesTest.php new file mode 100644 index 0000000..de440c3 --- /dev/null +++ b/tests/GetOrderStatusesTest.php @@ -0,0 +1,56 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetOrderStatuses::class); +}); + +it('returns order statuses array with expected structure', function () { + $response = $this->tool->__invoke(); + + expect($response)->toBeArray(); + expect($response)->toHaveKeys(['_notes', 'orderStatuses']); + expect($response['_notes'])->toBe('Retrieved all Commerce order statuses.'); + expect($response['orderStatuses'])->toBeArray(); +}); + +it('returns correct keys for each order status', function () { + $response = $this->tool->__invoke(); + + if (!empty($response['orderStatuses'])) { + $status = $response['orderStatuses'][0]; + expect($status)->toHaveKeys([ + 'id', + 'name', + 'handle', + 'color', + 'description', + 'isDefault', + 'sortOrder', + ]); + expect($status['id'])->toBeInt(); + expect($status['name'])->toBeString(); + expect($status['handle'])->toBeString(); + expect($status['isDefault'])->toBeBool(); + } +}); + +it('includes at least one order status', function () { + // Our project config defines a "New" status, so there should be at least one + $response = $this->tool->__invoke(); + + expect($response['orderStatuses'])->not->toBeEmpty(); +}); + +it('includes the default order status', function () { + $response = $this->tool->__invoke(); + + $defaults = array_filter($response['orderStatuses'], fn ($s) => $s['isDefault'] === true); + + expect($defaults)->not->toBeEmpty('At least one order status should be the default'); +}); diff --git a/tests/GetOrderTest.php b/tests/GetOrderTest.php new file mode 100644 index 0000000..46ef881 --- /dev/null +++ b/tests/GetOrderTest.php @@ -0,0 +1,151 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetOrder::class); +}); + +it('throws exception for non-existent order', function () { + expect(fn () => $this->tool->__invoke(orderId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Order with ID 99999 not found'); +}); + +it('retrieves order details with expected structure', function () { + $commerce = Commerce::getInstance(); + + // Create an order programmatically + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + $order->isCompleted = false; + + $success = Craft::$app->getElements()->saveElement($order); + expect($success)->toBeTrue(); + + $response = $this->tool->__invoke(orderId: $order->id); + + expect($response)->toHaveKeys([ + '_notes', + 'orderId', + 'number', + 'reference', + 'email', + 'isCompleted', + 'dateOrdered', + 'datePaid', + 'currency', + 'couponCode', + 'orderStatusId', + 'orderStatusName', + 'paidStatus', + 'origin', + 'shippingMethodHandle', + 'itemTotal', + 'totalShippingCost', + 'totalDiscount', + 'totalTax', + 'totalPaid', + 'total', + 'lineItems', + 'adjustments', + 'shippingAddress', + 'billingAddress', + 'url', + ]); + expect($response['_notes'])->toBe('Retrieved order details.'); + expect($response['orderId'])->toBe($order->id); + expect($response['currency'])->toBe('USD'); +}); + +it('returns line items as array', function () { + $commerce = Commerce::getInstance(); + + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + + Craft::$app->getElements()->saveElement($order); + + $response = $this->tool->__invoke(orderId: $order->id); + + expect($response['lineItems'])->toBeArray(); + expect($response['adjustments'])->toBeArray(); +}); + +it('returns numeric totals', function () { + $commerce = Commerce::getInstance(); + + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + + Craft::$app->getElements()->saveElement($order); + + $response = $this->tool->__invoke(orderId: $order->id); + + expect($response['itemTotal'])->toBeFloat(); + expect($response['totalShippingCost'])->toBeFloat(); + expect($response['totalDiscount'])->toBeFloat(); + expect($response['totalTax'])->toBeFloat(); + expect($response['totalPaid'])->toBeFloat(); + expect($response['total'])->toBeFloat(); +}); + +it('returns null for dateOrdered and datePaid on incomplete orders', function () { + $commerce = Commerce::getInstance(); + + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + $order->isCompleted = false; + + Craft::$app->getElements()->saveElement($order); + + $response = $this->tool->__invoke(orderId: $order->id); + + expect($response['dateOrdered'])->toBeNull(); + expect($response['datePaid'])->toBeNull(); + expect($response['isCompleted'])->toBeFalse(); +}); + +it('returns null orderStatusName when no status is set', function () { + $commerce = Commerce::getInstance(); + + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + + Craft::$app->getElements()->saveElement($order); + + $response = $this->tool->__invoke(orderId: $order->id); + + // An incomplete order without explicit status should have null orderStatusName + // (orderStatusId may or may not be set depending on Commerce defaults) + if ($response['orderStatusId'] === null) { + expect($response['orderStatusName'])->toBeNull(); + } else { + // If Commerce auto-assigns a status, the name should be a string + expect($response['orderStatusName'])->toBeString(); + } +}); + +it('returns empty arrays for addresses on orders without addresses', function () { + $commerce = Commerce::getInstance(); + + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + + Craft::$app->getElements()->saveElement($order); + + $response = $this->tool->__invoke(orderId: $order->id); + + expect($response['shippingAddress'])->toBeNull(); + expect($response['billingAddress'])->toBeNull(); +}); diff --git a/tests/GetProductTest.php b/tests/GetProductTest.php new file mode 100644 index 0000000..f124aea --- /dev/null +++ b/tests/GetProductTest.php @@ -0,0 +1,206 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetProduct::class); +}); + +it('throws exception for non-existent product', function () { + expect(fn () => $this->tool->__invoke(productId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Product with ID 99999 not found'); +}); + +it('retrieves product details with expected structure', function () { + // Create a product programmatically + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $productType = $productTypes[0]; + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productType->id; + $product->title = 'Test Product for GetProduct'; + $product->slug = 'test-product-get'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-GET-001'; + $variant->basePrice = 19.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + $success = Craft::$app->getElements()->saveElement($product); + expect($success)->toBeTrue(); + + $response = $this->tool->__invoke(productId: $product->id); + + expect($response)->toHaveKeys([ + '_notes', + 'productId', + 'title', + 'slug', + 'status', + 'typeId', + 'typeName', + 'typeHandle', + 'postDate', + 'expiryDate', + 'defaultSku', + 'defaultPrice', + 'url', + 'variants', + 'customFields', + ]); + expect($response['_notes'])->toBe('Retrieved product details with variants.'); + expect($response['productId'])->toBe($product->id); + expect($response['title'])->toBe('Test Product for GetProduct'); + expect($response['typeName'])->toBe($productType->name); +}); + +it('includes variant details in response', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Product With Variants'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-VAR-001'; + $variant->basePrice = 29.99; + $variant->isDefault = true; + $variant->weight = 1.5; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(productId: $product->id); + + expect($response['variants'])->toBeArray(); + expect($response['variants'])->not->toBeEmpty(); + + $firstVariant = $response['variants'][0]; + expect($firstVariant)->toHaveKeys([ + 'id', + 'title', + 'sku', + 'price', + 'isDefault', + 'stock', + 'minQty', + 'maxQty', + 'weight', + 'height', + 'length', + 'width', + 'freeShipping', + 'inventoryTracked', + 'sortOrder', + ]); + expect($firstVariant['sku'])->toBe('TEST-VAR-001'); + expect($firstVariant['price'])->toBe(29.99); + expect($firstVariant['isDefault'])->toBeTrue(); +}); + +it('returns postDate and null expiryDate by default', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Date Test Product'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-DATE-001'; + $variant->basePrice = 10.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(productId: $product->id); + + // postDate should be set automatically on save + expect($response['postDate'])->toBeString(); + // expiryDate should be null unless explicitly set + expect($response['expiryDate'])->toBeNull(); +}); + +it('returns product with explicit expiryDate', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Expiring Product'; + $product->enabled = true; + $product->expiryDate = new \DateTime('2030-12-31T23:59:59+00:00'); + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-EXPIRY-001'; + $variant->basePrice = 15.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(productId: $product->id); + + expect($response['expiryDate'])->toBeString(); + expect($response['expiryDate'])->toContain('2030-12-31'); +}); + +it('returns customFields key in response', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Custom Fields Test Product'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-FIELDS-001'; + $variant->basePrice = 20.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(productId: $product->id); + + // customFields should be an array (even if empty when no custom fields are configured) + expect($response['customFields'])->toBeArray(); +}); diff --git a/tests/GetProductTypeTest.php b/tests/GetProductTypeTest.php new file mode 100644 index 0000000..83d0d31 --- /dev/null +++ b/tests/GetProductTypeTest.php @@ -0,0 +1,117 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetProductType::class); + + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $this->productType = $productTypes[0]; +}); + +it('returns product type details with expected structure', function () { + $response = $this->tool->__invoke(productTypeId: $this->productType->id); + + expect($response)->toBeArray(); + expect($response)->toHaveKeys([ + '_notes', + 'id', + 'name', + 'handle', + 'fieldLayoutId', + 'variantFieldLayoutId', + 'hasDimensions', + 'hasProductTitleField', + 'productTitleFormat', + 'productTitleTranslationMethod', + 'hasVariantTitleField', + 'variantTitleFormat', + 'variantTitleTranslationMethod', + 'showSlugField', + 'slugTranslationMethod', + 'skuFormat', + 'descriptionFormat', + 'maxVariants', + 'enableVersioning', + 'isStructure', + 'propagationMethod', + 'siteSettings', + 'productFields', + 'variantFields', + 'editUrl', + 'editVariantUrl', + ]); + expect($response['_notes'])->toBe('Retrieved product type details with field layouts.'); +}); + +it('returns correct types for product type fields', function () { + $response = $this->tool->__invoke(productTypeId: $this->productType->id); + + expect($response['id'])->toBeInt(); + expect($response['name'])->toBeString(); + expect($response['handle'])->toBeString(); + expect($response['hasDimensions'])->toBeBool(); + expect($response['hasProductTitleField'])->toBeBool(); + expect($response['hasVariantTitleField'])->toBeBool(); + expect($response['showSlugField'])->toBeBool(); + expect($response['enableVersioning'])->toBeBool(); + expect($response['isStructure'])->toBeBool(); + expect($response['propagationMethod'])->toBeString(); + expect($response['siteSettings'])->toBeArray(); + expect($response['productFields'])->toBeArray(); + expect($response['variantFields'])->toBeArray(); + expect($response['editUrl'])->toBeString(); + expect($response['editVariantUrl'])->toBeString(); +}); + +it('returns the correct product type by ID', function () { + $response = $this->tool->__invoke(productTypeId: $this->productType->id); + + expect($response['id'])->toBe($this->productType->id); + expect($response['name'])->toBe($this->productType->name); + expect($response['handle'])->toBe($this->productType->handle); +}); + +it('returns site settings for the product type', function () { + $response = $this->tool->__invoke(productTypeId: $this->productType->id); + + expect($response['siteSettings'])->not->toBeEmpty(); + + $siteSetting = $response['siteSettings'][0]; + expect($siteSetting)->toHaveKeys(['siteId', 'hasUrls', 'uriFormat', 'template', 'enabledByDefault']); + expect($siteSetting['siteId'])->toBeInt(); + expect($siteSetting['hasUrls'])->toBeBool(); + expect($siteSetting['enabledByDefault'])->toBeBool(); +}); + +it('returns control panel edit URLs', function () { + $response = $this->tool->__invoke(productTypeId: $this->productType->id); + + expect($response['editUrl'])->toContain('commerce/settings/producttypes/'); + expect($response['editVariantUrl'])->toContain('commerce/settings/producttypes/'); + expect($response['editVariantUrl'])->toContain('/variant'); +}); + +it('returns structure fields as null when not a structure', function () { + $response = $this->tool->__invoke(productTypeId: $this->productType->id); + + if (!$response['isStructure']) { + expect($response['maxLevels'])->toBeNull(); + expect($response['defaultPlacement'])->toBeNull(); + } +}); + +it('throws exception for non-existent product type', function () { + expect(fn () => $this->tool->__invoke(productTypeId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Product type with ID 99999 not found'); +}); diff --git a/tests/GetProductTypesTest.php b/tests/GetProductTypesTest.php new file mode 100644 index 0000000..920490f --- /dev/null +++ b/tests/GetProductTypesTest.php @@ -0,0 +1,82 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetProductTypes::class); +}); + +it('returns product types array with expected structure', function () { + $response = $this->tool->__invoke(); + + expect($response)->toBeArray(); + expect($response)->toHaveKeys(['_notes', 'productTypes']); + expect($response['_notes'])->toBe('Retrieved all Commerce product types.'); + expect($response['productTypes'])->toBeArray(); +}); + +it('returns correct keys for each product type', function () { + $response = $this->tool->__invoke(); + + if (!empty($response['productTypes'])) { + $type = $response['productTypes'][0]; + expect($type)->toHaveKeys([ + 'id', + 'name', + 'handle', + 'fieldLayoutId', + 'variantFieldLayoutId', + 'hasDimensions', + 'hasProductTitleField', + 'productTitleFormat', + 'hasVariantTitleField', + 'variantTitleFormat', + 'skuFormat', + 'maxVariants', + 'siteSettings', + ]); + expect($type['id'])->toBeInt(); + expect($type['name'])->toBeString(); + expect($type['handle'])->toBeString(); + expect($type['hasDimensions'])->toBeBool(); + expect($type['maxVariants'])->toBeInt(); + expect($type['siteSettings'])->toBeArray(); + } +}); + +it('returns site settings for each product type', function () { + $response = $this->tool->__invoke(); + + if (!empty($response['productTypes'])) { + $type = $response['productTypes'][0]; + expect($type['siteSettings'])->not->toBeEmpty(); + + $siteSetting = $type['siteSettings'][0]; + expect($siteSetting)->toHaveKeys(['siteId', 'hasUrls', 'uriFormat', 'template', 'enabledByDefault']); + expect($siteSetting['siteId'])->toBeInt(); + expect($siteSetting['hasUrls'])->toBeBool(); + expect($siteSetting['enabledByDefault'])->toBeBool(); + } +}); + +it('throws exception when Commerce is not installed', function () { + // This test validates the tool's own guard clause. + // When Commerce IS installed, this test verifies the guard doesn't trigger. + // The actual "not installed" path is tested implicitly by the class_exists check in beforeEach. + $response = $this->tool->__invoke(); + + expect($response)->toHaveKey('productTypes'); +}); + +it('returns non-empty product types list from project config', function () { + // The project config includes a "general" product type, so this should never be empty + $response = $this->tool->__invoke(); + + expect($response['productTypes'])->not->toBeEmpty(); + expect($response['productTypes'][0]['name'])->toBeString(); + expect($response['productTypes'][0]['handle'])->toBeString(); +}); diff --git a/tests/GetProductsTest.php b/tests/GetProductsTest.php new file mode 100644 index 0000000..5de9633 --- /dev/null +++ b/tests/GetProductsTest.php @@ -0,0 +1,179 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetProducts::class); +}); + +it('returns products with expected structure', function () { + $response = $this->tool->__invoke(); + + expect($response)->toBeArray(); + expect($response)->toHaveKeys(['_notes', 'results']); + expect($response['_notes'])->toBe('The following products were found.'); + expect($response['results'])->toBeInstanceOf(\Illuminate\Support\Collection::class); +}); + +it('respects limit parameter', function () { + $limit = 2; + $response = $this->tool->__invoke(limit: $limit); + + expect($response['results']->count())->toBeLessThanOrEqual($limit); +}); + +it('generates correct notes for search query', function () { + $response = $this->tool->__invoke(query: 'widget'); + + expect($response['_notes'])->toContain('search query "widget"'); +}); + +it('generates correct notes for type filter', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $typeId = $productTypes[0]->id; + $typeName = $productTypes[0]->name; + + $response = $this->tool->__invoke(typeIds: [$typeId]); + + expect($response['_notes'])->toContain('product type(s):'); + expect($response['_notes'])->toContain($typeName); +}); + +it('generates correct notes with no filters', function () { + $response = $this->tool->__invoke(); + + expect($response['_notes'])->toBe('The following products were found.'); +}); + +it('throws exception for invalid product type ID', function () { + expect(fn () => $this->tool->__invoke(typeIds: [99999])) + ->toThrow(\RuntimeException::class, 'Product type with ID 99999 not found'); +}); + +it('returns correct keys for each product in results', function () { + // Create a product to ensure there's at least one result + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Products List Test'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-LIST-001'; + $variant->basePrice = 9.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $response = $this->tool->__invoke(); + + if ($response['results']->isNotEmpty()) { + $first = $response['results']->first(); + expect($first)->toHaveKeys([ + 'productId', + 'title', + 'slug', + 'status', + 'typeId', + 'defaultSku', + 'defaultPrice', + 'url', + ]); + expect($first['productId'])->toBeInt(); + expect($first['title'])->toBeString(); + } +}); + +it('generates correct notes for combined query and type filter', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $typeId = $productTypes[0]->id; + + $response = $this->tool->__invoke(query: 'test', typeIds: [$typeId]); + + expect($response['_notes'])->toContain('search query "test"'); + expect($response['_notes'])->toContain('product type(s):'); +}); + +it('returns empty results for non-matching search query', function () { + $response = $this->tool->__invoke(query: 'zzznonexistentproductxxx'); + + expect($response['results'])->toBeInstanceOf(\Illuminate\Support\Collection::class); + expect($response['results'])->toBeEmpty(); +}); + +it('filters products by status', function () { + // Create an enabled product + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Status Filter Test'; + $product->enabled = false; // Disabled product + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-STATUS-001'; + $variant->basePrice = 10.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + // Searching for disabled products should find it + $response = $this->tool->__invoke(status: 'disabled'); + + expect($response['results'])->not->toBeEmpty(); + $found = $response['results']->contains(fn ($p) => $p['productId'] === $product->id); + expect($found)->toBeTrue(); + + // Searching for live products should NOT find it + $responseLive = $this->tool->__invoke(status: 'live'); + $foundLive = $responseLive['results']->contains(fn ($p) => $p['productId'] === $product->id); + expect($foundLive)->toBeFalse(); +}); + +it('filters products by multiple type IDs', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (count($productTypes) < 1) { + $this->markTestSkipped('Need at least one product type.'); + } + + // Using the same type ID twice in the array is valid — verifies the array handling + $typeId = $productTypes[0]->id; + $response = $this->tool->__invoke(typeIds: [$typeId]); + + // Should succeed without error — verifies multiple type IDs path + expect($response['results'])->toBeInstanceOf(\Illuminate\Support\Collection::class); + expect($response['_notes'])->toContain('product type(s):'); +}); diff --git a/tests/GetStoreTest.php b/tests/GetStoreTest.php new file mode 100644 index 0000000..1e476ff --- /dev/null +++ b/tests/GetStoreTest.php @@ -0,0 +1,119 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetStore::class); + + // Get the primary store ID for testing + $commerce = \craft\commerce\Plugin::getInstance(); + $primaryStore = $commerce->getStores()->getPrimaryStore(); + $this->storeId = $primaryStore->id; +}); + +it('returns store details with expected structure', function () { + $response = $this->tool->__invoke(storeId: $this->storeId); + + expect($response)->toBeArray(); + expect($response)->toHaveKeys([ + '_notes', + 'id', + 'name', + 'handle', + 'primary', + 'currency', + 'autoSetNewCartAddresses', + 'autoSetCartShippingMethodOption', + 'autoSetPaymentSource', + 'allowEmptyCartOnCheckout', + 'allowCheckoutWithoutPayment', + 'allowPartialPaymentOnCheckout', + 'requireShippingAddressAtCheckout', + 'requireBillingAddressAtCheckout', + 'requireShippingMethodSelectionAtCheckout', + 'useBillingAddressForTax', + 'validateOrganizationTaxIdAsVatId', + 'orderReferenceFormat', + 'freeOrderPaymentStrategy', + 'minimumTotalPriceStrategy', + 'sortOrder', + 'sites', + 'url', + ]); + expect($response['_notes'])->toBe('Retrieved store details.'); +}); + +it('returns correct types for store fields', function () { + $response = $this->tool->__invoke(storeId: $this->storeId); + + expect($response['id'])->toBeInt(); + expect($response['name'])->toBeString(); + expect($response['handle'])->toBeString(); + expect($response['primary'])->toBeBool(); + expect($response['currency'])->toBeString(); + expect($response['autoSetNewCartAddresses'])->toBeBool(); + expect($response['autoSetCartShippingMethodOption'])->toBeBool(); + expect($response['autoSetPaymentSource'])->toBeBool(); + expect($response['allowEmptyCartOnCheckout'])->toBeBool(); + expect($response['allowCheckoutWithoutPayment'])->toBeBool(); + expect($response['allowPartialPaymentOnCheckout'])->toBeBool(); + expect($response['requireShippingAddressAtCheckout'])->toBeBool(); + expect($response['requireBillingAddressAtCheckout'])->toBeBool(); + expect($response['requireShippingMethodSelectionAtCheckout'])->toBeBool(); + expect($response['useBillingAddressForTax'])->toBeBool(); + expect($response['validateOrganizationTaxIdAsVatId'])->toBeBool(); + expect($response['orderReferenceFormat'])->toBeString(); + expect($response['freeOrderPaymentStrategy'])->toBeString(); + expect($response['minimumTotalPriceStrategy'])->toBeString(); + expect($response['sortOrder'])->toBeInt(); + expect($response['sites'])->toBeArray(); + expect($response['url'])->toBeString(); +}); + +it('returns the correct store by ID', function () { + $response = $this->tool->__invoke(storeId: $this->storeId); + + expect($response['id'])->toBe($this->storeId); +}); + +it('returns primary store details matching project config', function () { + $response = $this->tool->__invoke(storeId: $this->storeId); + + expect($response['primary'])->toBeTrue(); + expect($response['handle'])->toBe('primary'); + expect($response['currency'])->toBe('USD'); +}); + +it('returns site information for the store', function () { + $response = $this->tool->__invoke(storeId: $this->storeId); + + if (!empty($response['sites'])) { + $site = $response['sites'][0]; + expect($site)->toHaveKeys(['id', 'name', 'handle']); + expect($site['id'])->toBeInt(); + expect($site['name'])->toBeString(); + expect($site['handle'])->toBeString(); + } +}); + +it('returns valid strategy values', function () { + $response = $this->tool->__invoke(storeId: $this->storeId); + + expect($response['freeOrderPaymentStrategy'])->toBeIn(['complete', 'process']); + expect($response['minimumTotalPriceStrategy'])->toBeIn(['default', 'zero', 'shipping']); +}); + +it('returns a control panel settings URL', function () { + $response = $this->tool->__invoke(storeId: $this->storeId); + + expect($response['url'])->toContain('commerce/store-management/'); +}); + +it('throws exception for non-existent store', function () { + expect(fn () => $this->tool->__invoke(storeId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Store with ID 99999 not found'); +}); diff --git a/tests/GetStoresTest.php b/tests/GetStoresTest.php new file mode 100644 index 0000000..1bd1e0a --- /dev/null +++ b/tests/GetStoresTest.php @@ -0,0 +1,122 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetStores::class); +}); + +it('returns stores array with expected structure', function () { + $response = $this->tool->__invoke(); + + expect($response)->toBeArray(); + expect($response)->toHaveKeys(['_notes', 'stores']); + expect($response['_notes'])->toBe('Retrieved all Commerce stores.'); + expect($response['stores'])->toBeArray(); +}); + +it('returns at least one store', function () { + $response = $this->tool->__invoke(); + + expect($response['stores'])->not->toBeEmpty(); +}); + +it('returns correct keys for each store', function () { + $response = $this->tool->__invoke(); + + $store = $response['stores'][0]; + expect($store)->toHaveKeys([ + 'id', + 'name', + 'handle', + 'primary', + 'currency', + 'autoSetNewCartAddresses', + 'autoSetCartShippingMethodOption', + 'autoSetPaymentSource', + 'allowEmptyCartOnCheckout', + 'allowCheckoutWithoutPayment', + 'allowPartialPaymentOnCheckout', + 'requireShippingAddressAtCheckout', + 'requireBillingAddressAtCheckout', + 'requireShippingMethodSelectionAtCheckout', + 'useBillingAddressForTax', + 'validateOrganizationTaxIdAsVatId', + 'orderReferenceFormat', + 'freeOrderPaymentStrategy', + 'minimumTotalPriceStrategy', + 'sortOrder', + 'sites', + 'url', + ]); +}); + +it('returns correct types for store fields', function () { + $response = $this->tool->__invoke(); + + $store = $response['stores'][0]; + expect($store['id'])->toBeInt(); + expect($store['name'])->toBeString(); + expect($store['handle'])->toBeString(); + expect($store['primary'])->toBeBool(); + expect($store['currency'])->toBeString(); + expect($store['autoSetNewCartAddresses'])->toBeBool(); + expect($store['autoSetCartShippingMethodOption'])->toBeBool(); + expect($store['autoSetPaymentSource'])->toBeBool(); + expect($store['allowEmptyCartOnCheckout'])->toBeBool(); + expect($store['allowCheckoutWithoutPayment'])->toBeBool(); + expect($store['allowPartialPaymentOnCheckout'])->toBeBool(); + expect($store['requireShippingAddressAtCheckout'])->toBeBool(); + expect($store['requireBillingAddressAtCheckout'])->toBeBool(); + expect($store['requireShippingMethodSelectionAtCheckout'])->toBeBool(); + expect($store['useBillingAddressForTax'])->toBeBool(); + expect($store['validateOrganizationTaxIdAsVatId'])->toBeBool(); + expect($store['orderReferenceFormat'])->toBeString(); + expect($store['freeOrderPaymentStrategy'])->toBeString(); + expect($store['minimumTotalPriceStrategy'])->toBeString(); + expect($store['sortOrder'])->toBeInt(); + expect($store['sites'])->toBeArray(); + expect($store['url'])->toBeString(); +}); + +it('includes a primary store', function () { + $response = $this->tool->__invoke(); + + $primaryStores = array_filter($response['stores'], fn ($s) => $s['primary'] === true); + + expect($primaryStores)->not->toBeEmpty('At least one store should be the primary store'); +}); + +it('returns site information for each store', function () { + $response = $this->tool->__invoke(); + + $store = $response['stores'][0]; + + if (!empty($store['sites'])) { + $site = $store['sites'][0]; + expect($site)->toHaveKeys(['id', 'name', 'handle']); + expect($site['id'])->toBeInt(); + expect($site['name'])->toBeString(); + expect($site['handle'])->toBeString(); + } +}); + +it('returns valid currency codes', function () { + $response = $this->tool->__invoke(); + + foreach ($response['stores'] as $store) { + expect(strlen($store['currency']))->toBe(3, 'Currency should be a 3-letter ISO code'); + } +}); + +it('returns valid strategy values', function () { + $response = $this->tool->__invoke(); + + $store = $response['stores'][0]; + expect($store['freeOrderPaymentStrategy'])->toBeIn(['complete', 'process']); + expect($store['minimumTotalPriceStrategy'])->toBeIn(['default', 'zero', 'shipping']); +}); diff --git a/tests/GetUserFieldLayoutTest.php b/tests/GetUserFieldLayoutTest.php new file mode 100644 index 0000000..f65b1c2 --- /dev/null +++ b/tests/GetUserFieldLayoutTest.php @@ -0,0 +1,14 @@ +get(GetUserFieldLayout::class); + + $response = $tool->__invoke(); + + expect($response['fieldLayout']['id'])->toBe(GetUserFieldLayout::PLACEHOLDER_ID) + ->and($response['fieldLayout']['type'])->toBe(User::class) + ->and($response['settingsUrl'])->toContain('settings/users'); +}); diff --git a/tests/GetUserGroupTest.php b/tests/GetUserGroupTest.php new file mode 100644 index 0000000..3d1460a --- /dev/null +++ b/tests/GetUserGroupTest.php @@ -0,0 +1,16 @@ +markTestSkipped('User groups require Craft Pro in this environment.'); + } + + $group = createTestUserGroup('Managers'); + $tool = Craft::$container->get(GetUserGroup::class); + + $response = $tool->__invoke(handle: $group->handle); + + expect($response['handle'])->toBe($group->handle); +}); diff --git a/tests/GetUserGroupsTest.php b/tests/GetUserGroupsTest.php new file mode 100644 index 0000000..9abeccf --- /dev/null +++ b/tests/GetUserGroupsTest.php @@ -0,0 +1,16 @@ +markTestSkipped('User groups require Craft Pro in this environment.'); + } + + createTestUserGroup('Authors'); + $tool = Craft::$container->get(GetUserGroups::class); + + $response = $tool->__invoke(); + + expect(collect($response['results'])->pluck('name'))->toContain('Authors'); +}); diff --git a/tests/GetUserTest.php b/tests/GetUserTest.php new file mode 100644 index 0000000..0b2a0f4 --- /dev/null +++ b/tests/GetUserTest.php @@ -0,0 +1,23 @@ +tool = Craft::$container->get(GetUser::class); +}); + +it('gets a user by id', function () { + $user = createTestUser(); + + $response = $this->tool->__invoke(userId: $user->id); + + expect($response['id'])->toBe($user->id); +}); + +it('gets a user by email', function () { + $user = createTestUser(); + + $response = $this->tool->__invoke(email: $user->email); + + expect($response['email'])->toBe($user->email); +}); diff --git a/tests/GetUsersTest.php b/tests/GetUsersTest.php new file mode 100644 index 0000000..6ac4500 --- /dev/null +++ b/tests/GetUsersTest.php @@ -0,0 +1,29 @@ +tool = Craft::$container->get(GetUsers::class); +}); + +it('lists users', function () { + createTestUser(); + + $response = $this->tool->__invoke(limit: 10); + + expect($response['results'])->not->toBeEmpty(); +}); + +it('filters users by group handle', function () { + if (!craftSupportsUserGroups()) { + $this->markTestSkipped('User groups require Craft Pro in this environment.'); + } + + $user = createTestUser(); + $group = createTestUserGroup('Members'); + Craft::$app->getUsers()->assignUserToGroups($user->id, [$group->id]); + + $response = $this->tool->__invoke(groupHandle: $group->handle); + + expect(collect($response['results'])->pluck('id'))->toContain($user->id); +}); diff --git a/tests/GetVariantTest.php b/tests/GetVariantTest.php new file mode 100644 index 0000000..86f099b --- /dev/null +++ b/tests/GetVariantTest.php @@ -0,0 +1,209 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(GetVariant::class); +}); + +it('throws exception for non-existent variant', function () { + expect(fn () => $this->tool->__invoke(variantId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Variant with ID 99999 not found'); +}); + +it('retrieves variant details with expected structure', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Product for Variant Test'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-GETVAR-001'; + $variant->basePrice = 49.99; + $variant->isDefault = true; + $variant->weight = 2.5; + $variant->height = 10.0; + $variant->length = 20.0; + $variant->width = 15.0; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + // Re-fetch product from DB to get saved variant with ID + $freshProduct = Craft::$app->getElements()->getElementById($product->id, \craft\commerce\elements\Product::class); + $savedVariant = $freshProduct->getVariants()->first(); + expect($savedVariant)->not->toBeNull('Variant should exist after save'); + + $response = $this->tool->__invoke(variantId: $savedVariant->id); + + expect($response)->toHaveKeys([ + '_notes', + 'variantId', + 'title', + 'sku', + 'price', + 'isDefault', + 'sortOrder', + 'stock', + 'minQty', + 'maxQty', + 'weight', + 'height', + 'length', + 'width', + 'freeShipping', + 'inventoryTracked', + 'productId', + 'productTitle', + 'url', + 'customFields', + ]); + expect($response['_notes'])->toBe('Retrieved variant details.'); + expect($response['sku'])->toBe('TEST-GETVAR-001'); + expect($response['price'])->toBe(49.99); + expect($response['isDefault'])->toBeTrue(); +}); + +it('includes parent product information', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Parent Product'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-GETVAR-PARENT'; + $variant->basePrice = 19.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + // Re-fetch product from DB to get saved variant with ID + $freshProduct = Craft::$app->getElements()->getElementById($product->id, \craft\commerce\elements\Product::class); + $savedVariant = $freshProduct->getVariants()->first(); + expect($savedVariant)->not->toBeNull('Variant should exist after save'); + + $response = $this->tool->__invoke(variantId: $savedVariant->id); + + expect($response['productId'])->toBe($product->id); + expect($response['productTitle'])->toBe('Parent Product'); + expect($response['url'])->toBeString(); +}); + +it('returns correct dimension values', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Dimensions Test Product'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-DIM-001'; + $variant->basePrice = 30.00; + $variant->isDefault = true; + $variant->weight = 5.25; + $variant->height = 15.0; + $variant->length = 30.0; + $variant->width = 20.0; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $freshProduct = Craft::$app->getElements()->getElementById($product->id, \craft\commerce\elements\Product::class); + $savedVariant = $freshProduct->getVariants()->first(); + + $response = $this->tool->__invoke(variantId: $savedVariant->id); + + expect((float) $response['weight'])->toBe(5.25); + expect((float) $response['height'])->toBe(15.0); + expect((float) $response['length'])->toBe(30.0); + expect((float) $response['width'])->toBe(20.0); +}); + +it('returns freeShipping and inventoryTracked flags', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Flags Test Product'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-FLAGS-001'; + $variant->basePrice = 5.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $freshProduct = Craft::$app->getElements()->getElementById($product->id, \craft\commerce\elements\Product::class); + $savedVariant = $freshProduct->getVariants()->first(); + + $response = $this->tool->__invoke(variantId: $savedVariant->id); + + expect($response['freeShipping'])->toBeBool(); + expect($response['inventoryTracked'])->toBeBool(); +}); + +it('returns customFields as array', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Variant Custom Fields Test'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-VCF-001'; + $variant->basePrice = 12.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + Craft::$app->getElements()->saveElement($product); + + $freshProduct = Craft::$app->getElements()->getElementById($product->id, \craft\commerce\elements\Product::class); + $savedVariant = $freshProduct->getVariants()->first(); + + $response = $this->tool->__invoke(variantId: $savedVariant->id); + + expect($response['customFields'])->toBeArray(); +}); diff --git a/tests/MatrixFieldIntegrationTest.php b/tests/MatrixFieldIntegrationTest.php index 08349d2..e3d18ce 100644 --- a/tests/MatrixFieldIntegrationTest.php +++ b/tests/MatrixFieldIntegrationTest.php @@ -99,7 +99,7 @@ expect($field)->toBeInstanceOf(\craft\fields\Matrix::class); expect($field->minEntries)->toBe(1); expect($field->maxEntries)->toBe(20); - expect($field->viewMode)->toBe('cards'); + expect($field->viewMode)->toBe('cards-grid'); expect($field->showCardsInGrid)->toBeTrue(); expect($field->createButtonLabel)->toBe('Add Content Block'); diff --git a/tests/Pest.php b/tests/Pest.php index d81a04d..583c466 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,15 @@ in('./'); +require_once __DIR__ . '/AddressTestHelpers.php'; +require_once __DIR__ . '/UserTestHelpers.php'; + +beforeEach(function () { + (function (): void { + $this->_layouts = null; + })->call(Craft::$app->getFields()); + + (function (): void { + $this->_sections = null; + $this->_entryTypes = null; + })->call(Craft::$app->getEntries()); +}); + /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/SearchOrdersTest.php b/tests/SearchOrdersTest.php new file mode 100644 index 0000000..2d34a32 --- /dev/null +++ b/tests/SearchOrdersTest.php @@ -0,0 +1,163 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(SearchOrders::class); +}); + +it('returns orders with expected structure', function () { + $response = $this->tool->__invoke(); + + expect($response)->toBeArray(); + expect($response)->toHaveKeys(['_notes', 'results']); + expect($response['_notes'])->toBe('The following orders were found.'); + expect($response['results'])->toBeInstanceOf(\Illuminate\Support\Collection::class); +}); + +it('respects limit parameter', function () { + $limit = 2; + $response = $this->tool->__invoke(limit: $limit); + + expect($response['results']->count())->toBeLessThanOrEqual($limit); +}); + +it('generates correct notes for search query', function () { + $response = $this->tool->__invoke(query: 'test-order'); + + expect($response['_notes'])->toContain('search query "test-order"'); +}); + +it('generates correct notes for email filter', function () { + $response = $this->tool->__invoke(email: 'customer@example.com'); + + expect($response['_notes'])->toContain('email "customer@example.com"'); +}); + +it('generates correct notes for completed filter', function () { + $response = $this->tool->__invoke(isCompleted: true); + + expect($response['_notes'])->toContain('completed orders'); +}); + +it('generates correct notes for active carts filter', function () { + $response = $this->tool->__invoke(isCompleted: false); + + expect($response['_notes'])->toContain('active carts'); +}); + +it('generates correct notes for paid status filter', function () { + $response = $this->tool->__invoke(paidStatus: 'paid'); + + expect($response['_notes'])->toContain('paid status: paid'); +}); + +it('generates correct notes with no filters', function () { + $response = $this->tool->__invoke(); + + expect($response['_notes'])->toBe('The following orders were found.'); +}); + +it('generates correct notes for combined filters', function () { + $response = $this->tool->__invoke( + email: 'test@example.com', + isCompleted: true, + ); + + expect($response['_notes'])->toContain('email "test@example.com"'); + expect($response['_notes'])->toContain('completed orders'); +}); + +it('returns correct keys for each order in results', function () { + // Create an order to ensure there's at least one result + $commerce = \craft\commerce\Plugin::getInstance(); + + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + + Craft::$app->getElements()->saveElement($order); + + $response = $this->tool->__invoke(); + + if ($response['results']->isNotEmpty()) { + $first = $response['results']->first(); + expect($first)->toHaveKeys([ + 'orderId', + 'number', + 'reference', + 'email', + 'isCompleted', + 'dateOrdered', + 'total', + 'totalPaid', + 'paidStatus', + 'currency', + 'url', + ]); + expect($first['orderId'])->toBeInt(); + } +}); + +it('generates correct notes for orderStatusId filter', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $statuses = $commerce->getOrderStatuses()->getAllOrderStatuses(); + + if (empty($statuses)) { + $this->markTestSkipped('No order statuses configured in Commerce.'); + } + + $status = $statuses[0]; + $response = $this->tool->__invoke(orderStatusId: $status->id); + + expect($response['_notes'])->toContain('status:'); + expect($response['_notes'])->toContain($status->name); +}); + +it('filters by isCompleted and returns only matching orders', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + + // Create an incomplete order (cart) + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + $order->isCompleted = false; + + Craft::$app->getElements()->saveElement($order); + + // Search for active carts — our order should be present + $response = $this->tool->__invoke(isCompleted: false); + + $foundIds = $response['results']->pluck('orderId')->toArray(); + expect($foundIds)->toContain($order->id); +}); + +it('generates correct notes for dateOrderedAfter filter', function () { + // dateOrderedAfter/Before don't show in _notes by default, + // but we can verify the query runs without error + $response = $this->tool->__invoke(dateOrderedAfter: '2020-01-01T00:00:00+00:00'); + + expect($response)->toHaveKeys(['_notes', 'results']); + expect($response['results'])->toBeInstanceOf(\Illuminate\Support\Collection::class); +}); + +it('generates correct notes for dateOrderedBefore filter', function () { + $response = $this->tool->__invoke(dateOrderedBefore: '2099-12-31T23:59:59+00:00'); + + expect($response)->toHaveKeys(['_notes', 'results']); + expect($response['results'])->toBeInstanceOf(\Illuminate\Support\Collection::class); +}); + +it('supports combined dateOrderedAfter and dateOrderedBefore range filter', function () { + $response = $this->tool->__invoke( + dateOrderedAfter: '2020-01-01T00:00:00+00:00', + dateOrderedBefore: '2099-12-31T23:59:59+00:00', + ); + + expect($response)->toHaveKeys(['_notes', 'results']); + expect($response['results'])->toBeInstanceOf(\Illuminate\Support\Collection::class); +}); diff --git a/tests/UpdateAddressTest.php b/tests/UpdateAddressTest.php new file mode 100644 index 0000000..04cf958 --- /dev/null +++ b/tests/UpdateAddressTest.php @@ -0,0 +1,42 @@ +tool = Craft::$container->get(UpdateAddress::class); +}); + +it('updates a user-owned address', function () { + $user = createTestUserOwner(); + $address = createUserOwnedAddress($user); + + $response = $this->tool->__invoke( + addressId: $address->id, + addressLine1: '456 Updated St', + locality: 'Seattle', + ); + + expect($response['_notes'])->toBe('The address was successfully updated.'); + expect($response['addressLine1'])->toBe('456 Updated St'); + expect($response['locality'])->toBe('Seattle'); +}); + +it('updates a field-owned address', function () { + ['entry' => $entry, 'field' => $field] = createTestEntryOwnerWithAddressesField(); + $address = createFieldOwnedAddress($entry, $field); + + $response = $this->tool->__invoke( + addressId: $address->id, + title: 'Updated Office', + postalCode: '94107', + ); + + expect($response['title'])->toBe('Updated Office'); + expect($response['postalCode'])->toBe('94107'); + expect($response['fieldId'])->toBe($field->id); +}); + +it('throws when address is not found', function () { + expect(fn() => $this->tool->__invoke(addressId: 99999, locality: 'Nowhere')) + ->toThrow(\InvalidArgumentException::class, 'Address with ID 99999 not found'); +}); diff --git a/tests/UpdateDraftTest.php b/tests/UpdateDraftTest.php index bbf7124..e9be83d 100644 --- a/tests/UpdateDraftTest.php +++ b/tests/UpdateDraftTest.php @@ -5,7 +5,8 @@ use happycog\craftmcp\tools\UpdateDraft; beforeEach(function () { - $this->section = Craft::$app->getEntries()->getAllSections()[0]; + $this->section = Craft::$app->getEntries()->getSectionByHandle('news'); + expect($this->section)->not->toBeNull(); $this->sectionId = $this->section->id; $this->entryTypeId = $this->section->getEntryTypes()[0]->id; @@ -168,4 +169,4 @@ expect($response['title'])->toBe('Original Title'); // Should remain unchanged expect($response['draftId'])->toBe($draftId); -}); \ No newline at end of file +}); diff --git a/tests/UpdateOrderTest.php b/tests/UpdateOrderTest.php new file mode 100644 index 0000000..bc0e1a8 --- /dev/null +++ b/tests/UpdateOrderTest.php @@ -0,0 +1,113 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(UpdateOrder::class); + + $commerce = Commerce::getInstance(); + + // Create a reusable order for update tests + $order = new \craft\commerce\elements\Order(); + $order->number = $commerce->getCarts()->generateCartNumber(); + $order->currency = 'USD'; + + $success = Craft::$app->getElements()->saveElement($order); + expect($success)->toBeTrue(); + + $this->order = $order; +}); + +it('can update order message', function () { + $response = $this->tool->__invoke( + orderId: $this->order->id, + message: 'Updated order notes', + ); + + expect($response['message'])->toBe('Updated order notes'); + expect($response['_notes'])->toBe('The order was successfully updated.'); +}); + +it('can update order status', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $statuses = $commerce->getOrderStatuses()->getAllOrderStatuses(); + + if (empty($statuses)) { + $this->markTestSkipped('No order statuses configured in Commerce.'); + } + + $statusId = $statuses[0]->id; + + $response = $this->tool->__invoke( + orderId: $this->order->id, + orderStatusId: $statusId, + ); + + expect($response['orderStatusId'])->toBe($statusId); + expect($response['orderStatusName'])->toBe($statuses[0]->name); +}); + +it('returns proper response format after update', function () { + $response = $this->tool->__invoke( + orderId: $this->order->id, + message: 'Format test', + ); + + expect($response)->toHaveKeys([ + '_notes', + 'orderId', + 'number', + 'reference', + 'orderStatusId', + 'orderStatusName', + 'message', + 'url', + ]); + expect($response['orderId'])->toBe($this->order->id); + expect($response['url'])->toBeString(); +}); + +it('throws exception for non-existent order', function () { + expect(fn () => $this->tool->__invoke(orderId: 99999, message: 'test')) + ->toThrow(\InvalidArgumentException::class, 'Order with ID 99999 not found'); +}); + +it('throws exception for invalid order status ID', function () { + expect(fn () => $this->tool->__invoke( + orderId: $this->order->id, + orderStatusId: 99999, + ))->toThrow(\InvalidArgumentException::class, 'Order status with ID 99999 not found'); +}); + +it('handles empty update gracefully', function () { + $response = $this->tool->__invoke( + orderId: $this->order->id, + ); + + expect($response['orderId'])->toBe($this->order->id); +}); + +it('can update both status and message at once', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $statuses = $commerce->getOrderStatuses()->getAllOrderStatuses(); + + if (empty($statuses)) { + $this->markTestSkipped('No order statuses configured in Commerce.'); + } + + $statusId = $statuses[0]->id; + + $response = $this->tool->__invoke( + orderId: $this->order->id, + orderStatusId: $statusId, + message: 'Status and message updated', + ); + + expect($response['orderStatusId'])->toBe($statusId); + expect($response['message'])->toBe('Status and message updated'); +}); diff --git a/tests/UpdateProductTest.php b/tests/UpdateProductTest.php new file mode 100644 index 0000000..9be8dd3 --- /dev/null +++ b/tests/UpdateProductTest.php @@ -0,0 +1,148 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(UpdateProduct::class); + + // Create a reusable product for update tests + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Original Product Title'; + $product->slug = 'original-product-slug'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-UPD-001'; + $variant->basePrice = 19.99; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + $success = Craft::$app->getElements()->saveElement($product); + expect($success)->toBeTrue(); + + $this->product = $product; +}); + +it('can update product title', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + title: 'Updated Product Title', + ); + + expect($response['title'])->toBe('Updated Product Title'); + expect($response['_notes'])->toBe('The product was successfully updated.'); + + $updated = Craft::$app->getElements()->getElementById($this->product->id, \craft\commerce\elements\Product::class); + expect($updated->title)->toBe('Updated Product Title'); +}); + +it('can update product slug', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + slug: 'new-product-slug', + ); + + expect($response['slug'])->toBe('new-product-slug'); +}); + +it('can disable a product', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + enabled: false, + ); + + expect($response['status'])->toBe('disabled'); +}); + +it('returns proper response format after update', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + title: 'Format Test', + ); + + expect($response)->toHaveKeys([ + '_notes', + 'productId', + 'title', + 'slug', + 'status', + 'url', + ]); + expect($response['productId'])->toBe($this->product->id); + expect($response['url'])->toBeString(); +}); + +it('preserves unchanged fields when updating', function () { + $originalSlug = $this->product->slug; + + $this->tool->__invoke( + productId: $this->product->id, + title: 'Only Title Changed', + ); + + $updated = Craft::$app->getElements()->getElementById($this->product->id, \craft\commerce\elements\Product::class); + expect($updated->title)->toBe('Only Title Changed'); + expect($updated->slug)->toBe($originalSlug); +}); + +it('throws exception for non-existent product', function () { + expect(fn () => $this->tool->__invoke(productId: 99999, title: 'Test')) + ->toThrow(\InvalidArgumentException::class, 'Product with ID 99999 not found'); +}); + +it('handles empty update gracefully', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + ); + + expect($response['productId'])->toBe($this->product->id); + expect($response['title'])->toBe('Original Product Title'); +}); + +it('can update product postDate', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + postDate: '2025-06-15T10:00:00+00:00', + ); + + $updated = Craft::$app->getElements()->getElementById($this->product->id, \craft\commerce\elements\Product::class); + expect($updated->postDate)->not->toBeNull(); + expect($updated->postDate->format('Y-m-d'))->toBe('2025-06-15'); +}); + +it('can update product expiryDate', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + expiryDate: '2030-12-31T23:59:59+00:00', + ); + + $updated = Craft::$app->getElements()->getElementById($this->product->id, \craft\commerce\elements\Product::class); + expect($updated->expiryDate)->not->toBeNull(); + expect($updated->expiryDate->format('Y-m-d'))->toBe('2030-12-31'); +}); + +it('can update multiple fields at once', function () { + $response = $this->tool->__invoke( + productId: $this->product->id, + title: 'Multi-Update Title', + slug: 'multi-update-slug', + enabled: false, + ); + + expect($response['title'])->toBe('Multi-Update Title'); + expect($response['slug'])->toBe('multi-update-slug'); + expect($response['status'])->toBe('disabled'); +}); diff --git a/tests/UpdateProductTypeTest.php b/tests/UpdateProductTypeTest.php new file mode 100644 index 0000000..4c55286 --- /dev/null +++ b/tests/UpdateProductTypeTest.php @@ -0,0 +1,183 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->createTool = Craft::$container->get(CreateProductType::class); + $this->tool = Craft::$container->get(UpdateProductType::class); + + // Create a product type for testing updates + $created = $this->createTool->__invoke( + name: 'Update Test Type', + handle: 'updateTestType' . random_int(1000, 9999), + ); + $this->productTypeId = $created['id']; +}); + +it('updates product type name', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + name: 'Updated Name', + ); + + expect($response['name'])->toBe('Updated Name'); + expect($response['_notes'])->toBe('The product type was successfully updated.'); +}); + +it('updates product type handle', function () { + $newHandle = 'updatedHandle' . random_int(1000, 9999); + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + handle: $newHandle, + ); + + expect($response['handle'])->toBe($newHandle); +}); + +it('updates hasDimensions', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + hasDimensions: true, + ); + + expect($response['hasDimensions'])->toBeTrue(); +}); + +it('updates max variants', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + maxVariants: 10, + ); + + expect($response['maxVariants'])->toBe(10); +}); + +it('updates versioning setting', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + enableVersioning: true, + ); + + expect($response['enableVersioning'])->toBeTrue(); +}); + +it('updates SKU format', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + skuFormat: '{product.slug}-{sku}', + ); + + expect($response['skuFormat'])->toBe('{product.slug}-{sku}'); +}); + +it('updates product title field settings', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + hasProductTitleField: false, + productTitleFormat: '{dateCreated|date}', + ); + + expect($response['hasProductTitleField'])->toBeFalse(); + expect($response['productTitleFormat'])->toBe('{dateCreated|date}'); +}); + +it('updates variant title field settings', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + hasVariantTitleField: false, + variantTitleFormat: '{product.title} - variant', + ); + + expect($response['hasVariantTitleField'])->toBeFalse(); + expect($response['variantTitleFormat'])->toBe('{product.title} - variant'); +}); + +it('preserves unchanged settings when updating', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $before = $commerce->getProductTypes()->getProductTypeById($this->productTypeId); + $originalHandle = $before->handle; + + $this->tool->__invoke( + productTypeId: $this->productTypeId, + name: 'Only Name Changed', + ); + + $after = $commerce->getProductTypes()->getProductTypeById($this->productTypeId); + expect($after->name)->toBe('Only Name Changed'); + expect($after->handle)->toBe($originalHandle); +}); + +it('handles empty update gracefully', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + ); + + expect($response['id'])->toBe($this->productTypeId); + expect($response['_notes'])->toBe('The product type was successfully updated.'); +}); + +it('returns proper response structure', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + name: 'Response Check', + ); + + expect($response)->toHaveKeys([ + '_notes', + 'id', + 'name', + 'handle', + 'fieldLayoutId', + 'variantFieldLayoutId', + 'hasProductTitleField', + 'productTitleFormat', + 'hasVariantTitleField', + 'variantTitleFormat', + 'skuFormat', + 'hasDimensions', + 'maxVariants', + 'enableVersioning', + 'editUrl', + 'editVariantUrl', + ]); +}); + +it('throws exception for non-existent product type', function () { + expect(fn () => $this->tool->__invoke(productTypeId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Product type with ID 99999 not found'); +}); + +it('throws exception when disabling product title field without format', function () { + expect(fn () => $this->tool->__invoke( + productTypeId: $this->productTypeId, + hasProductTitleField: false, + ))->toThrow(\InvalidArgumentException::class, 'Product title format is required'); +}); + +it('throws exception when disabling variant title field without format', function () { + expect(fn () => $this->tool->__invoke( + productTypeId: $this->productTypeId, + hasVariantTitleField: false, + variantTitleFormat: '', + ))->toThrow(\InvalidArgumentException::class, 'Variant title format is required'); +}); + +it('can update multiple settings at once', function () { + $response = $this->tool->__invoke( + productTypeId: $this->productTypeId, + name: 'Multi Update', + hasDimensions: true, + maxVariants: 3, + enableVersioning: true, + ); + + expect($response['name'])->toBe('Multi Update'); + expect($response['hasDimensions'])->toBeTrue(); + expect($response['maxVariants'])->toBe(3); + expect($response['enableVersioning'])->toBeTrue(); +}); diff --git a/tests/UpdateStoreTest.php b/tests/UpdateStoreTest.php new file mode 100644 index 0000000..63571ab --- /dev/null +++ b/tests/UpdateStoreTest.php @@ -0,0 +1,201 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(UpdateStore::class); + + // Get the primary store for testing + $commerce = \craft\commerce\Plugin::getInstance(); + $primaryStore = $commerce->getStores()->getPrimaryStore(); + $this->storeId = $primaryStore->id; + $this->originalName = $primaryStore->getName(); +}); + +it('can update store name', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + name: 'Updated Store Name', + ); + + expect($response['name'])->toBe('Updated Store Name'); + expect($response['_notes'])->toBe('The store was successfully updated.'); +}); + +it('returns proper response format after update', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + name: 'Format Test Store', + ); + + expect($response)->toHaveKeys([ + '_notes', + 'id', + 'name', + 'handle', + 'primary', + 'currency', + 'url', + ]); + expect($response['id'])->toBe($this->storeId); + expect($response['url'])->toBeString(); +}); + +it('can update checkout settings', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + allowCheckoutWithoutPayment: true, + allowEmptyCartOnCheckout: true, + allowPartialPaymentOnCheckout: true, + ); + + expect($response['_notes'])->toBe('The store was successfully updated.'); + + // Verify by re-fetching the store + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect((bool) $store->getAllowCheckoutWithoutPayment())->toBeTrue(); + expect((bool) $store->getAllowEmptyCartOnCheckout())->toBeTrue(); + expect((bool) $store->getAllowPartialPaymentOnCheckout())->toBeTrue(); +}); + +it('can update address requirement settings', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + requireShippingAddressAtCheckout: true, + requireBillingAddressAtCheckout: true, + requireShippingMethodSelectionAtCheckout: true, + ); + + expect($response['_notes'])->toBe('The store was successfully updated.'); + + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect((bool) $store->getRequireShippingAddressAtCheckout())->toBeTrue(); + expect((bool) $store->getRequireBillingAddressAtCheckout())->toBeTrue(); + expect((bool) $store->getRequireShippingMethodSelectionAtCheckout())->toBeTrue(); +}); + +it('can update cart automation settings', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + autoSetNewCartAddresses: true, + autoSetCartShippingMethodOption: true, + autoSetPaymentSource: true, + ); + + expect($response['_notes'])->toBe('The store was successfully updated.'); + + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect((bool) $store->getAutoSetNewCartAddresses())->toBeTrue(); + expect((bool) $store->getAutoSetCartShippingMethodOption())->toBeTrue(); + expect((bool) $store->getAutoSetPaymentSource())->toBeTrue(); +}); + +it('can update tax settings', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + useBillingAddressForTax: true, + ); + + expect($response['_notes'])->toBe('The store was successfully updated.'); + + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect((bool) $store->getUseBillingAddressForTax())->toBeTrue(); +}); + +it('can update free order payment strategy', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + freeOrderPaymentStrategy: 'process', + ); + + expect($response['_notes'])->toBe('The store was successfully updated.'); + + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect($store->getFreeOrderPaymentStrategy())->toBe('process'); +}); + +it('can update minimum total price strategy', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + minimumTotalPriceStrategy: 'zero', + ); + + expect($response['_notes'])->toBe('The store was successfully updated.'); + + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect($store->getMinimumTotalPriceStrategy())->toBe('zero'); +}); + +it('can update order reference format', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + orderReferenceFormat: '{{number[:5]}}', + ); + + expect($response['_notes'])->toBe('The store was successfully updated.'); + + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect($store->getOrderReferenceFormat())->toBe('{{number[:5]}}'); +}); + +it('preserves unchanged settings when updating', function () { + $commerce = \craft\commerce\Plugin::getInstance(); + $storeBefore = $commerce->getStores()->getStoreById($this->storeId); + $originalCurrency = $storeBefore->getCurrency()?->getCode(); + $originalHandle = $storeBefore->handle; + + $this->tool->__invoke( + storeId: $this->storeId, + name: 'Only Name Changed', + ); + + $storeAfter = $commerce->getStores()->getStoreById($this->storeId); + expect($storeAfter->getName())->toBe('Only Name Changed'); + expect($storeAfter->getCurrency()?->getCode())->toBe($originalCurrency); + expect($storeAfter->handle)->toBe($originalHandle); +}); + +it('handles empty update gracefully', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + ); + + expect($response['id'])->toBe($this->storeId); + expect($response['_notes'])->toBe('The store was successfully updated.'); +}); + +it('throws exception for non-existent store', function () { + expect(fn () => $this->tool->__invoke(storeId: 99999)) + ->toThrow(\InvalidArgumentException::class, 'Store with ID 99999 not found'); +}); + +it('can update multiple settings at once', function () { + $response = $this->tool->__invoke( + storeId: $this->storeId, + name: 'Multi-Update Store', + allowCheckoutWithoutPayment: true, + requireBillingAddressAtCheckout: true, + freeOrderPaymentStrategy: 'process', + minimumTotalPriceStrategy: 'shipping', + ); + + expect($response['name'])->toBe('Multi-Update Store'); + + $commerce = \craft\commerce\Plugin::getInstance(); + $store = $commerce->getStores()->getStoreById($this->storeId); + expect((bool) $store->getAllowCheckoutWithoutPayment())->toBeTrue(); + expect((bool) $store->getRequireBillingAddressAtCheckout())->toBeTrue(); + expect($store->getFreeOrderPaymentStrategy())->toBe('process'); + expect($store->getMinimumTotalPriceStrategy())->toBe('shipping'); +}); diff --git a/tests/UpdateUserGroupTest.php b/tests/UpdateUserGroupTest.php new file mode 100644 index 0000000..9b86f5d --- /dev/null +++ b/tests/UpdateUserGroupTest.php @@ -0,0 +1,19 @@ +markTestSkipped('User groups require Craft Pro in this environment.'); + } + + $group = createTestUserGroup('Reviewers'); + $tool = Craft::$container->get(UpdateUserGroup::class); + + $response = $tool->__invoke( + groupId: $group->id, + permissions: ['accesscp', 'custompermission:review'], + ); + + expect($response['permissions'])->toContain('custompermission:review'); +}); diff --git a/tests/UpdateUserTest.php b/tests/UpdateUserTest.php new file mode 100644 index 0000000..d63130b --- /dev/null +++ b/tests/UpdateUserTest.php @@ -0,0 +1,33 @@ +tool = Craft::$container->get(UpdateUser::class); +}); + +it('updates a user by username', function () { + $user = createTestUser(); + + $response = $this->tool->__invoke( + username: $user->username, + fullName: 'Updated User', + ); + + expect($response['fullName'])->toBe('Updated User'); +}); + +it('updates a user permissions when supported', function () { + if (!craftSupportsUserPermissionAssignment()) { + $this->markTestSkipped('Direct user permissions require Craft Pro in this environment.'); + } + + $user = createTestUser(); + + $response = $this->tool->__invoke( + username: $user->username, + permissions: ['accesscp', 'custompermission:updated'], + ); + + expect($response['permissions'])->toContain('custompermission:updated'); +}); diff --git a/tests/UpdateVariantTest.php b/tests/UpdateVariantTest.php new file mode 100644 index 0000000..91a30f3 --- /dev/null +++ b/tests/UpdateVariantTest.php @@ -0,0 +1,213 @@ +markTestSkipped('Craft Commerce is not installed.'); + } + + $this->tool = Craft::$container->get(UpdateVariant::class); + + $commerce = \craft\commerce\Plugin::getInstance(); + $productTypes = $commerce->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->markTestSkipped('No product types configured in Commerce.'); + } + + // Create a product with a variant for update tests + $product = new \craft\commerce\elements\Product(); + $product->typeId = $productTypes[0]->id; + $product->title = 'Product for Variant Update'; + $product->enabled = true; + + $variant = new \craft\commerce\elements\Variant(); + $variant->sku = 'TEST-UPDVAR-001'; + $variant->basePrice = 25.00; + $variant->isDefault = true; + $product->setVariants([$variant]); + $product->setDirtyAttributes(['variants']); + + $success = Craft::$app->getElements()->saveElement($product); + expect($success)->toBeTrue(); + + // Re-fetch product from DB to get saved variant with ID + $freshProduct = Craft::$app->getElements()->getElementById($product->id, \craft\commerce\elements\Product::class); + $savedVariant = $freshProduct->getVariants()->first(); + expect($savedVariant)->not->toBeNull('Variant should exist after save'); + + $this->variant = $savedVariant; + $this->product = $freshProduct; +}); + +it('can update variant price', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + price: 39.99, + ); + + expect($response['price'])->toBe(39.99); + expect($response['_notes'])->toBe('The variant was successfully updated.'); +}); + +it('can update variant SKU', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + sku: 'UPDATED-SKU-001', + ); + + expect($response['sku'])->toBe('UPDATED-SKU-001'); +}); + +it('can update variant stock via inventory system', function () { + // Stock is read-only in Commerce 5.x — managed via the inventory system. + // The UpdateVariant tool does not support direct stock updates. + // Verify that stock is returned as a read-only value in the response. + $response = $this->tool->__invoke( + variantId: $this->variant->id, + price: 30.00, + ); + + expect($response)->toHaveKey('stock'); + expect($response['stock'])->toBeInt(); +}); + +it('can update multiple variant fields at once', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + price: 59.99, + sku: 'MULTI-UPD-001', + ); + + expect($response['price'])->toBe(59.99); + expect($response['sku'])->toBe('MULTI-UPD-001'); +}); + +it('returns proper response format after update', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + title: 'Updated Variant Title', + ); + + expect($response)->toHaveKeys([ + '_notes', + 'variantId', + 'title', + 'sku', + 'price', + 'stock', + 'productId', + 'url', + ]); + expect($response['variantId'])->toBe($this->variant->id); + expect($response['productId'])->toBe($this->product->id); + expect($response['url'])->toBeString(); +}); + +it('preserves unchanged fields when updating', function () { + $originalSku = $this->variant->sku; + + $this->tool->__invoke( + variantId: $this->variant->id, + price: 99.99, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect($updated->sku)->toBe($originalSku); + expect((float) $updated->price)->toBe(99.99); +}); + +it('throws exception for non-existent variant', function () { + expect(fn () => $this->tool->__invoke(variantId: 99999, price: 10.00)) + ->toThrow(\InvalidArgumentException::class, 'Variant with ID 99999 not found'); +}); + +it('handles empty update gracefully', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + ); + + expect($response['variantId'])->toBe($this->variant->id); + expect($response['sku'])->toBe('TEST-UPDVAR-001'); +}); + +it('can update variant minQty', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + minQty: 2, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect($updated->minQty)->toBe(2); +}); + +it('can update variant maxQty', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + maxQty: 50, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect($updated->maxQty)->toBe(50); +}); + +it('can update variant weight', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + weight: 3.5, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect((float) $updated->weight)->toBe(3.5); +}); + +it('can update variant height', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + height: 12.0, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect((float) $updated->height)->toBe(12.0); +}); + +it('can update variant length', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + length: 25.0, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect((float) $updated->length)->toBe(25.0); +}); + +it('can update variant width', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + width: 8.0, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect((float) $updated->width)->toBe(8.0); +}); + +it('can update variant freeShipping', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + freeShipping: true, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect($updated->freeShipping)->toBeTrue(); +}); + +it('can update variant inventoryTracked', function () { + $response = $this->tool->__invoke( + variantId: $this->variant->id, + inventoryTracked: true, + ); + + $updated = Craft::$app->getElements()->getElementById($this->variant->id, \craft\commerce\elements\Variant::class); + expect($updated->inventoryTracked)->toBeTrue(); +}); diff --git a/tests/UserFieldLayoutMutationTest.php b/tests/UserFieldLayoutMutationTest.php new file mode 100644 index 0000000..01d06ea --- /dev/null +++ b/tests/UserFieldLayoutMutationTest.php @@ -0,0 +1,113 @@ +getLayout = Craft::$container->get(GetUserFieldLayout::class); + $this->addTab = Craft::$container->get(AddTabToFieldLayout::class); + $this->addUiElement = Craft::$container->get(AddUiElementToFieldLayout::class); + $this->moveElement = Craft::$container->get(MoveElementInFieldLayout::class); + $this->removeElement = Craft::$container->get(RemoveElementFromFieldLayout::class); +}); + +it('retrieves user layout id that can be reused by field layout tools', function () { + $response = $this->getLayout->__invoke(); + + expect($response['fieldLayout']['id'])->toBe(GetUserFieldLayout::PLACEHOLDER_ID); + expect($response['fieldLayout']['type'])->toBe(User::class); +}); + +it('can add a tab to the user field layout', function () { + $layout = $this->getLayout->__invoke(); + $fieldLayoutId = $layout['fieldLayout']['id']; + + $result = $this->addTab->__invoke( + fieldLayoutId: $fieldLayoutId, + name: 'User Test Tab', + position: ['type' => 'append'], + ); + + expect(collect($result['fieldLayout']['tabs'])->pluck('name'))->toContain('User Test Tab'); +}); + +it('can add a generic ui element to the user field layout', function () { + $layout = $this->getLayout->__invoke(); + $fieldLayoutId = $layout['fieldLayout']['id']; + $tabName = collect($layout['fieldLayout']['tabs'])->pluck('name')->first() ?? 'Content'; + + $result = $this->addUiElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementType: Heading::class, + tabName: $tabName, + position: ['type' => 'append'], + config: ['heading' => 'User Layout Heading'], + ); + + $addedUid = $result['addedElement']['uid']; + expect($addedUid)->toBeString(); + + $updatedLayout = $this->getLayout->__invoke(); + $elements = collect($updatedLayout['fieldLayout']['tabs'])->flatMap(fn(array $tab) => $tab['elements']); + expect($elements->pluck('uid'))->toContain($addedUid); +}); + +it('can move a user layout element to another tab after adding it', function () { + $layout = $this->getLayout->__invoke(); + $fieldLayoutId = $layout['fieldLayout']['id']; + $sourceTabName = collect($layout['fieldLayout']['tabs'])->pluck('name')->first() ?? 'Content'; + $targetTabName = 'Moved User Elements'; + + $this->addTab->__invoke( + fieldLayoutId: $fieldLayoutId, + name: $targetTabName, + position: ['type' => 'append'], + ); + + $added = $this->addUiElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementType: Heading::class, + tabName: $sourceTabName, + position: ['type' => 'append'], + config: ['heading' => 'Move Me'], + ); + + $result = $this->moveElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementUid: $added['addedElement']['uid'], + tabName: $targetTabName, + position: ['type' => 'append'], + ); + + $targetTab = collect($result['fieldLayout']['tabs'])->firstWhere('name', $targetTabName); + + expect($targetTab)->not->toBeNull() + ->and(collect($targetTab['elements'])->pluck('uid'))->toContain($added['addedElement']['uid']); +}); + +it('can remove a user layout element after adding it', function () { + $layout = $this->getLayout->__invoke(); + $fieldLayoutId = $layout['fieldLayout']['id']; + $tabName = collect($layout['fieldLayout']['tabs'])->pluck('name')->first() ?? 'Content'; + + $added = $this->addUiElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementType: Heading::class, + tabName: $tabName, + position: ['type' => 'append'], + config: ['heading' => 'Remove Me'], + ); + + $result = $this->removeElement->__invoke( + fieldLayoutId: $fieldLayoutId, + elementUid: $added['addedElement']['uid'], + ); + + $elements = collect($result['fieldLayout']['tabs'])->flatMap(fn(array $tab) => $tab['elements']); + expect($elements->pluck('uid'))->not->toContain($added['addedElement']['uid']); +}); diff --git a/tests/UserTestHelpers.php b/tests/UserTestHelpers.php new file mode 100644 index 0000000..2d83125 --- /dev/null +++ b/tests/UserTestHelpers.php @@ -0,0 +1,75 @@ +getProjectConfig()->get('system.edition'); + + if (is_string($configuredEdition) && $configuredEdition !== '') { + Craft::$app->setEdition(CmsEdition::fromHandle($configuredEdition)); + } +} + +function createTestUser(string $emailPrefix = 'user-test', bool $admin = false): User +{ + syncCraftEditionFromProjectConfig(); + + $existingUser = User::find()->status(null)->site('*')->one(); + if ($existingUser instanceof User) { + return $existingUser; + } + + $email = $emailPrefix . '-' . uniqid('', true) . '@example.com'; + $user = new User(); + $user->email = $email; + $user->username = $email; + $user->admin = $admin; + $user->active = true; + $user->pending = false; + $user->newPassword = 'Password123!'; + $user->setScenario(User::SCENARIO_REGISTRATION); + + $saved = Craft::$app->getElements()->saveElement($user, false); + expect($saved)->toBeTrue(); + + return $user; +} + +function createTestUserGroup(string $name = 'Test Group'): UserGroup +{ + syncCraftEditionFromProjectConfig(); + + $group = new UserGroup(); + $group->name = $name; + $group->handle = StringHelper::toHandle($name . ' ' . uniqid()); + + $saved = Craft::$app->getUserGroups()->saveGroup($group); + throw_unless($saved, 'Failed to save test user group: ' . json_encode($group->getErrors())); + + return $group; +} + +function craftSupportsUserGroups(): bool +{ + syncCraftEditionFromProjectConfig(); + + return Craft::$app->edition->value >= CmsEdition::Pro->value; +} + +function craftSupportsUserPermissionAssignment(): bool +{ + syncCraftEditionFromProjectConfig(); + + return Craft::$app->edition->value >= CmsEdition::Pro->value; +} + +function craftCanCreateAdditionalUsers(): bool +{ + syncCraftEditionFromProjectConfig(); + + return Craft::$app->getUsers()->canCreateUsers(); +} diff --git a/tests/UsersControllerTest.php b/tests/UsersControllerTest.php new file mode 100644 index 0000000..b306909 --- /dev/null +++ b/tests/UsersControllerTest.php @@ -0,0 +1,26 @@ +get('/api/users'); + + $response->assertStatus(200); + $content = $response->content; + + expect($content)->toContain('"results"') + ->and($content)->toContain('"id":' . $user->id) + ->and($content)->toContain('"email":"' . $user->email . '"'); +}); + +test('GET /api/users/ gets a user', function () { + $user = createTestUser(); + + $response = $this->get('/api/users/' . $user->id); + + $response->assertStatus(200); + $content = $response->content; + + expect($content)->toContain('"id":' . $user->id) + ->and($content)->toContain('"email":"' . $user->email . '"'); +});