Commit 6dcc1570305 for woocommerce
commit 6dcc157030598005573d4db01ffbb9e51f8190a3
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Tue Nov 3 17:23:45 2026 -0300
Tell agents why an extension field in an extensible ability cannot be written (#70439)
* Spike: accept registered product types in the product abilities
Replace product_type_alias with the product type registry, and add
virtual and downloadable as fields of their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use one flat input schema for product-update
The update schema lists id, type and the fields of every supported
product type. A client no longer sends type to change a per-type field
such as regular_price. After load() resolves the product, the update
refuses a field that the product type does not support, with
woocommerce_product_field_unsupported. product-create keeps one oneOf
branch for each product type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Let the owner of a product type choose its Core fields
The product class still decides the default list of Core fields, and the
new woocommerce_ability_product_type_fields filter lets the owner of a
type add or remove one, such as a price for a bundle.
Also from the review of the spike:
- Leave a type without a class out of the create and update schemas
instead of listing it and refusing it at run time.
- Read the config of a loaded product from its class, not from its type.
- Sanitize the virtual and downloadable query filters with
rest_sanitize_boolean(), so "false" is false.
- Drop the two config checks in ProductUpdate::load() that change()
repeats.
- Say in the create schema that a product without a type is simple.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* List the fields of each product type in one flat input schema
product-create had one oneOf branch per product type. A wrong field
gave the message "input[type] is not simple", and every branch carried
a stub for the extensions property. product-create now has the same
flat schema as product-update. The description of type lists the fields
that each type takes, and the run-time check names the type, the fields
it refused, and the fields the type takes.
The woocommerce_ability_product_type_fields filter also carries the
writable extension fields of the product resource, as namespace.field.
The owner of an extension field removes it from the types it does not
apply to, so the schema and the error name the right fields for each
type. register_field() stays resource-neutral. The filter no longer
receives the class name, because the owner of a type knows its class.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the field list out of the type description
The description of type listed the fields of every product type. That
is a long sentence an agent has to parse, and it repeats what the
run-time error already says. The schema now lists every field once,
and the error names the fields the type takes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Let a field name the object types it applies to
An extension field that applies to some types of a resource had no
structured way to say so. The product filter carried extension fields
as namespace.field strings, which was product-specific and loose.
ExtensibleAbilities::register_field() takes object_types: the types,
as get_type() of the object returns them, that the field applies to.
Core leaves the field out of other objects and refuses a write to them
with woocommerce_ability_field_object_type, for every resource. The
woocommerce_ability_product_type_fields filter goes back to Core
fields only.
Also: document why a type whose class is WC_Product_Simple itself is
refused, and cast the boolean query filters with wc_string_to_bool so
phpstan passes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the object_types diagnostic untranslated
Developer diagnostics passed to wc_doing_it_wrong() stay in English, as
the lint rule requires.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Shorten the changelog entry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Refuse variations in product-update and restricted fields on unknown types
A product-update call with a variation ID could edit the variation with the
simple product fields, and with a type it could write a simple product type
on the variation post. product-update now refuses variations with the same
woocommerce_product_type_unsupported error as products-query and
product-delete, and the product class config refuses variation classes.
A field with object_types no longer applies to an object whose type is
unknown, and get_type() is only called when it is callable and the field is
restricted.
Addresses the CodeRabbit review on #70050.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename the changelog entry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Refuse a type change on a variable product
product-update replaced the loaded product with one of the requested
type before checking that the current type is supported, so a variable
product could be saved as simple and lose its variations. The current
type is checked first, as CodeRabbit pointed out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Apply forced product properties only when the type changes
A repeated `type` cleared the stock of a stocked grouped product. The
forced properties now apply only when `type` differs from the stored type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tell agents which fields each product type takes
The description of `type` lists the Core fields of each type, and an extension
field with `object_types` says which types it applies to. The field list was
removed in b669c71c74 because it looked long and repeated the error. A test with
6 models and 8 tasks showed that it helps: 87% of calls were right on the first
try with this guidance and 74% without it, with no wrong saves. Refusals stay
the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say on each Core field which product types take it
A field that not every type takes now says "Applies to: ..." in its description.
This is the shape that won a final test with 6 models and 10 clear tasks, set
against the `type` field list alone: 96% of calls were right on the first try
with both, and 92% with the list alone, with no wrong saves. The rule for
replacing the list alone was fixed before the run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove checks that repeat the input schema in the product abilities
The input schema already limits the product type, so the abilities no
longer check it again after validation. The product type fields filter
result and get_type() of a subject are used as returned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the object_types diagnostic message in English
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Prototype: create variable products with their variations in one call
Item 3 of the extensible abilities plan for 11.4, built on the custom
product types spike.
Product abilities:
- product-create accepts `attributes` on a variable type and an optional
`variations` list. Core builds every variation in memory, checks the
attribute combinations and SKUs across the call, runs extension fields
and validators on each one, then saves the parent and the variations.
- A variation is a product with `type: "variation"`. product-update,
product-delete and products-query accept its ID, and product-create
makes one from a `parent_id`. products-query filters by `parent_id`.
- The output gains `parent_id`, `attributes` and `variation_ids`.
Pipeline:
- ActionableAbility wraps save() in a database transaction and rolls it
back when save() returns an error or throws. pending_objects() lets an
ability name the other objects it saves, so the field writes and the
validators run on each of them before the first save.
- ExtensibleAbilities::get_input_schema() exposes the `extensions` input
schema of a resource, so an ability can accept it on nested objects.
Transactions:
- wc_transaction_query() counts its nesting. The first start opens a
transaction, deeper starts set savepoints, and each commit or rollback
ends its own level.
- The test bootstrap no longer turns transactions off. It enters the
transaction that the test case owns one time, so every transaction in
the code under test becomes a savepoint, and a rollback is real inside
a test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Drop the object caches of the objects a rolled back save touched
The saves fill the post and order caches with rows the rollback
removes, so a read in the same request would still see them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Change, add and delete variations through the parent update
product-update on a variable type takes the `variations` list too. An
item with an id changes that variation, an item without one creates
one, and `delete` removes one. Variations the input does not name stay
as they are. The named variations save in the same transaction as the
parent, with one parent sync, so a price change across many variations
is one call that either lands whole or not at all.
A single variation stays reachable as a product with `type: "variation"`
in the other abilities.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Check variation permissions on the parent and write up the design
A variation follows its parent: a published variation of a draft parent
is as hidden as the parent, and whoever may edit the parent may edit its
variations. The query, update and delete abilities now check the parent
of a variation id, as Wholesale Suite's own ability does.
The register_field() docblock says where a write that must touch a
second object belongs: a save hook of the object, which runs inside the
transaction of the ability. ActionableAbility says the same.
docs/abilities/variable-products.md describes the API, the mechanism,
the test setup, what reading Wholesale Suite changed, and the open items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Fire woocommerce_ability_after_save once per call, inside the transaction
After the last save and before the commit, ActionableAbility fires the
action with every saved object, the subject first, and the ability. An
extension writes there what the saved objects imply elsewhere, such as
the price range a variable parent derives from its variations, or a row
in a table of its own. A WC_Data_Exception refuses the call with its
code and message, any other exception refuses it with a fixed message,
and either way every save of the call rolls back. The action does not
fire with the feature off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Return the secondary objects from change() and replace the after-save action with syncs
An ability that saves more than one object used to build the list in
change() and hand it over from a second method, pending_objects(), through
instance state. Now change() returns the list. Each secondary object
carries its extensions input and, when it differs from the ability's
resource, the name of its resource, so an order ability can return order
items and each object gets the fields and validators of its own resource.
The woocommerce_ability_after_save action becomes
ExtensibleAbilities::register_sync(). A sync is registered on a resource
with a namespace, like a validator, runs once per call after every object
is saved and only when an object of its resource was saved, and refuses
the call by returning a WP_Error, which the action could not do. The
error carries the namespace.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Warn when a secondary object names an unregistered resource
A resource name on a secondary object is a string the ability types by
hand. An unregistered name used to get no fields and no validators with
no sign of it. The pipeline now calls wc_doing_it_wrong() for it and still
runs the call. The register_sync() docblock also says that the saved
objects can belong to more than one resource.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Drop type from variable product updates
product-update now has one flat schema, so an update of a variation or
of the variations of a parent no longer sends type. The tests and the
design doc follow, and a test checks that variations on a simple
product is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use global attributes in the product abilities
An attributes item whose name or id matches a global attribute now
builds a taxonomy attribute, as the REST API does: the attribute id,
the taxonomy name and the term ids of its options. Options without a
term keep their name, so the save creates the term inside the
transaction. A variation matches the parent's local attribute first,
then its global one, and stores the term slug. The output gives each
attribute its global id, or 0 for a local attribute.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Change a variation's options only through its parent
A direct product-update on a variation cannot check its options against
the other variations, so it could give two variations the same options.
The parent's variations list checks them against every sibling and can
move an existing variation to new options. A direct update of a
variation now refuses attributes, and the update schema takes the
parent shape only. product-create with type variation still validates
its attributes against the parent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Check a new variation against its siblings on create
product-create with type "variation" checked the options against the
parent's attributes only, so it could add a second variation with the
same options as an existing one. The sibling comparison of
plan_variations() now lives in check_variation_siblings(), and the
single-variation create runs it too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Build the variable product inputs on the flat schema
product-create no longer has a branch per type, so the fields of a
variation create, parent_id and attributes, sit on the one flat object.
A variation has no name of its own, so the create checks the name at
run time instead of requiring it in the schema. The attributes input of
a create takes both shapes, the parent's and the variation's, and the
update takes the parent's only.
Also type the variation plan for phpstan.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Apply the Wholesale Suite review of the variable product abilities
Josh Kohlbach tested PR 70203 with Wholesale Prices and reported eight
gaps. This commit closes them.
- Sibling checks and variation_ids read the children from the data
store. WC_Product_Variable::get_children() runs the
woocommerce_get_children filter, which a plugin can use to hide
variations from a user, and then a duplicate slipped past the check.
- product-delete runs on the ActionableAbility pipeline. Validators can
refuse a delete, the syncs see the deleted objects, a variation delete
syncs its parent, and a failure rolls back. A delete writes no
extension fields, so it gets no extensions input.
- A sync receives a context with the deleted objects, as they were
before the delete, and the input of the call. A sync of a resource
also runs when an object of that resource was deleted.
- A variation matches any option of an attribute with a null option.
- A parent attribute takes variation: false for an attribute that shows
on the product page only. The output reports the flag.
- A name is a local attribute unless global is true or an id names a
global attribute. An unknown global attribute is refused.
The response is built after the syncs from the primary object, which
the doc now states.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Return the created and changed variations from the product writes
A client that changed fifty prices through the parent had to read the
variations back with products-query. product-create and product-update
now return them under variations, in input order, next to the product.
Deleted variations are not listed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Rename closure parameters that use a reserved word
The branch lint warns on a parameter named object, and the lint job
fails on warnings.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Shorten the changelog entry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close the gaps an adversarial review found in the variable product abilities
- A variation create checks that the user may edit the parent.
- product-update refuses a type change into or out of a variable product
or a variation, because WooCommerce deletes the variations on that
change, and a type that does not fit the post is an error, not an
exception.
- A variations item cannot repeat an id, or delete and change at once,
or delete without an id. A new variation gets its parent id before the
validators run. A variation is published or private, which is what
WooCommerce counts as a child.
- The sibling check reads no children for a parent that has no id yet.
- product-delete gets no extensions input, and lists every child it
deletes, whatever its status.
- A global attribute is found by its label as well as its slug, and a
local attribute may send id 0, so the output of attributes is valid
input.
- The update ability clears its deleted objects between calls.
- The transaction test no longer commits the test transaction.
Also drop the design doc, which lived outside the published docs tree,
and rename the changelog entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Tell syncs apart what was trashed from what was deleted
A sync saw a trashed product in the deleted list and had no way to know
it could come back, short of reading the force flag of the input. The
context now carries trashed objects under their own key, so a sync keeps
what a restore needs. Josh Kohlbach raised it on PR 70203.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* List only the fields that apply to a variation in the variation schemas
The variations input and output repeated every product field, including
fields whose object_types rule out a variation. The schema advertised them for
nothing and grew with each extension.
The filtered input schema accepts other fields, so the write step still refuses
them with the error that names the field and the types it applies to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the variation input schema closed and refuse wrong-type fields in validate_input
The variation input accepted any field so that the write step could name the
problem. The schema stays closed now, and the product write abilities refuse a
field that cannot apply to a variation before the schema runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Support variation subclasses in the product abilities
An extension can have its own variation class. Core only knew `variation`, so reading such a variation failed the output schema, and a new variation was always plain, which refused a field limited to the subclass.
The product output no longer limits `type` to a list, because Core cannot list every class that the factory builds. A new `woocommerce_ability_variation_product_type` filter lets the owner of a variable type name the type of its new variations. The factory picks a saved variation's class from its post, and a new variation has no post while the abilities check it in memory. The check for fields on variations now resolves a type through the factory, so `woocommerce_product_class` counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Accept variation subclass types in the products query
Product output can say a type like `subscription_variation`, but
`products-query` refused that value as input. The list of types now
also has the type that each variable product type builds for its
variations.
A variation has no product type term, so the query looks for variations
whose parent has one of the parent types. A subquery does this inside
the database query, so paging stays correct. `type: variation` still
returns plain and subclass variations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Prefix the codes of a sync refusal with its namespace
A sync refusal returned the bare code of the extension, so two extensions
could return the same code. The client now sees `<namespace>:<code>`, the
format that validator refusals get.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Assert refusal codes that #70308 prefixes by their end
These refusals keep the bare code on this branch and get `<namespace>:` from
#70308, so the assertions hold with and without it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Give validators the primary object and run them on deleted variations
A new variation has parent ID 0 on product-create, because the parent is not
saved yet, so a rule that passes on update behaved differently on create. A
validator now gets `$context['primary']` as its third argument. Validators
that take two arguments keep working.
Validators also skipped variations that the `variations` input deletes and
the variations of a deleted variable parent. They now run on these too, before
any save, with `$context['deleting']` true. A refusal deletes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Return the stored slug from product-create
The data store writes post_name but never sets it back on the object, so the
output had an empty slug although the post had one. The mutation response now
reads the slug of the stored post. The data store does not change, because
that would change every product save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the new sync and secondary object notices in English
Trunk now forbids translation calls in wc_doing_it_wrong() messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Treat a variation attribute with no option as any option
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Adapt the variable product abilities to the base's schema checks
The base no longer has get_supported_product_types() or
get_product_type_config(), and get_object_type() always returns a string.
The products query builds its type list from the supported type configs,
and product-create relies on the input schema for the type. The rebase
also brings back the product type notes and the object type notes that
the base still has.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Remove checks that repeat the schema or the contract in variable product writes
The variation extensions input and output use the same product schema as
the parent, and apply_fields() refuses a field that does not apply to a
variation, so the filtered variation schema and its validate_input()
pre-check go away. A sync refusal keeps its code, as a validator refusal
does. register_validator() and register_sync() share one argument check.
product-create refuses a variation whose parent is not a variable product
in load(), and a variation attribute item with options. The variation
plan stores each variation with its extensions input. Guards on input the
schema already checked, on arrays Core builds, and on the saved SKU check
that set_sku() already does are removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* List only the fields that apply to a variation in the variation schemas
The variation extensions input and output repeated every product field,
which doubled the extension bytes of product-create and product-update.
get_input_schema() and add_fields_schema() take an optional field filter
again, and the variation schemas pass field_applies_to_variations(). A
field that does not apply is refused by the closed variation schema, and
apply_fields() still refuses a field that does not match the exact
variation type.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Refuse a secondary object whose resource is not registered
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close the gaps in nested deletes, parent syncs, rollbacks and validator context
- A variations item with delete needs the delete permission on the parent,
the same as product-delete.
- A variation create or update returns its parent as a secondary object and
syncs that instance, so validators and syncs see the saved parent.
- product-delete stores its snapshots before delete(), so a throwing delete
hook still lets the rollback drop the product caches.
- The rollback drops the order caches again.
- Every field write runs before the first validator. Validators get the
saved objects in $context['objects'] and the deleted ones in
$context['objects_to_delete'], so a rule can compare the variations of
one call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Make the ProductDelete steps protected
The base class made load(), change(), save() and prepare_response()
protected, so the delete ability follows it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Set savepoints only inside a transaction that an ability owns
wc_transaction_query() counted its nesting for every caller. A caller
that starts a transaction and never commits it, such as Mix and Match's
delete_product(), then left the counter above 0, so every later commit
in the request became RELEASE SAVEPOINT and nothing was saved.
wc_transaction_query() works as on trunk again. DatabaseTransaction::run()
owns a transaction: inside it, wc_transaction_query() sets savepoints, and
run() always ends with the COMMIT or ROLLBACK of the level it opened. The
product abilities use run(), so a variable product and its variations
still save together. The test bootstrap owns the test case's transaction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Give the variation attribute option one type
The input took `option` as a string or null, and the output returned
null for a variation that matches any option. A missing option already
meant any option on input. Now the input takes a string only, and the
output leaves `option` out for an any-option variation, so an agent can
send back what it read, and every client gets one type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse a type change into or out of a variable subclass in product-update
The guard compared type names, so a WC_Product_Variable subclass with a
type of its own, such as an extension's variable subscription, could
change to simple, which left its variations without a variable parent.
Check the class of the current product and of the requested type.
Use a prefix check instead of a regular expression in the transaction
test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Resolve the target class with the product ID in the product-update type guard
The guard asked for the class of the requested type with ID 0, while
the conversion uses the product's ID. A woocommerce_product_class filter
that depends on the ID could return a variable class only for the saved
product, and the guard then let the change through.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep a caller's transaction open when an ability runs inside it
A caller that started a transaction with wc_transaction_query() lost it when an
ability ran: run() sent START TRANSACTION, which commits the caller's earlier
writes, so the caller's later rollback undid nothing.
wc_transaction_query() now records a transaction it starts outside run(). Inside
such a transaction, run() works in a savepoint, and the caller's commit or
rollback decides the outcome, as on trunk, where abilities used no transaction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Check publish_products for a variation status change through the parent
product-update refused a direct status change to a published status without
publish_products, but a variation item of the parent's variations input skipped
the check. Both paths now use the same check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Drop the meta and product caches of rolled back objects
After a rollback, a fresh product object could still return a rejected
extension value from the WC_Data meta cache, which clean_post_cache() does not
clear. The cleanup now also drops the meta cache and the product instance cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fail a variation delete through the parent when the row stays
WC_Data::delete() returns true even when pre_delete_post blocks the delete, so
the call succeeded and reported the variation as deleted to the syncs. The
nested delete now checks the row, as the standalone delete does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse a variation that repeats the SKU of its parent
The sibling check compared the SKUs of the variations with one another, but not
with the parent's SKU, which is not saved yet when set_sku() checks it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Apply the product type fields filter to variation items
A variation item of the parent's variations input used the unfiltered variation
fields, so a field that woocommerce_ability_product_type_fields removed was
refused on a direct update but saved through the parent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Check the publish permission for new variations added through the parent
A user without publish_products could add a published variation through
product-update, because the check ran only for existing variations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tell agents why an extension field cannot be written
When the input schema refuses the input, name every bad extension field
in one message: an unregistered namespace, an unknown field with the
writable fields listed, a read-only field, or a bad value. A bad field on
a `variations` item starts with the item, as `variations[0]: `.
The variation `extensions` schema lists only the fields that apply to a
variation, so a field that does not apply fails the closed schema with a
generic message. Treat that field as unknown on the variation, and list
only the writable fields that apply to a variation, the same filter that
the schema uses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Ignore the describe_bad_fields() call that PHPStan checks in the ProductsQuery context
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Move the variation field refusals out of the shared product trait
ProductsQuery uses ProductAbilityTrait but does not extend
ActionableAbility, so it has no describe_bad_fields(). Only the
product create and update abilities need the refusals, so they now use
a trait of their own, and the PHPStan ignore is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Read string booleans in the product ability inputs as booleans
The ability schema accepts "false", "0" and 0 as booleans and passes them
through unchanged. The product abilities read the variation delete flag,
the delete force flag and the attribute flags with empty() or a (bool)
cast, so "delete": "false" deleted the variation and "force": "false"
deleted the product for good. Read them with wc_string_to_bool(), as the
product setters and the query abilities already do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Luis Felipe Zaguini <luis.felipe.zaguini@a8c.com>
diff --git a/plugins/woocommerce/changelog/wooai-1629-explain-unwritable-field b/plugins/woocommerce/changelog/wooai-1629-explain-unwritable-field
new file mode 100644
index 00000000000..1f144aa9c1f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1629-explain-unwritable-field
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Ability errors for extension fields now say what is wrong and what to do, for every bad field in one message: an unregistered namespace, an unknown field with the writable fields listed, a read-only field, or a bad value.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 10beaecfd72..062588099e5 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -376,6 +376,101 @@ abstract class ActionableAbility extends \WP_Ability {
}
}
+ /**
+ * Check the input schema. When it refuses the input, name every bad extension
+ * field in one message, because an MCP client gets only the first message of an
+ * error. A failure with no bad extension field keeps the error of the schema.
+ *
+ * @since 11.4.0
+ *
+ * @param mixed $input Ability input.
+ * @return true|\WP_Error
+ */
+ public function validate_input( $input = null ) {
+ $result = parent::validate_input( $input );
+ if ( ! is_wp_error( $result ) || ! is_array( $input ) ) {
+ return $result;
+ }
+ $refusals = $this->get_field_refusals( $input );
+ if ( empty( $refusals ) ) {
+ return $result;
+ }
+ return new \WP_Error( 'woocommerce_ability_invalid_fields', implode( ' ', $refusals ), array( 'status' => 400 ) );
+ }
+
+ /**
+ * Say what is wrong with each bad extension field of the input. An ability
+ * whose input has more `extensions`, such as one per item, adds those here.
+ *
+ * @since 11.4.0
+ *
+ * @param array $input Ability input that the schema refused.
+ * @return string[]
+ */
+ protected function get_field_refusals( array $input ): array {
+ if ( ! isset( $this->get_input_schema()['properties']['extensions'] ) ) {
+ return array();
+ }
+ return self::describe_bad_fields( $input['extensions'] ?? null, $this->get_resource() );
+ }
+
+ /**
+ * Say what is wrong with each unknown namespace, unknown field, read-only field
+ * and bad value. A value of the wrong shape is left to the input schema.
+ *
+ * @since 11.4.0
+ *
+ * @param mixed $values Values keyed by namespace, then field.
+ * @param string $resource_name Resource of the object that gets the values.
+ * @param callable|null $field_filter Optional. Gets the arguments of a field and returns whether the object can take it. A field it refuses counts as unknown.
+ * @return string[]
+ */
+ protected static function describe_bad_fields( $values, string $resource_name, ?callable $field_filter = null ): array {
+ $registered = ExtensibleAbilities::get_fields( $resource_name );
+ $refusals = array();
+ foreach ( is_array( $values ) ? $values : array() as $namespace => $fields ) {
+ if ( ! isset( $registered[ $namespace ] ) ) {
+ /* translators: %s: Extension namespace. */
+ $refusals[] = sprintf( __( 'No extension registered the namespace "%s" for this ability. Remove it from the input, or use a namespace from the input schema.', 'woocommerce' ), $namespace );
+ continue;
+ }
+ $candidates = null === $field_filter ? $registered[ $namespace ] : array_filter( $registered[ $namespace ], $field_filter );
+ foreach ( is_array( $fields ) ? $fields : array() as $field => $value ) {
+ $args = $candidates[ $field ] ?? null;
+ if ( null === $args ) {
+ $refusals[] = self::describe_unknown_field( $candidates, (string) $namespace, (string) $field );
+ } elseif ( ! isset( $args['set_callback'] ) ) {
+ /* translators: 1: Extension namespace. 2: Field name. */
+ $refusals[] = sprintf( __( 'The extension field "%1$s.%2$s" is read-only. Remove it from the input.', 'woocommerce' ), $namespace, $field );
+ } else {
+ $valid = rest_validate_value_from_schema( $value, $args['input_schema'], 'extensions.' . $namespace . '.' . $field );
+ if ( is_wp_error( $valid ) ) {
+ $refusals[] = $valid->get_error_message();
+ }
+ }
+ }
+ }
+ return $refusals;
+ }
+
+ /**
+ * Say that a namespace has no such field, and list the fields that the agent can write.
+ *
+ * @param array<string, array> $fields Fields of the namespace that the object can take.
+ * @param string $extension_namespace Extension namespace from the input.
+ * @param string $field Field name from the input.
+ * @return string
+ */
+ private static function describe_unknown_field( array $fields, string $extension_namespace, string $field ): string {
+ $writable = array_keys( array_filter( $fields, static fn( array $args ): bool => isset( $args['set_callback'] ) ) );
+ if ( empty( $writable ) ) {
+ /* translators: 1: Extension namespace. 2: Field name. */
+ return sprintf( __( 'The extension namespace "%1$s" has no field "%2$s", and it has no fields you can write. Remove "%1$s.%2$s" from the input.', 'woocommerce' ), $extension_namespace, $field );
+ }
+ /* translators: 1: Extension namespace. 2: Field name. 3: Comma-separated list of field names. */
+ return sprintf( __( 'The extension namespace "%1$s" has no field "%2$s". The fields you can write in this namespace are: %3$s. Use one of them, or remove "%1$s.%2$s" from the input.', 'woocommerce' ), $extension_namespace, $field, implode( ', ', $writable ) );
+ }
+
/**
* Use the change as the execute callback, and add the `extensions` input
* when the feature is on.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 3ef40f0791e..27d7ba1bc0c 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductFieldRefusalsTrait;
defined( 'ABSPATH' ) || exit;
@@ -22,6 +23,7 @@ class ProductCreate extends ActionableAbility {
use DomainAbilityHelpers;
use ProductAbilityTrait;
+ use ProductFieldRefusalsTrait;
/**
* Ability name.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
index 9b7480a8e4a..6dfdc75c358 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
@@ -132,7 +132,7 @@ class ProductDelete extends ActionableAbility {
*/
protected function load( array $input ) {
$this->product_id = 0;
- $this->force = ! empty( $input['force'] );
+ $this->force = wc_string_to_bool( $input['force'] ?? false );
$this->parent = null;
$this->deleting = array();
$this->deleted = array();
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index e86f412d6c2..09b66ae53d4 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\Abilities\Domain;
use Automattic\WooCommerce\Abilities\ActionableAbility;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductFieldRefusalsTrait;
defined( 'ABSPATH' ) || exit;
@@ -20,6 +21,7 @@ class ProductUpdate extends ActionableAbility {
use DomainAbilityHelpers;
use ProductAbilityTrait;
+ use ProductFieldRefusalsTrait;
/**
* Ability name.
@@ -246,7 +248,7 @@ class ProductUpdate extends ActionableAbility {
*/
public static function can_update_product( $input = array() ): bool {
$product_id = self::get_product_permission_id( self::get_id_from_input( $input ) );
- $deletes = is_array( $input ) && is_array( $input['variations'] ?? null ) && ! empty( array_filter( array_column( $input['variations'], 'delete' ) ) );
+ $deletes = is_array( $input ) && is_array( $input['variations'] ?? null ) && ! empty( array_filter( array_map( 'wc_string_to_bool', array_column( $input['variations'], 'delete' ) ) ) );
return $product_id > 0
&& wc_rest_check_post_permissions( 'product', 'edit', $product_id )
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index ea9e6f7cbec..dd0c21b6603 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -833,8 +833,9 @@ trait ProductAbilityTrait {
foreach ( array_values( $items ) as $index => $item ) {
$position = $index + 1;
$id = (int) ( $item['id'] ?? 0 );
+ $delete = wc_string_to_bool( $item['delete'] ?? false );
- if ( array_key_exists( 'delete', $item ) && ( $id <= 0 || count( $item ) > 2 ) ) {
+ if ( $delete && ( $id <= 0 || count( $item ) > 2 ) ) {
return new \WP_Error(
'woocommerce_product_variation_invalid',
/* translators: %d: variation position. */
@@ -862,7 +863,7 @@ trait ProductAbilityTrait {
);
}
$named[ $id ] = true;
- if ( ! empty( $item['delete'] ) ) {
+ if ( $delete ) {
$plan['delete'][] = $variation;
continue;
}
@@ -1073,7 +1074,7 @@ trait ProductAbilityTrait {
$options = array_map( 'sanitize_text_field', $item['options'] ?? array() );
$taxonomy = self::get_global_attribute_taxonomy( $item );
$name = '' === $taxonomy ? sanitize_text_field( trim( (string) ( $item['name'] ?? '' ) ) ) : $taxonomy;
- if ( '' === $taxonomy && ( ! empty( $item['id'] ) || ! empty( $item['global'] ) ) ) {
+ if ( '' === $taxonomy && ( ! empty( $item['id'] ) || wc_string_to_bool( $item['global'] ?? false ) ) ) {
return new \WP_Error(
'woocommerce_product_attributes_invalid',
sprintf(
@@ -1100,8 +1101,8 @@ trait ProductAbilityTrait {
$attribute->set_name( $name );
$attribute->set_options( $options );
$attribute->set_position( (int) $position );
- $attribute->set_visible( (bool) ( $item['visible'] ?? true ) );
- $attribute->set_variation( (bool) ( $item['variation'] ?? true ) );
+ $attribute->set_visible( $item['visible'] ?? true );
+ $attribute->set_variation( $item['variation'] ?? true );
$attributes[ sanitize_title( $name ) ] = $attribute;
}
@@ -1130,7 +1131,7 @@ trait ProductAbilityTrait {
}
$name = trim( $item['name'] ?? '' );
- if ( empty( $item['global'] ) || '' === $name ) {
+ if ( ! wc_string_to_bool( $item['global'] ?? false ) || '' === $name ) {
return '';
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductFieldRefusalsTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductFieldRefusalsTrait.php
new file mode 100644
index 00000000000..c0e02aa236e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductFieldRefusalsTrait.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Product field refusals trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Extension field refusals for the product abilities that write a product and
+ * its `variations` items. The using class extends `ActionableAbility` and uses
+ * `ProductAbilityTrait`.
+ */
+trait ProductFieldRefusalsTrait {
+
+ /**
+ * Say what is wrong with each bad extension field of the input and of its
+ * `variations` items.
+ *
+ * @param array $input Ability input that the schema refused.
+ * @return string[]
+ */
+ protected function get_field_refusals( array $input ): array {
+ return array_merge( parent::get_field_refusals( $input ), self::get_variation_field_refusals( $input ) );
+ }
+
+ /**
+ * Say what is wrong with each bad extension field of the `variations` items.
+ * The message starts with the item, as `variations[0]: `.
+ *
+ * @param array $input Ability input that the schema refused.
+ * @return string[]
+ */
+ private static function get_variation_field_refusals( array $input ): array {
+ if ( ! ExtensibleAbilities::is_enabled() || ! is_array( $input['variations'] ?? null ) ) {
+ return array();
+ }
+ $refusals = array();
+ foreach ( $input['variations'] as $index => $item ) {
+ foreach ( self::describe_bad_fields( $item['extensions'] ?? null, 'product', array( self::class, 'field_applies_to_variations' ) ) as $message ) {
+ $refusals[] = 'variations[' . $index . ']: ' . $message;
+ }
+ }
+ return $refusals;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index bdad93604e9..84e804719a0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -2522,6 +2522,103 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertSame( array( $created['product']['variation_ids'][0] ), wc_get_product( $created['product']['id'] )->get_children() );
}
+ /**
+ * Delete flag values that the boolean schema accepts and that mean false.
+ *
+ * @return array<string, array{0: bool|string|int}>
+ */
+ public function provider_false_delete_flags(): array {
+ return array(
+ 'boolean false' => array( false ),
+ 'string false' => array( 'false' ),
+ 'string zero' => array( '0' ),
+ 'integer zero' => array( 0 ),
+ );
+ }
+
+ /**
+ * @testdox Should change a variation through the parent when its item sends a false delete flag.
+ * @dataProvider provider_false_delete_flags
+ *
+ * @param bool|string|int $flag Delete flag value.
+ */
+ public function test_a_variation_item_with_a_false_delete_flag_is_a_change( $flag ): void {
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $variation_id = $created['product']['variation_ids'][0];
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $created['product']['id'],
+ 'variations' => array(
+ array(
+ 'id' => $variation_id,
+ 'delete' => $flag,
+ 'regular_price' => '9.00',
+ ),
+ ),
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertNotNull( get_post( $variation_id ) );
+ $this->assertSame( '9.00', wc_get_product( $variation_id )->get_regular_price() );
+
+ $kept = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $created['product']['id'],
+ 'variations' => array(
+ array(
+ 'id' => $variation_id,
+ 'delete' => $flag,
+ ),
+ ),
+ )
+ );
+
+ $this->assertNotWPError( $kept );
+ $this->assertNotNull( get_post( $variation_id ) );
+ }
+
+ /**
+ * @testdox Should move a product to the trash when force is the string false.
+ */
+ public function test_product_delete_with_force_string_false_trashes(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'force' => 'false',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'trash', get_post_status( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Should read string false on the attribute flags of a product as false.
+ */
+ public function test_product_create_reads_string_false_attribute_flags(): void {
+ $input = $this->get_shirt_input();
+ $input['attributes'][0]['visible'] = 'false';
+ $input['attributes'][0]['variation'] = 'false';
+ $input['attributes'][0]['global'] = 'false';
+ unset( $input['variations'] );
+
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $attribute = $created['product']['attributes'][0];
+ $this->assertFalse( $attribute['visible'] );
+ $this->assertFalse( $attribute['variation'] );
+ $this->assertSame( 0, $attribute['id'] );
+ }
+
/**
* @testdox Should fail a variation delete through the parent when pre_delete_post blocks it.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 9df42a17544..c6d6fe951ce 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -2158,7 +2158,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 400, $response->get_status(), wp_json_encode( $extensions ) );
if ( isset( $extensions['other_ext']['count'] ) && 'many' === $extensions['other_ext']['count'] ) {
- $this->assertStringContainsString( 'input[extensions][other_ext][count] is not of type integer,null.', $response->get_data()['message'] );
+ $this->assertSame( 'extensions.other_ext.count is not of type integer,null.', $response->get_data()['message'] );
}
}
$saved = wc_get_product( $product->get_id() );
@@ -2166,6 +2166,224 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertFalse( $saved->meta_exists( '_test_color' ) );
}
+ /**
+ * @testdox Should say what is wrong with one bad extension field, and save nothing.
+ * @testWith [{"nope":{"x":"red"}}, "No extension registered the namespace \"nope\" for this ability. Remove it from the input, or use a namespace from the input schema."]
+ * [{"nope":"red"}, "No extension registered the namespace \"nope\" for this ability. Remove it from the input, or use a namespace from the input schema."]
+ * [{"nope":{}}, "No extension registered the namespace \"nope\" for this ability. Remove it from the input, or use a namespace from the input schema."]
+ * [{"other_ext":{"cuont":3}}, "The extension namespace \"other_ext\" has no field \"cuont\". The fields you can write in this namespace are: count, size. Use one of them, or remove \"other_ext.cuont\" from the input."]
+ * [{"test-ext":{"code":"A2"}}, "The extension field \"test-ext.code\" is read-only. Remove it from the input."]
+ * [{"other_ext":{"size":"huge"}}, "extensions.other_ext.size is not one of small, large, and null."]
+ *
+ * @param array $extensions Value of the `extensions` input.
+ * @param string $message Expected error message.
+ */
+ public function test_a_bad_extension_field_is_refused_by_name( array $extensions, string $message ): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->validate_input(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Pencil',
+ 'extensions' => $extensions,
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( array( 'woocommerce_ability_invalid_fields' ), $result->get_error_codes() );
+ $this->assertSame( $message, $result->get_error_message() );
+ $this->assertSame( 400, $result->get_error_data()['status'] );
+ $this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+ }
+
+ /**
+ * @testdox Should say that a namespace has no writable fields when a field does not exist and all its fields are read-only.
+ */
+ public function test_an_unknown_field_in_a_read_only_namespace_is_refused(): void {
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'read_only_ext',
+ 'field' => 'code',
+ 'output_schema' => array( 'type' => 'string' ),
+ 'get_callback' => static fn() => 'A1',
+ )
+ );
+ }
+ );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->validate_input(
+ array(
+ 'id' => $product->get_id(),
+ 'extensions' => array( 'read_only_ext' => array( 'cdoe' => 'A2' ) ),
+ )
+ );
+
+ $this->assertSame( 'The extension namespace "read_only_ext" has no field "cdoe", and it has no fields you can write. Remove "read_only_ext.cdoe" from the input.', $result->get_error_message() );
+ }
+
+ /**
+ * @testdox Should name every bad extension field in the first message of one error, for a client that reads only that message.
+ */
+ public function test_every_bad_extension_field_is_in_the_first_message(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Pencil',
+ 'extensions' => array(
+ 'nope' => array( 'x' => 'y' ),
+ 'test-ext' => array( 'code' => 'A2' ),
+ 'other_ext' => array(
+ 'cuont' => 3,
+ 'count' => 'many',
+ ),
+ ),
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertCount( 1, $result->get_error_messages() );
+ $this->assertSame(
+ implode(
+ ' ',
+ array(
+ 'No extension registered the namespace "nope" for this ability. Remove it from the input, or use a namespace from the input schema.',
+ 'The extension field "test-ext.code" is read-only. Remove it from the input.',
+ 'The extension namespace "other_ext" has no field "cuont". The fields you can write in this namespace are: count, size. Use one of them, or remove "other_ext.cuont" from the input.',
+ 'extensions.other_ext.count is not of type integer,null.',
+ )
+ ),
+ $result->get_error_message()
+ );
+ $this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+ }
+
+ /**
+ * @testdox Should name the bad fields of the product and of its variations in one message on $ability, and run no validator.
+ * @testWith ["woocommerce/product-create"]
+ * ["woocommerce/product-update"]
+ *
+ * @param string $ability Ability name.
+ */
+ public function test_bad_fields_on_the_product_and_its_variations_are_refused_together( string $ability ): void {
+ $validated = 0;
+ $this->register_validator(
+ 'product',
+ 'test-ext',
+ static function () use ( &$validated ) {
+ ++$validated;
+ return null;
+ }
+ );
+ $input = $this->get_shirt_input();
+ if ( 'woocommerce/product-update' === $ability ) {
+ $input['id'] = \WC_Helper_Product::create_variation_product()->get_id();
+ }
+ $input['extensions'] = array( 'other_ext' => array( 'size' => 'huge' ) );
+ $input['variations'][0]['extensions'] = array( 'other_ext' => array( 'count' => 'many' ) );
+ $input['variations'][1]['extensions'] = array( 'nope' => array( 'x' => 'y' ) );
+
+ $result = wp_get_ability( $ability )->execute( $input );
+
+ $this->assertSame(
+ 'extensions.other_ext.size is not one of small, large, and null. variations[0]: extensions.other_ext.count is not of type integer,null. variations[1]: No extension registered the namespace "nope" for this ability. Remove it from the input, or use a namespace from the input schema.',
+ $result->get_error_message()
+ );
+ $this->assertSame( 0, $validated );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+ }
+
+ /**
+ * @testdox Should suggest only the fields that apply to a variation.
+ */
+ public function test_an_unknown_field_on_a_variation_suggests_only_variation_fields(): void {
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ $fields = array(
+ 'simple_only' => 'simple',
+ 'variation_only' => 'variation',
+ );
+ foreach ( $fields as $field => $object_type ) {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'typed-ext',
+ 'field' => $field,
+ 'output_schema' => array( 'type' => 'string' ),
+ 'input_schema' => array( 'type' => 'string' ),
+ 'object_types' => array( $object_type ),
+ 'get_callback' => static fn() => 'value',
+ 'set_callback' => static function () {},
+ )
+ );
+ }
+ }
+ );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+ $input = $this->get_shirt_input();
+
+ $input['variations'][0]['extensions'] = array( 'typed-ext' => array( 'typo' => 'x' ) );
+
+ $result = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+
+ $this->assertSame(
+ 'variations[0]: The extension namespace "typed-ext" has no field "typo". The fields you can write in this namespace are: variation_only. Use one of them, or remove "typed-ext.typo" from the input.',
+ $result->get_error_message()
+ );
+ }
+
+ /**
+ * @testdox Should keep the schema error when no extension field is at fault.
+ * @testWith [{"not_a_core_field":"x"}]
+ * [{"extensions":{"test-ext":"red"}}]
+ * [{"extensions":"red"}]
+ *
+ * @param array $bad_input Input that only the schema refuses.
+ */
+ public function test_the_schema_error_stands_without_a_bad_extension_field( array $bad_input ): void {
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->validate_input( array( 'id' => $product->get_id() ) + $bad_input );
+
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should keep the extensions input schema closed.
+ */
+ public function test_the_extensions_input_schema_stays_closed(): void {
+ $extensions = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions'];
+
+ $this->assertFalse( $extensions['additionalProperties'] );
+ $this->assertFalse( $extensions['properties']['test-ext']['additionalProperties'] );
+ }
+
+ /**
+ * @testdox Should write valid extension fields on the product and on its variations.
+ */
+ public function test_valid_extension_fields_on_the_product_and_its_variations_are_written(): void {
+ $input = $this->get_shirt_input();
+
+ $input['extensions'] = array( 'test-ext' => array( 'color' => 'red' ) );
+ $input['variations'][0]['extensions'] = array( 'other_ext' => array( 'count' => 3 ) );
+
+ $this->run_ability( 'woocommerce/product-create', $input );
+
+ $this->assertSame( 'red', self::$received['color'] ?? null );
+ $this->assertSame( 3, self::$received['count'] ?? null );
+ $this->assertGreaterThan( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+ }
+
/**
* @testdox Should refuse the change and save nothing when an update callback throws.
*/
@@ -3358,8 +3576,47 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertArrayNotHasKey( 'typed-ext', $schema['properties']['variations']['items']['properties']['extensions']['properties'] );
$this->assertFalse( $schema['properties']['variations']['items']['properties']['extensions']['additionalProperties'] );
$this->assertSame( 400, $refused->get_status() );
- $this->assertSame( 'ability_invalid_input', $refused->get_data()['code'] );
- $this->assertSame( 'Ability "woocommerce/product-create" has invalid input. Reason: typed-ext is not a valid property of Object.', $refused->get_data()['message'] );
+ $this->assertSame( 'woocommerce_ability_invalid_fields', $refused->get_data()['code'] );
+ $this->assertSame( 'variations[0]: The extension namespace "typed-ext" has no field "simple_only", and it has no fields you can write. Remove "typed-ext.simple_only" from the input.', $refused->get_data()['message'] );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+ }
+
+ /**
+ * @testdox Should name a variation field that does not apply to a variation and list the fields that do.
+ */
+ public function test_a_variation_field_that_does_not_apply_lists_the_variation_fields(): void {
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ $fields = array(
+ 'simple_only' => 'simple',
+ 'variation_only' => 'variation',
+ );
+ foreach ( $fields as $field => $object_type ) {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'typed-ext',
+ 'field' => $field,
+ 'output_schema' => array( 'type' => 'string' ),
+ 'input_schema' => array( 'type' => 'string' ),
+ 'object_types' => array( $object_type ),
+ 'get_callback' => static fn() => 'value',
+ 'set_callback' => static function () {},
+ )
+ );
+ }
+ }
+ );
+ $this->reset_registries();
+
+ $input = $this->get_shirt_input();
+ $input['variations'][1]['extensions'] = array( 'typed-ext' => array( 'simple_only' => 'nope' ) );
+ $refused = $this->run_ability_response( 'woocommerce/product-create', $input );
+
+ $this->assertSame( 400, $refused->get_status() );
+ $this->assertSame( 'woocommerce_ability_invalid_fields', $refused->get_data()['code'] );
+ $this->assertSame( 'variations[1]: The extension namespace "typed-ext" has no field "simple_only". The fields you can write in this namespace are: variation_only. Use one of them, or remove "typed-ext.simple_only" from the input.', $refused->get_data()['message'] );
$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
}