Commit a35808365d0 for woocommerce
commit a35808365d0d4a33944224cb4cdaef7530108b1e
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Wed Oct 21 14:54:39 2026 +0200
Subscriptions engine: report validation errors with codes, fields and translated messages (#70037)
Report subscriptions engine validation errors with codes and translations
- Throw coded InvalidInputException from Core with a dot-path field and params, and LogicException for internal invariants
- Translate errors on the Api side through ValidationException, with field-free messages in the woocommerce domain
- Report every invalid argument and entity value of a write at once, not only the first
- Return plan REST refusals as rest_invalid_param with params, details and an errors map keyed by field path
- Split argument checks into Core ArgumentValidator and unknown-key filtering into ArgumentFilter
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-validation-error-codes b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-validation-error-codes
new file mode 100644
index 00000000000..25cbb7bb0be
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-validation-error-codes
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Report validation errors in the unreleased subscriptions engine with stable codes, fields and translated messages; no changelog entry needed.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
index 024523dec4e..6888d189d07 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
@@ -10,7 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;
defined( 'ABSPATH' ) || exit;
@@ -53,7 +53,7 @@ final class ContractActions {
* `is_available`: `callable( ContractView $contract ): bool`, default always.
*/
public static function register( string $extension_slug, string $action, array $args ): void {
- $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::REGISTRATION_KEYS, 'argument' );
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::REGISTRATION_KEYS, 'argument' );
$callback = $filtered_args['callback'] ?? null;
$permission = $filtered_args['permission'] ?? null;
$description = $filtered_args['description'] ?? '';
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
index 9f72b0a4853..894b23c4b83 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -19,16 +19,17 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
-use DomainException;
use InvalidArgumentException;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;
defined( 'ABSPATH' ) || exit;
@@ -36,6 +37,8 @@ defined( 'ABSPATH' ) || exit;
* Public contract facade: reads and writes.
*
* Final and static-only: a stateless entry point, not an extension seam.
+ *
+ * @phpstan-type ContractFields array{customer_id?: int|null, currency?: string|null, selling_plan_id?: int|null, origin_order_id?: int|null, status?: string, payment_method?: string|null, payment_method_title?: string|null, payment_token_id?: int|null, start_gmt?: string|null, next_payment_gmt?: string|null, last_payment_gmt?: string|null, last_attempt_gmt?: string|null, trial_end_gmt?: string|null, end_gmt?: string|null, schedule_source?: string, billing_total?: string, discount_total?: string, shipping_total?: string, tax_total?: string, items?: array<int, array<string, mixed>>, addresses?: array<string, array<string, mixed>>}
*/
final class Contracts {
@@ -137,6 +140,7 @@ final class Contracts {
return ( new ContractRepository() )->acquire_lock( $contract_id, $operation );
}
+ // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
/**
* Create a contract locked from birth: finish building it (cycles, status) with the returned
* lock, then release it. A `creation_key` (1-100 characters, trimmed) makes the create
@@ -152,14 +156,32 @@ final class Contracts {
* @param array<string, mixed> $options `operation`: the lock's label (1-64 of `[a-z0-9_-]`, default `create`).
* @return array{contract: ContractView, lock: ContractLock}|null The contract as written (no re-read) and its
* lock; null when `creation_key` is taken.
- * @throws InvalidArgumentException If `extension_slug` is missing, or a value or the operation label is invalid.
+ * @throws ValidationException If `extension_slug` is missing, a value is invalid or the operation label is not a
+ * string, with every invalid value reported.
+ * @throws InvalidArgumentException If the operation label is not 1-64 of `[a-z0-9_-]`: the lock refuses it, alone.
*/
public static function create( array $args, array $options = array() ): ?array {
- $filtered_options = ArgumentValidator::filter_known_keys( __METHOD__, $options, self::CREATE_OPTION_KEYS, 'option' );
- $operation = ArgumentValidator::validate_string( 'operation', $filtered_options['operation'] ?? self::CREATE_OPERATION );
- $contract = self::build( $args );
+ $filtered_options = ArgumentFilter::filter_known_keys( __METHOD__, $options, self::CREATE_OPTION_KEYS, 'option' );
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::CREATE_KEYS );
+
+ $validator = new ArgumentValidator();
+ $operation = $validator->get_string( 'operation', $filtered_options['operation'] ?? self::CREATE_OPERATION );
+ $extension_slug = $validator->get_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+ $creation_key = $validator->get_nullable_string( 'creation_key', $filtered_args['creation_key'] ?? null );
+ $fields = self::get_contract_fields( $validator, $filtered_args );
+ try {
+ $validator->throw_errors();
+ $contract = Contract::create(
+ array(
+ 'extension_slug' => $extension_slug,
+ 'creation_key' => $creation_key,
+ ) + $fields
+ );
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+ }
- $lock = ( new ContractRepository() )->insert( $contract, $operation );
+ $lock = ( new ContractRepository() )->insert( $contract, (string) $operation );
if ( null === $lock ) {
return null;
}
@@ -169,20 +191,23 @@ final class Contracts {
'lock' => $lock,
);
}
+ // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
/**
* Write the given fields (the keys of {@see self::create()} except `extension_slug`) to an
* existing contract. Only the present keys are written; `items` and `addresses` replace the
* whole set; `null` clears a nullable field (a money field to 0). Unknown keys raise a
- * `_doing_it_wrong()` notice and are ignored.
+ * `_doing_it_wrong()` notice and are ignored. The arguments are checked before the contract is
+ * read, so invalid arguments throw even for a missing contract.
*
* @param int $contract_id Contract id.
* @param array<string, mixed> $args Fields to write.
* @param ContractLock|null $lock The caller's lock on this contract, or null for an internal one.
* @return ContractView|null The row as read before the write plus the written fields; null
* when the contract does not exist (also when it is deleted before the write).
- * @throws InvalidArgumentException If a value is invalid, or the lock is for another contract.
+ * @throws ValidationException If a value is invalid, with every invalid value reported.
+ * @throws InvalidArgumentException If the lock is for another contract.
* @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
*/
public static function update( int $contract_id, array $args, ?ContractLock $lock = null ): ?ContractView {
@@ -190,16 +215,25 @@ final class Contracts {
ContractRepository::assert_lock_for( $lock, $contract_id );
}
- $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CONTRACT_KEYS );
- $repository = new ContractRepository();
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::CONTRACT_KEYS );
- if ( array() === $filtered_args ) {
+ $validator = new ArgumentValidator();
+ $changes = self::get_contract_fields( $validator, $filtered_args );
+ try {
+ $validator->throw_errors();
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+ }
+
+ $repository = new ContractRepository();
+
+ if ( array() === $changes ) {
$contract = $repository->find( $contract_id );
return null === $contract ? null : ContractView::from_contract( $contract, true );
}
if ( null !== $lock ) {
- $contract = self::update_contract( $repository, $contract_id, $filtered_args, $lock );
+ $contract = self::update_contract( $repository, $contract_id, $changes, $lock );
return null === $contract ? null : ContractView::from_contract( $contract, true );
}
@@ -209,7 +243,7 @@ final class Contracts {
return null;
}
try {
- $contract = self::update_contract( $repository, $contract_id, $filtered_args, $internal_lock );
+ $contract = self::update_contract( $repository, $contract_id, $changes, $internal_lock );
} finally {
$repository->release_internal_lock( $internal_lock );
}
@@ -226,28 +260,29 @@ final class Contracts {
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
/**
- * Read the contract, apply and validate the fields, and write them behind {@see self::update()}.
+ * Read the contract, apply the checked fields, and write them behind {@see self::update()}.
*
- * @param ContractRepository $repository Repository.
- * @param int $contract_id Contract id.
- * @param array<string, mixed> $filtered_args Known fields to write.
- * @param ContractLock $lock The held lock.
+ * @param ContractRepository $repository Repository.
+ * @param int $contract_id Contract id.
+ * @param array<string, mixed> $changes Normalized fields from {@see self::get_contract_fields()}.
+ * @phpstan-param ContractFields $changes
+ * @param ContractLock $lock The held lock.
* @return Contract|null The contract with the written fields; null when it does not exist.
- * @throws InvalidArgumentException If a value is invalid.
+ * @throws ValidationException If a value breaks an entity invariant.
*/
- private static function update_contract( ContractRepository $repository, int $contract_id, array $filtered_args, ContractLock $lock ): ?Contract {
+ private static function update_contract( ContractRepository $repository, int $contract_id, array $changes, ContractLock $lock ): ?Contract {
$contract = $repository->find( $contract_id );
if ( null === $contract ) {
return null;
}
try {
- self::apply( $contract, $filtered_args );
- } catch ( DomainException $e ) {
- throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ self::apply( $contract, $changes );
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
}
- $updated = $repository->update_fields( $contract, array_keys( $filtered_args ), $lock );
+ $updated = $repository->update_fields( $contract, array_keys( $changes ), $lock );
if ( ! $updated ) {
return null;
}
@@ -274,30 +309,37 @@ final class Contracts {
* @param ContractLock $lock The caller's lock on this contract, from {@see self::lock()},
* {@see self::create()} or the due handler's context.
* @return CycleView|null The appended cycle, or null when the position or count is already taken.
- * @throws InvalidArgumentException If a required key is missing, a value is invalid, or the lock
- * is for another contract.
+ * @throws ValidationException If a required key is missing or a value is invalid, with every
+ * invalid value reported (ill-typed arguments before the cycle is built).
+ * @throws InvalidArgumentException If the lock is for another contract.
* @throws ContractLockedException If `$lock` was lost.
*/
public static function add_cycle( int $contract_id, array $args, ContractLock $lock ): ?CycleView {
- $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );
+ $validator = new ArgumentValidator();
$cycle_args = array(
'contract_id' => $contract_id,
'kind' => $filtered_args['kind'] ?? null,
- 'sequence_no' => ArgumentValidator::validate_nullable_id( 'sequence_no', $filtered_args['sequence_no'] ?? null ),
- 'count' => ArgumentValidator::validate_nullable_id( 'count', $filtered_args['count'] ?? null ),
+ 'sequence_no' => $validator->get_nullable_id( 'sequence_no', $filtered_args['sequence_no'] ?? null ),
+ 'count' => $validator->get_nullable_id( 'count', $filtered_args['count'] ?? null ),
'status' => $filtered_args['status'] ?? null,
- 'starts_at_gmt' => ArgumentValidator::validate_nullable_date( 'starts_at_gmt', $filtered_args['starts_at_gmt'] ?? null ),
- 'ends_at_gmt' => ArgumentValidator::validate_nullable_date( 'ends_at_gmt', $filtered_args['ends_at_gmt'] ?? null ),
- 'expected_total' => ArgumentValidator::validate_money( 'expected_total', $filtered_args['expected_total'] ?? null ),
- 'currency' => ArgumentValidator::validate_currency( $filtered_args['currency'] ?? null ),
- 'order_id' => ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
+ 'starts_at_gmt' => $validator->get_nullable_date( 'starts_at_gmt', $filtered_args['starts_at_gmt'] ?? null ),
+ 'ends_at_gmt' => $validator->get_nullable_date( 'ends_at_gmt', $filtered_args['ends_at_gmt'] ?? null ),
+ 'expected_total' => $validator->get_money( 'expected_total', $filtered_args['expected_total'] ?? null ),
+ 'currency' => $validator->get_currency( $filtered_args['currency'] ?? null ),
+ 'order_id' => $validator->get_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
);
+ try {
+ $validator->throw_errors();
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+ }
try {
$cycle = Cycle::create( $cycle_args );
- } catch ( DomainException $e ) {
- throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
}
try {
@@ -325,27 +367,31 @@ final class Contracts {
* {@see self::create()} or the due handler's context.
* @return bool True when this call made the transition, or for a same-status call when the
* cycle carries the status and the values.
- * @throws InvalidArgumentException If a status is missing or unregistered, a value is invalid,
- * the cycle does not exist, or it belongs to another contract than the lock.
+ * @throws ValidationException If a status is missing or unregistered (field `to_status`), or a value is
+ * invalid, with every invalid value reported.
+ * @throws InvalidArgumentException If the cycle does not exist, or it belongs to another contract than the lock.
* @throws ContractLockedException If `$lock` was lost.
*/
public static function transition_cycle( int $cycle_id, array $args, ContractLock $lock ): bool {
- $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::TRANSITION_KEYS );
- $from_status = ArgumentValidator::validate_non_empty_string( 'from_status', $filtered_args['from_status'] ?? null );
- $to_status = ArgumentValidator::validate_non_empty_string( 'to_status', $filtered_args['to_status'] ?? null );
- $changes = array();
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::TRANSITION_KEYS );
+ $validator = new ArgumentValidator();
+ $from_status = $validator->get_non_empty_string( 'from_status', $filtered_args['from_status'] ?? null );
+ $to_status = $validator->get_non_empty_string( 'to_status', $filtered_args['to_status'] ?? null );
+ $changes = array();
if ( array_key_exists( 'order_id', $filtered_args ) ) {
- $changes['order_id'] = ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] );
+ $changes['order_id'] = $validator->get_nullable_id( 'order_id', $filtered_args['order_id'] );
}
if ( array_key_exists( 'reason', $filtered_args ) ) {
- $changes['reason'] = ArgumentValidator::validate_nullable_string( 'reason', $filtered_args['reason'] );
+ $changes['reason'] = $validator->get_nullable_string( 'reason', $filtered_args['reason'] );
}
try {
- return ( new ContractRepository() )->transition_cycle( $cycle_id, $from_status, $to_status, $changes, $lock );
- } catch ( DomainException $e ) {
- throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the repository message is not output.
+ $validator->throw_errors();
+
+ return ( new ContractRepository() )->transition_cycle( $cycle_id, (string) $from_status, (string) $to_status, $changes, $lock );
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
}
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
@@ -559,136 +605,222 @@ final class Contracts {
/**
* Fetch a window of the contract's billing cycles, newest first. A negative `$limit` or
- * `$offset` throws an InvalidArgumentException.
+ * `$offset` throws a {@see ValidationException}.
*
* @param int $contract_id Contract id.
* @param int $limit Maximum cycles to return.
* @param int $offset Cycles to skip (for paging).
* @return array<int, CycleView> Cycles newest first.
+ * @throws ValidationException If `$limit` or `$offset` is negative, with both reported.
*/
public static function get_cycles( int $contract_id, int $limit = 20, int $offset = 0 ): array {
- $limit = ArgumentValidator::validate_non_negative_int( 'limit', $limit );
- $offset = ArgumentValidator::validate_non_negative_int( 'offset', $offset );
+ $validator = new ArgumentValidator();
+ $limit = (int) $validator->get_non_negative_int( 'limit', $limit );
+ $offset = (int) $validator->get_non_negative_int( 'offset', $offset );
+ try {
+ $validator->throw_errors();
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+ }
return self::cycle_views( ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit, $offset ) );
}
/**
- * Validate create arguments into a new, unsaved contract.
- *
- * @param array<string, mixed> $args Caller fields.
- * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid.
+ * Check the shape of the present contract fields with the collector and return them
+ * normalized, `items` and `addresses` without unknown keys (a refused field is left
+ * out or null; the collector holds its error).
+ *
+ * @param ArgumentValidator $validator Argument collector.
+ * @param array<string, mixed> $args Caller fields (known keys only; `extension_slug` and `creation_key` are ignored).
+ * @return array<string, mixed>
+ * @phpstan-return ContractFields
*/
- private static function build( array $args ): Contract {
- $filtered_args = ArgumentValidator::filter_known_keys( self::class . '::create', $args, self::CREATE_KEYS );
- $extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
- $creation_key = ArgumentValidator::validate_nullable_string( 'creation_key', $filtered_args['creation_key'] ?? null );
- unset( $filtered_args['extension_slug'], $filtered_args['creation_key'] );
-
- try {
- $contract = Contract::create(
- array(
- 'extension_slug' => $extension_slug,
- 'creation_key' => $creation_key,
- )
- );
- self::apply( $contract, $filtered_args );
- } catch ( DomainException $e ) {
- throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ private static function get_contract_fields( ArgumentValidator $validator, array $args ): array {
+ $fields = array();
+ if ( array_key_exists( 'customer_id', $args ) ) {
+ $fields['customer_id'] = $validator->get_nullable_id( 'customer_id', $args['customer_id'] );
+ }
+ if ( array_key_exists( 'currency', $args ) ) {
+ $fields['currency'] = $validator->get_currency( $args['currency'] );
+ }
+ if ( array_key_exists( 'selling_plan_id', $args ) ) {
+ $fields['selling_plan_id'] = $validator->get_nullable_id( 'selling_plan_id', $args['selling_plan_id'] );
+ }
+ if ( array_key_exists( 'origin_order_id', $args ) ) {
+ $fields['origin_order_id'] = $validator->get_nullable_id( 'origin_order_id', $args['origin_order_id'] );
+ }
+ if ( array_key_exists( 'status', $args ) ) {
+ $value = $validator->get_string( 'status', $args['status'] );
+ if ( null !== $value ) {
+ $fields['status'] = $value;
+ }
+ }
+ if ( array_key_exists( 'payment_method', $args ) ) {
+ $fields['payment_method'] = $validator->get_nullable_string( 'payment_method', $args['payment_method'] );
+ }
+ if ( array_key_exists( 'payment_method_title', $args ) ) {
+ $fields['payment_method_title'] = $validator->get_nullable_string( 'payment_method_title', $args['payment_method_title'] );
+ }
+ if ( array_key_exists( 'payment_token_id', $args ) ) {
+ $fields['payment_token_id'] = $validator->get_nullable_id( 'payment_token_id', $args['payment_token_id'] );
+ }
+ if ( array_key_exists( 'start_gmt', $args ) ) {
+ $fields['start_gmt'] = $validator->get_nullable_date( 'start_gmt', $args['start_gmt'] );
+ }
+ if ( array_key_exists( 'next_payment_gmt', $args ) ) {
+ $fields['next_payment_gmt'] = $validator->get_nullable_date( 'next_payment_gmt', $args['next_payment_gmt'] );
+ }
+ if ( array_key_exists( 'last_payment_gmt', $args ) ) {
+ $fields['last_payment_gmt'] = $validator->get_nullable_date( 'last_payment_gmt', $args['last_payment_gmt'] );
+ }
+ if ( array_key_exists( 'last_attempt_gmt', $args ) ) {
+ $fields['last_attempt_gmt'] = $validator->get_nullable_date( 'last_attempt_gmt', $args['last_attempt_gmt'] );
+ }
+ if ( array_key_exists( 'trial_end_gmt', $args ) ) {
+ $fields['trial_end_gmt'] = $validator->get_nullable_date( 'trial_end_gmt', $args['trial_end_gmt'] );
+ }
+ if ( array_key_exists( 'end_gmt', $args ) ) {
+ $fields['end_gmt'] = $validator->get_nullable_date( 'end_gmt', $args['end_gmt'] );
+ }
+ if ( array_key_exists( 'schedule_source', $args ) ) {
+ $value = $validator->get_string( 'schedule_source', $args['schedule_source'] );
+ if ( null !== $value ) {
+ $fields['schedule_source'] = $value;
+ }
+ }
+ if ( array_key_exists( 'billing_total', $args ) ) {
+ $value = $validator->get_money( 'billing_total', $args['billing_total'] );
+ if ( null !== $value ) {
+ $fields['billing_total'] = $value;
+ }
+ }
+ if ( array_key_exists( 'discount_total', $args ) ) {
+ $value = $validator->get_money( 'discount_total', $args['discount_total'] );
+ if ( null !== $value ) {
+ $fields['discount_total'] = $value;
+ }
+ }
+ if ( array_key_exists( 'shipping_total', $args ) ) {
+ $value = $validator->get_money( 'shipping_total', $args['shipping_total'] );
+ if ( null !== $value ) {
+ $fields['shipping_total'] = $value;
+ }
+ }
+ if ( array_key_exists( 'tax_total', $args ) ) {
+ $value = $validator->get_money( 'tax_total', $args['tax_total'] );
+ if ( null !== $value ) {
+ $fields['tax_total'] = $value;
+ }
+ }
+ if ( array_key_exists( 'items', $args ) ) {
+ $items = $validator->get_list_of_arrays( 'items', $args['items'] );
+ if ( null !== $items ) {
+ $fields['items'] = ArgumentFilter::filter_known_keys_in_rows( self::class, $items, array_fill_keys( Contract::ITEM_FIELDS, true ), 'item key' );
+ }
+ }
+ if ( array_key_exists( 'addresses', $args ) ) {
+ $addresses = $validator->get_contract_addresses( $args['addresses'] );
+ if ( null !== $addresses ) {
+ $fields['addresses'] = ArgumentFilter::filter_known_keys_in_rows( self::class, $addresses, array_fill_keys( Contract::ADDRESS_FIELDS, true ), 'address key' );
+ }
}
- return $contract;
+ return $fields;
}
- // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
/**
- * Validate the caller's field shapes and apply them to a contract through its setters,
- * which enforce the entity invariants. Nothing is written to storage; an invalid value
- * throws before any write.
+ * Apply normalized changes to a contract through its setters, which enforce the entity
+ * invariants, then check that money has a currency. Every refusal is thrown together.
+ * Nothing is written to storage.
*
* @param Contract $contract Contract to change.
- * @param array<string, mixed> $args Caller fields (known keys only, no `extension_slug`).
- * @throws InvalidArgumentException If a value has the wrong shape.
- * @throws DomainException If a value breaks an entity invariant (from the entity setters).
+ * @param array<string, mixed> $changes Normalized fields from {@see self::get_contract_fields()}.
+ * @phpstan-param ContractFields $changes
+ * @throws InvalidInputException If a value breaks an entity invariant.
*/
- private static function apply( Contract $contract, array $args ): void {
- $instrument = $contract->get_payment_instrument();
- $token_id = $instrument->get_token_id();
- $gateway = $instrument->get_gateway();
- $title = $instrument->get_title();
-
- foreach ( $args as $key => $value ) {
- switch ( $key ) {
- case 'customer_id':
- $contract->set_customer_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
- break;
- case 'selling_plan_id':
- $contract->set_selling_plan_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
- break;
- case 'origin_order_id':
- $contract->set_origin_order_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
- break;
- case 'currency':
- $contract->set_currency( ArgumentValidator::validate_currency( $value ) );
- break;
- case 'status':
- $contract->set_status( ArgumentValidator::validate_string( $key, $value ) );
- break;
- case 'schedule_source':
- $contract->set_schedule_source( ArgumentValidator::validate_string( $key, $value ) );
- break;
- case 'start_gmt':
- $contract->set_start_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
- break;
- case 'next_payment_gmt':
- $contract->set_next_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
- break;
- case 'last_payment_gmt':
- $contract->set_last_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
- break;
- case 'last_attempt_gmt':
- $contract->set_last_attempt_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
- break;
- case 'trial_end_gmt':
- $contract->set_trial_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
- break;
- case 'end_gmt':
- $contract->set_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
- break;
- case 'billing_total':
- $contract->set_billing_total( ArgumentValidator::validate_money( $key, $value ) );
- break;
- case 'discount_total':
- $contract->set_discount_total( ArgumentValidator::validate_money( $key, $value ) );
- break;
- case 'shipping_total':
- $contract->set_shipping_total( ArgumentValidator::validate_money( $key, $value ) );
- break;
- case 'tax_total':
- $contract->set_tax_total( ArgumentValidator::validate_money( $key, $value ) );
- break;
- case 'items':
- $contract->set_items( ArgumentValidator::validate_contract_items( $value, self::class ) );
- break;
- case 'addresses':
- $contract->set_addresses( ArgumentValidator::validate_contract_addresses( $value, self::class ) );
- break;
- case 'payment_token_id':
- $token_id = ArgumentValidator::validate_nullable_id( $key, $value );
- break;
- case 'payment_method':
- $gateway = ArgumentValidator::validate_nullable_string( $key, $value );
- break;
- case 'payment_method_title':
- $title = ArgumentValidator::validate_nullable_string( $key, $value );
- break;
+ private static function apply( Contract $contract, array $changes ): void {
+ $errors = array();
+ if ( array_key_exists( 'customer_id', $changes ) ) {
+ $contract->set_customer_id( $changes['customer_id'] );
+ }
+ if ( array_key_exists( 'currency', $changes ) ) {
+ $contract->set_currency( $changes['currency'] );
+ }
+ if ( array_key_exists( 'selling_plan_id', $changes ) ) {
+ $contract->set_selling_plan_id( $changes['selling_plan_id'] );
+ }
+ if ( array_key_exists( 'origin_order_id', $changes ) ) {
+ $contract->set_origin_order_id( $changes['origin_order_id'] );
+ }
+ if ( array_key_exists( 'status', $changes ) ) {
+ try {
+ $contract->set_status( $changes['status'] );
+ } catch ( InvalidInputException $e ) {
+ $errors[] = $e;
}
}
+ if ( array_key_exists( 'schedule_source', $changes ) ) {
+ try {
+ $contract->set_schedule_source( $changes['schedule_source'] );
+ } catch ( InvalidInputException $e ) {
+ $errors[] = $e;
+ }
+ }
+ if ( array_key_exists( 'start_gmt', $changes ) ) {
+ $contract->set_start_gmt( $changes['start_gmt'] );
+ }
+ if ( array_key_exists( 'next_payment_gmt', $changes ) ) {
+ $contract->set_next_payment_gmt( $changes['next_payment_gmt'] );
+ }
+ if ( array_key_exists( 'last_payment_gmt', $changes ) ) {
+ $contract->set_last_payment_gmt( $changes['last_payment_gmt'] );
+ }
+ if ( array_key_exists( 'last_attempt_gmt', $changes ) ) {
+ $contract->set_last_attempt_gmt( $changes['last_attempt_gmt'] );
+ }
+ if ( array_key_exists( 'trial_end_gmt', $changes ) ) {
+ $contract->set_trial_end_gmt( $changes['trial_end_gmt'] );
+ }
+ if ( array_key_exists( 'end_gmt', $changes ) ) {
+ $contract->set_end_gmt( $changes['end_gmt'] );
+ }
+ if ( array_key_exists( 'billing_total', $changes ) ) {
+ $contract->set_billing_total( $changes['billing_total'] );
+ }
+ if ( array_key_exists( 'discount_total', $changes ) ) {
+ $contract->set_discount_total( $changes['discount_total'] );
+ }
+ if ( array_key_exists( 'shipping_total', $changes ) ) {
+ $contract->set_shipping_total( $changes['shipping_total'] );
+ }
+ if ( array_key_exists( 'tax_total', $changes ) ) {
+ $contract->set_tax_total( $changes['tax_total'] );
+ }
+ if ( array_key_exists( 'items', $changes ) ) {
+ $contract->set_items( $changes['items'] );
+ }
+ if ( array_key_exists( 'addresses', $changes ) ) {
+ $contract->set_addresses( $changes['addresses'] );
+ }
+
+ $instrument = $contract->get_payment_instrument();
+ $contract->set_payment_instrument(
+ new PaymentInstrumentRef(
+ array_key_exists( 'payment_token_id', $changes ) ? $changes['payment_token_id'] : $instrument->get_token_id(),
+ array_key_exists( 'payment_method', $changes ) ? $changes['payment_method'] : $instrument->get_gateway(),
+ array_key_exists( 'payment_method_title', $changes ) ? $changes['payment_method_title'] : $instrument->get_title()
+ )
+ );
- $contract->set_payment_instrument( new PaymentInstrumentRef( $token_id, $gateway, $title ) );
- $contract->assert_money_has_currency();
+ $money_error = $contract->get_money_currency_error();
+ if ( null !== $money_error ) {
+ $errors[] = $money_error;
+ }
+ if ( array() !== $errors ) {
+ throw InvalidInputException::from_errors( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The caller converts it with ValidationException::from_core(), which escapes the message.
+ }
}
- // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* A view of `$contract`, or null.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php b/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
deleted file mode 100644
index 78db1c0d60f..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-/**
- * PlanValidationException - a plan write refused by the owning extension's plan validation.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Api
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
-
-use InvalidArgumentException;
-use WP_Error;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Thrown by {@see Plans} when a `woocommerce_subscriptions_engine_validate_plan`
- * callback adds errors. Carries the collected errors with their codes and data;
- * the message joins the error messages.
- */
-final class PlanValidationException extends InvalidArgumentException {
-
- /**
- * Collected validation errors.
- *
- * @var WP_Error
- */
- private $errors;
-
- /**
- * Wrap the collected validation errors.
- *
- * @param WP_Error $errors Validation errors.
- */
- public function __construct( WP_Error $errors ) {
- parent::__construct( esc_html( implode( ' ', $errors->get_error_messages() ) ) );
-
- $this->errors = $errors;
- }
-
- /**
- * The validation errors, with their codes and data.
- */
- public function get_errors(): WP_Error {
- return $this->errors;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
index 66a6d07de19..6f6f4e992a2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -20,15 +20,17 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
-use DomainException;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
use WP_Error;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;
defined( 'ABSPATH' ) || exit;
@@ -36,6 +38,8 @@ defined( 'ABSPATH' ) || exit;
* Public plan facade: reads and writes.
*
* Final and static-only: a stateless entry point, not an extension seam.
+ *
+ * @phpstan-type PlanFields array{name?: string, status?: string, billing_policy?: array<int|string, mixed>|null, pricing_policy?: array<int|string, mixed>|null, delivery_policy?: array<int|string, mixed>|null}
*/
final class Plans {
@@ -78,11 +82,14 @@ final class Plans {
*/
private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
- // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- create() and update() also throw RuntimeException indirectly, through validate_with_extension() and the repository.
+ // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- create() and update() also throw ValidationException and RuntimeException indirectly, through throw_refusals() and the repository.
/**
* Create a plan.
*
- * Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
+ * Unknown keys raise a `_doing_it_wrong()` notice and are ignored. Every invalid value is
+ * reported at once: ill-typed arguments refuse the call before the plan is built; when
+ * the arguments are well-typed, the plan's own refusals and the owning extension's
+ * (`woocommerce_subscriptions_engine_validate_plan`) arrive together.
*
* @param array<string, mixed> $args Plan fields: `extension_slug` (required, the owning
* extension), `name` (required, non-empty), `status` (a
@@ -90,30 +97,34 @@ final class Plans {
* `billing_policy`, `pricing_policy`, `delivery_policy`
* (string-keyed arrays the owning extension interprets, or null).
* @return PlanView The new plan, built from the written fields (no re-read).
- * @throws InvalidArgumentException If a required key is missing or a value is invalid, or a
- * {@see PlanValidationException} when the owning extension refuses the plan.
+ * @throws ValidationException If a required key is missing or a value is invalid (each error
+ * with the engine code, `field` and `params`), or the owning extension
+ * refuses the plan; entity and extension refusals arrive in one exception.
* @throws RuntimeException If a validation callback throws (the callback's throwable is chained
* as the previous exception) or the insert fails (no previous exception).
*/
public static function create( array $args ): PlanView {
- $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
- $extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
- unset( $filtered_args['extension_slug'] );
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
+ $validator = new ArgumentValidator();
+ $extension_slug = $validator->get_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+ $fields = self::get_plan_fields( $validator, $filtered_args + array( 'name' => '' ) );
try {
- // The entity requires a name on create; apply() then sets every field, the name included.
- $plan = Plan::create(
- array(
- 'extension_slug' => $extension_slug,
- 'name' => ArgumentValidator::validate_string( 'name', $filtered_args['name'] ?? '' ),
- )
- );
- self::apply( $plan, $filtered_args );
- } catch ( DomainException $e ) {
- throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ $validator->throw_errors();
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
}
- self::validate_with_extension( $plan );
+ $fields['extension_slug'] = $extension_slug;
+ $refusal = null;
+ try {
+ $plan = Plan::create( $fields );
+ } catch ( InvalidInputException $e ) {
+ $refusal = $e;
+ $plan = Plan::draft( $fields );
+ }
+
+ self::throw_refusals( $plan, $refusal );
( new PlanRepository() )->insert( $plan );
@@ -130,10 +141,11 @@ final class Plans {
* present plan fields are written, so fields a concurrent writer changed in between
* keep its values. A present policy key replaces the whole payload (null clears it);
* policies are never merged. Re-sending the stored status is accepted even when that
- * status is no longer registered (its extension was deactivated). Args with no plan
- * field return the stored plan without validating or writing. Unknown keys raise a
- * `_doing_it_wrong()` notice and are ignored. The engine opens no transaction: wrap
- * the call in one when it must be atomic with other writes.
+ * status is no longer registered (its extension was deactivated). The arguments are
+ * checked before the plan is read, so ill-typed arguments throw even for a missing plan.
+ * Args with no plan field return the stored plan without validating or writing. Unknown
+ * keys raise a `_doing_it_wrong()` notice and are ignored. The engine opens no
+ * transaction: wrap the call in one when it must be atomic with other writes.
*
* @param int $plan_id Plan id.
* @param array<string, mixed> $args `extension_slug` (required, the owning extension)
@@ -142,36 +154,46 @@ final class Plans {
* another writer changed meanwhile may be stale here, not in storage);
* null when no plan of that extension has the id (also when it is
* deleted before the write).
- * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid, or a
- * {@see PlanValidationException} when the owning extension refuses the plan.
+ * @throws ValidationException If `extension_slug` is missing or a value is invalid (each error
+ * with the engine code, `field` and `params`), or the owning extension
+ * refuses the plan; entity and extension refusals arrive in one exception.
* @throws RuntimeException If a validation callback throws (the callback's throwable is chained
* as the previous exception) or the update fails (no previous exception).
*/
public static function update( int $plan_id, array $args ): ?PlanView {
- $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
- $extension_slug = ArgumentValidator::validate_non_empty_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
- unset( $filtered_args['extension_slug'] );
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
+
+ $validator = new ArgumentValidator();
+ $extension_slug = $validator->get_non_empty_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+ $changes = self::get_plan_fields( $validator, $filtered_args );
+ try {
+ $validator->throw_errors();
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+ }
$repository = new PlanRepository();
- $plan = $repository->find( $plan_id, $extension_slug );
+ $plan = $repository->find( $plan_id, (string) $extension_slug );
if ( null === $plan ) {
return null;
}
- $fields = array_keys( $filtered_args );
- if ( array() === $fields ) {
+ if ( array() === $changes ) {
return PlanView::from_plan( $plan );
}
+ $refusal = null;
try {
- self::apply( $plan, $filtered_args );
- } catch ( DomainException $e ) {
- throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ self::apply( $plan, $changes );
+ } catch ( InvalidInputException $e ) {
+ $refusal = $e;
+ $plan = self::get_would_be_plan( $plan, $changes );
}
- self::validate_with_extension( $plan );
+ self::throw_refusals( $plan, $refusal );
- if ( ! $repository->update_fields( $plan, $fields ) ) {
+ $updated = $repository->update_fields( $plan, array_keys( $changes ) );
+ if ( ! $updated ) {
return null;
}
@@ -272,30 +294,43 @@ final class Plans {
* @type int $offset Plans to skip (for paging), a non-negative integer. Default 0.
* }
* @return array<int, PlanView>
- * @throws InvalidArgumentException If a value is invalid: an empty or non-string slug or status,
- * `any` as a slug, a non-positive or non-integer id, limit or offset.
+ * @throws ValidationException If a value is invalid, with every invalid value reported: an empty
+ * or non-string slug or status, `any` as a slug, a non-positive or
+ * non-integer id or limit, a negative or non-integer offset.
*/
public static function list( array $args = array() ): array {
- $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::LIST_KEYS );
+ $filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::LIST_KEYS );
- $query = array(
+ $validator = new ArgumentValidator();
+ $query = array(
'orderby' => 'id',
'order' => 'asc',
- 'limit' => ArgumentValidator::validate_nullable_id( 'limit', $filtered_args['limit'] ?? null ) ?? self::DEFAULT_LIST_LIMIT,
- 'offset' => ArgumentValidator::validate_non_negative_int( 'offset', $filtered_args['offset'] ?? 0 ),
+ 'limit' => $validator->get_nullable_id( 'limit', $filtered_args['limit'] ?? null ) ?? self::DEFAULT_LIST_LIMIT,
+ 'offset' => $validator->get_non_negative_int( 'offset', $filtered_args['offset'] ?? 0 ),
);
if ( array_key_exists( 'extension_slug', $filtered_args ) ) {
- $extension_slugs = array_values( array_unique( ArgumentValidator::validate_string_list( 'extension_slug', $filtered_args['extension_slug'] ) ) );
+ $extension_slugs = array_values( array_unique( (array) $validator->get_string_list( 'extension_slug', $filtered_args['extension_slug'] ) ) );
if ( in_array( 'any', $extension_slugs, true ) ) {
- throw new InvalidArgumentException( '"extension_slug" must not be "any": leave it out to list every extension\'s plans.' );
+ $validator->add(
+ new InvalidInputException(
+ ValidationErrorCode::INVALID_VALUE,
+ 'extension_slug',
+ '"extension_slug" must not be "any": leave it out to list every extension\'s plans.'
+ )
+ );
}
$query['extension_slugs'] = $extension_slugs;
}
if ( array_key_exists( 'status', $filtered_args ) ) {
- $query['status'] = ArgumentValidator::validate_string_list( 'status', $filtered_args['status'] );
+ $query['status'] = $validator->get_string_list( 'status', $filtered_args['status'] );
}
if ( array_key_exists( 'ids', $filtered_args ) ) {
- $query['ids'] = ArgumentValidator::validate_id_list( 'ids', $filtered_args['ids'] );
+ $query['ids'] = $validator->get_id_list( 'ids', $filtered_args['ids'] );
+ }
+ try {
+ $validator->throw_errors();
+ } catch ( InvalidInputException $e ) {
+ throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
}
return array_map(
@@ -306,49 +341,121 @@ final class Plans {
);
}
- // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
/**
- * Validate the caller's field shapes and apply them to a plan through its setters,
- * which enforce the entity invariants. Nothing is written to storage; an invalid value
- * throws before any write.
+ * Check the shape of the present plan fields with the collector and return them
+ * normalized (a refused field is left out, or null for a policy; the collector holds
+ * its error).
*
- * @param Plan $plan Plan to change.
- * @param array<string, mixed> $args Caller fields (known keys only, no `extension_slug`).
- * @throws InvalidArgumentException If a value has the wrong shape.
- * @throws DomainException If a value breaks an entity invariant (from the entity setters).
+ * @param ArgumentValidator $validator Argument collector.
+ * @param array<string, mixed> $args Caller fields (known keys only).
+ * @return array<string, mixed>
+ * @phpstan-return PlanFields
*/
- private static function apply( Plan $plan, array $args ): void {
- foreach ( $args as $key => $value ) {
- switch ( $key ) {
- case 'name':
- $plan->set_name( trim( ArgumentValidator::validate_string( $key, $value ) ) );
- break;
- case 'status':
- $plan->set_status( ArgumentValidator::validate_string( $key, $value ) );
- break;
- case 'billing_policy':
- $plan->set_billing_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
- break;
- case 'pricing_policy':
- $plan->set_pricing_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
- break;
- case 'delivery_policy':
- $plan->set_delivery_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
- break;
+ private static function get_plan_fields( ArgumentValidator $validator, array $args ): array {
+ $fields = array();
+ if ( array_key_exists( 'name', $args ) ) {
+ $name = $validator->get_string( 'name', $args['name'] );
+ if ( null !== $name ) {
+ $fields['name'] = trim( $name );
+ }
+ }
+ if ( array_key_exists( 'status', $args ) ) {
+ $status = $validator->get_string( 'status', $args['status'] );
+ if ( null !== $status ) {
+ $fields['status'] = $status;
+ }
+ }
+ foreach ( array( 'billing_policy', 'pricing_policy', 'delivery_policy' ) as $key ) {
+ if ( array_key_exists( $key, $args ) ) {
+ $fields[ $key ] = $validator->get_nullable_array( $key, $args[ $key ] );
}
}
+
+ return $fields;
}
- // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
- * Let the plan's extension validate the would-be plan before it is written.
+ * Apply normalized changes to a plan through its setters, which enforce the entity
+ * invariants. Every setter runs; their refusals are thrown together. Nothing is written
+ * to storage.
+ *
+ * @param Plan $plan Plan to change.
+ * @param array<string, mixed> $changes Normalized fields from {@see self::get_plan_fields()}.
+ * @phpstan-param PlanFields $changes
+ * @throws InvalidInputException If a value breaks an entity invariant.
+ */
+ private static function apply( Plan $plan, array $changes ): void {
+ $errors = array();
+ if ( array_key_exists( 'name', $changes ) ) {
+ try {
+ $plan->set_name( $changes['name'] );
+ } catch ( InvalidInputException $e ) {
+ $errors[] = $e;
+ }
+ }
+ if ( array_key_exists( 'status', $changes ) ) {
+ try {
+ $plan->set_status( $changes['status'] );
+ } catch ( InvalidInputException $e ) {
+ $errors[] = $e;
+ }
+ }
+ if ( array_key_exists( 'billing_policy', $changes ) ) {
+ try {
+ $plan->set_billing_policy( $changes['billing_policy'] );
+ } catch ( InvalidInputException $e ) {
+ $errors[] = $e;
+ }
+ }
+ if ( array_key_exists( 'pricing_policy', $changes ) ) {
+ try {
+ $plan->set_pricing_policy( $changes['pricing_policy'] );
+ } catch ( InvalidInputException $e ) {
+ $errors[] = $e;
+ }
+ }
+ if ( array_key_exists( 'delivery_policy', $changes ) ) {
+ try {
+ $plan->set_delivery_policy( $changes['delivery_policy'] );
+ } catch ( InvalidInputException $e ) {
+ $errors[] = $e;
+ }
+ }
+
+ if ( array() !== $errors ) {
+ throw InvalidInputException::from_errors( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The caller converts it with ValidationException::from_core(), which escapes the message.
+ }
+ }
+
+ /**
+ * The would-be plan of a refused update: the stored plan with the attempted changes,
+ * refused values included, for the validation callbacks. Never stored.
*
- * @param Plan $plan The would-be plan (unsaved on create).
- * @throws PlanValidationException If a callback added errors.
+ * @param Plan $plan Stored plan.
+ * @param array<string, mixed> $changes Normalized fields from {@see self::get_plan_fields()}.
+ */
+ private static function get_would_be_plan( Plan $plan, array $changes ): Plan {
+ $stored = array(
+ 'id' => $plan->get_id(),
+ 'date_created_gmt' => $plan->get_date_created_gmt(),
+ 'date_updated_gmt' => $plan->get_date_updated_gmt(),
+ ) + $plan->to_storage();
+
+ return Plan::draft( array_merge( $stored, $changes ) );
+ }
+
+ /**
+ * Ask the plan's extension to validate the would-be plan before it is written, also when the
+ * plan itself was refused, then throw both refusals together: the plan's errors first.
+ * Returns only when neither refused.
+ *
+ * @param Plan $plan The would-be plan (unsaved on create; unchecked when refused).
+ * @param InvalidInputException|null $refusal The plan's own refusal, or null.
+ * @throws ValidationException If the plan was refused or a callback added errors.
* @throws RuntimeException If a callback threw, with the callback's throwable as the previous
* exception. The REST controller relies on that to tell it from a failed write.
*/
- private static function validate_with_extension( Plan $plan ): void {
+ private static function throw_refusals( Plan $plan, ?InvalidInputException $refusal ): void {
$errors = new WP_Error();
$extension_slug = (string) $plan->get_extension_slug();
@@ -358,8 +465,16 @@ final class Plans {
* including status-only updates, so the plan's extension can refuse it.
*
* Add errors to $errors to refuse the write; act only on your own $extension_slug.
+ * Give an error `field` data (dot path, such as `billing_policy.interval`, with
+ * optional `params`) and REST reports it under that top-level arg in `rest_invalid_param`,
+ * with any `status` of its own folded into the 400. An error without `field` is
+ * reported as a general 400; one with the code `rest_invalid_param` is reported
+ * without its data.
* The view is the would-be state after the write (its id is 0 on create) and is
- * read-only.
+ * read-only. It also fires when the engine refuses the plan itself (an empty name,
+ * an unregistered status): validate your own payloads and do not rely on `name`,
+ * `status` or `extension_slug` being valid. The engine's errors and yours are
+ * reported together. A callback that throws fails the write as a server error.
*
* Changed with plans as records: `$plan` is a read-only {@see PlanView}. Earlier
* engine versions passed the Core `Plan` entity; a callback still typed on `Plan`
@@ -384,8 +499,19 @@ final class Plans {
throw new RuntimeException( 'Plans: the plan could not be validated.', 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Literal message.
}
- if ( $errors->has_errors() ) {
- throw new PlanValidationException( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The exception escapes its message.
+ if ( null === $refusal ) {
+ if ( $errors->has_errors() ) {
+ throw new ValidationException( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The exception escapes its message.
+ }
+ return;
}
+
+ $engine_errors = ValidationException::from_core( $refusal );
+ $all_errors = $engine_errors->get_errors();
+ $all_errors->merge_from( $errors );
+ $messages = array_merge( array( $engine_errors->getMessage() ), array_map( 'esc_html', $errors->get_error_messages() ) );
+
+ // The refusal is chained: it tells the REST controller which errors are the engine's.
+ throw new ValidationException( $all_errors, implode( ' ', $messages ), $refusal ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Both parts are escaped.
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
index 54994231399..f7e819c11bc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -12,14 +12,15 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
-use InvalidArgumentException;
use RuntimeException;
use WP_Error;
use WP_REST_Controller;
@@ -49,6 +50,17 @@ final class PlansController extends WP_REST_Controller {
*/
private const WRITE_FIELDS = array( 'name', 'status', 'billing_policy', 'pricing_policy', 'delivery_policy' );
+ /**
+ * Arg options of a writable schema field: WordPress neither checks nor casts it, so
+ * the facade (or the `extension_slug` check) reports it with every other refusal.
+ *
+ * @var array<string, null>
+ */
+ private const UNCHECKED_ARG_OPTIONS = array(
+ 'validate_callback' => null,
+ 'sanitize_callback' => null,
+ );
+
/**
* Logger source.
*/
@@ -138,7 +150,8 @@ final class PlansController extends WP_REST_Controller {
'description' => __( 'Status of the plans to query (any registered plan status).', 'woocommerce-subscriptions-engine' ),
'type' => 'string',
'required' => false,
- 'validate_callback' => array( $this, 'validate_status_param' ),
+ // get_items() checks it, so its refusal carries the `errors` map.
+ 'sanitize_callback' => null,
),
'orderby' => array(
'description' => __( 'Order by field for the plan query.', 'woocommerce-subscriptions-engine' ),
@@ -201,24 +214,6 @@ final class PlansController extends WP_REST_Controller {
return $this->rest_permissions->require_admin_permission();
}
- /**
- * Validate a status param: any registered plan status.
- *
- * @param mixed $value Param value.
- * @return true|WP_Error
- */
- public function validate_status_param( $value ) {
- if ( is_string( $value ) && PlanStatus::is_registered( $value ) ) {
- return true;
- }
-
- return new WP_Error(
- 'rest_invalid_param',
- __( 'status must be a registered plan status.', 'woocommerce-subscriptions-engine' ),
- array( 'status' => 400 )
- );
- }
-
/**
* Get a paginated plan list.
*
@@ -231,6 +226,11 @@ final class PlansController extends WP_REST_Controller {
return $extension_slugs;
}
+ $status_error = $this->get_status_filter_error( $request->get_param( 'status' ) );
+ if ( null !== $status_error ) {
+ return $status_error;
+ }
+
$page = max( 1, Coercion::coerce_int( $request->get_param( 'page' ), 1 ) );
$per_page = $this->get_per_page( $request );
$args = array(
@@ -304,10 +304,8 @@ final class PlansController extends WP_REST_Controller {
try {
$plan = Plans::create( $args );
- } catch ( PlanValidationException $e ) {
- return $this->as_bad_request( $e->get_errors() );
- } catch ( InvalidArgumentException $e ) {
- return $this->invalid_error( $e->getMessage() );
+ } catch ( ValidationException $e ) {
+ return $this->get_validation_error( $e );
} catch ( RuntimeException $e ) {
return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_create_failed' );
}
@@ -337,10 +335,8 @@ final class PlansController extends WP_REST_Controller {
try {
// A plan of another extension reads as missing: the facade scopes the update to the request's extension slug.
$plan = Plans::update( Coercion::coerce_int( $request->get_param( 'id' ) ), $args );
- } catch ( PlanValidationException $e ) {
- return $this->as_bad_request( $e->get_errors() );
- } catch ( InvalidArgumentException $e ) {
- return $this->invalid_error( $e->getMessage() );
+ } catch ( ValidationException $e ) {
+ return $this->get_validation_error( $e );
} catch ( RuntimeException $e ) {
return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_update_failed' );
}
@@ -412,7 +408,7 @@ final class PlansController extends WP_REST_Controller {
'status' => array(
'description' => __( 'Limit result set to plans with a registered plan status.', 'woocommerce-subscriptions-engine' ),
'type' => 'string',
- 'validate_callback' => array( $this, 'validate_status_param' ),
+ 'sanitize_callback' => null,
),
'orderby' => array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce-subscriptions-engine' ),
@@ -457,34 +453,37 @@ final class PlansController extends WP_REST_Controller {
'description' => __( 'Owning extension slug.', 'woocommerce-subscriptions-engine' ),
'type' => array( 'string', 'null' ),
'context' => array( 'view', 'edit' ),
+ 'arg_options' => self::UNCHECKED_ARG_OPTIONS,
),
'status' => array(
'description' => __( 'Plan status (any registered plan status).', 'woocommerce-subscriptions-engine' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
- 'arg_options' => array(
- 'validate_callback' => array( $this, 'validate_status_param' ),
- ),
+ 'arg_options' => self::UNCHECKED_ARG_OPTIONS,
),
'name' => array(
'description' => __( 'Display name.', 'woocommerce-subscriptions-engine' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
+ 'arg_options' => self::UNCHECKED_ARG_OPTIONS,
),
'billing_policy' => array(
'description' => __( 'Billing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
'type' => array( 'object', 'null' ),
'context' => array( 'view', 'edit' ),
+ 'arg_options' => self::UNCHECKED_ARG_OPTIONS,
),
'pricing_policy' => array(
'description' => __( 'Pricing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
'type' => array( 'object', 'null' ),
'context' => array( 'view', 'edit' ),
+ 'arg_options' => self::UNCHECKED_ARG_OPTIONS,
),
'delivery_policy' => array(
'description' => __( 'Delivery payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
'type' => array( 'object', 'null' ),
'context' => array( 'view', 'edit' ),
+ 'arg_options' => self::UNCHECKED_ARG_OPTIONS,
),
'date_created_gmt' => array(
'description' => __( 'Creation time (GMT).', 'woocommerce-subscriptions-engine' ),
@@ -520,7 +519,7 @@ final class PlansController extends WP_REST_Controller {
/**
* Collect the present writable params as facade args, passed through as given
- * (the name is sanitized); the facade validates them.
+ * (a string name is sanitized); the facade validates them.
*
* @param WP_REST_Request $request Request.
* @return array<string, mixed>
@@ -529,7 +528,8 @@ final class PlansController extends WP_REST_Controller {
$args = array();
foreach ( self::WRITE_FIELDS as $field ) {
if ( $request->has_param( $field ) ) {
- $args[ $field ] = 'name' === $field ? $this->get_string_param( $request, 'name' ) : $request->get_param( $field );
+ $value = $request->get_param( $field );
+ $args[ $field ] = 'name' === $field && is_string( $value ) ? sanitize_text_field( $value ) : $value;
}
}
@@ -537,17 +537,17 @@ final class PlansController extends WP_REST_Controller {
}
/**
- * Present a stored policy as a JSON object: an empty payload serializes as `{}`.
+ * Present a map (a stored policy, error params) as a JSON object: an empty one serializes as `{}`.
*
- * @param array<string, mixed>|null $policy Policy payload.
- * @return array<string, mixed>|object|null
+ * @param array<array-key, mixed>|null $map Map.
+ * @return array<array-key, mixed>|object|null
*/
- private static function as_json_object( ?array $policy ) {
- if ( array() === $policy ) {
+ private static function as_json_object( ?array $map ) {
+ if ( array() === $map ) {
return (object) array();
}
- return $policy;
+ return $map;
}
/**
@@ -580,6 +580,175 @@ final class PlansController extends WP_REST_Controller {
);
}
+ /**
+ * Report validation errors as WordPress reports invalid request params: errors with a
+ * `field` become one `rest_invalid_param` error (see {@see self::get_invalid_param_error()});
+ * errors without one are added to it. With no `field` on any error, the errors return
+ * as a plain 400.
+ *
+ * @param ValidationException $e The facade's refusal.
+ */
+ private function get_validation_error( ValidationException $e ): WP_Error {
+ $paired = $this->get_paired_errors( $e );
+ if ( array() === $paired['fielded'] ) {
+ return $this->as_bad_request( $paired['fieldless'] );
+ }
+
+ $error = $this->get_invalid_param_error( $paired['fielded'] );
+ $this->merge_fieldless_errors( $error, $paired['fieldless'] );
+
+ return $error;
+ }
+
+ /**
+ * Pair each error message with its own data, in `WP_Error` order. The engine's errors
+ * always pair. An extension's error pairs only when every extension message of its code
+ * carries data; otherwise that code's extension messages are fieldless, so a field never
+ * lands on the wrong message.
+ *
+ * @param ValidationException $e The facade's refusal.
+ * @return array{fielded: list<array{code: string, message: string, field: string, data: array<mixed>}>, fieldless: WP_Error}
+ */
+ private function get_paired_errors( ValidationException $e ): array {
+ $errors = $e->get_errors();
+ $fielded = array();
+ $fieldless = new WP_Error();
+
+ // The chained Core refusal holds the engine's errors, which come first in their code and always carry data.
+ $engine_counts = array();
+ $refusal = $e->getPrevious();
+ if ( $refusal instanceof InvalidInputException ) {
+ foreach ( $refusal->get_errors() as $error ) {
+ $engine_counts[ $error->get_error_code() ] = ( $engine_counts[ $error->get_error_code() ] ?? 0 ) + 1;
+ }
+ }
+
+ foreach ( $errors->get_error_codes() as $code ) {
+ $code = (string) $code;
+ $messages = array_values( $errors->get_error_messages( $code ) );
+ $all_data = array_values( $errors->get_all_error_data( $code ) );
+ // `WP_Error` skips empty data, so when the counts differ the extension's data cannot be placed.
+ $pairs = count( $messages ) === count( $all_data );
+
+ foreach ( $messages as $index => $message ) {
+ $data = ( $pairs || $index < ( $engine_counts[ $code ] ?? 0 ) ) ? $all_data[ $index ] : null;
+ if ( is_array( $data ) && isset( $data['field'] ) && is_string( $data['field'] ) && '' !== $data['field'] ) {
+ $fielded[] = array(
+ 'code' => $code,
+ 'message' => $message,
+ 'field' => $data['field'],
+ 'data' => $data,
+ );
+ continue;
+ }
+
+ $fieldless->add( $code, $message, $data );
+ }
+ }
+
+ return array(
+ 'fielded' => $fielded,
+ 'fieldless' => $fieldless,
+ );
+ }
+
+ /**
+ * The `rest_invalid_param` error for fielded errors. Keyed by top-level arg: `params`
+ * holds the arg's sentences naming their fields, and `details` the arg's first error
+ * (code, that sentence and its own data). `errors` maps each full dot path to its
+ * errors' code, field-free message and params.
+ *
+ * @param list<array{code: string, message: string, field: string, data: array<mixed>}> $fielded Paired, fielded errors.
+ */
+ private function get_invalid_param_error( array $fielded ): WP_Error {
+ $params = array();
+ $details = array();
+ $field_errors = array();
+ foreach ( $fielded as $error ) {
+ $arg = $this->get_top_level_arg( $error['field'] );
+ $message = $this->get_field_message( $error['field'], $error['message'] );
+
+ $params[ $arg ][] = $message;
+ if ( ! isset( $details[ $arg ] ) ) {
+ $details[ $arg ] = array(
+ 'code' => $error['code'],
+ 'message' => $message,
+ 'data' => $error['data'],
+ );
+ if ( isset( $error['data']['params'] ) && is_array( $error['data']['params'] ) ) {
+ $details[ $arg ]['data']['params'] = self::as_json_object( $error['data']['params'] );
+ }
+ }
+ $field_errors[ $error['field'] ][] = array(
+ 'code' => $error['code'],
+ 'message' => $error['message'],
+ 'params' => self::as_json_object( isset( $error['data']['params'] ) && is_array( $error['data']['params'] ) ? $error['data']['params'] : array() ),
+ );
+ }
+
+ return new WP_Error(
+ 'rest_invalid_param',
+ /* translators: %s: List of invalid parameters. */
+ sprintf( __( 'Invalid parameter(s): %s' ), implode( ', ', array_keys( $params ) ) ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- WordPress core's own message for invalid request params, in its default domain.
+ array(
+ 'status' => 400,
+ 'params' => array_map(
+ static function ( array $messages ): string {
+ return implode( ' ', $messages );
+ },
+ $params
+ ),
+ 'details' => $details,
+ 'errors' => $field_errors,
+ )
+ );
+ }
+
+ /**
+ * Add fieldless errors to the response error, each with a 400 status unless it has
+ * its own. A `rest_invalid_param` one is added without its data, so it can never
+ * replace the response's data.
+ *
+ * @param WP_Error $error The `rest_invalid_param` response error.
+ * @param WP_Error $fieldless Errors without a field.
+ */
+ private function merge_fieldless_errors( WP_Error $error, WP_Error $fieldless ): void {
+ $fieldless = $this->as_bad_request( $fieldless );
+ foreach ( $fieldless->get_error_codes() as $code ) {
+ foreach ( $fieldless->get_error_messages( $code ) as $message ) {
+ $error->add( $code, $message );
+ }
+ if ( 'rest_invalid_param' === $code ) {
+ continue;
+ }
+ foreach ( $fieldless->get_all_error_data( $code ) as $data ) {
+ $error->add_data( $data, $code );
+ }
+ }
+ }
+
+ /**
+ * The top-level request arg of a dot path: the path up to its first dot.
+ *
+ * @param string $field Field, in dot notation.
+ */
+ private function get_top_level_arg( string $field ): string {
+ $dot = strpos( $field, '.' );
+
+ return false === $dot ? $field : substr( $field, 0, $dot );
+ }
+
+ /**
+ * A validation message that names its field, for REST `params` and `details`.
+ *
+ * @param string $field Field, in dot notation.
+ * @param string $message Translated, field-free message.
+ */
+ private function get_field_message( string $field, string $message ): string {
+ /* translators: 1: field name, such as billing_policy.interval, 2: validation message. */
+ return sprintf( __( '%1$s: %2$s', 'woocommerce' ), $field, $message );
+ }
+
/**
* Give each error code without a status a 400 status.
*
@@ -596,6 +765,30 @@ final class PlansController extends WP_REST_Controller {
return $errors;
}
+ /**
+ * The list filter's `status` error, in the validation error shape: null when the
+ * filter is absent or a registered plan status.
+ *
+ * @param mixed $value Param value.
+ */
+ private function get_status_filter_error( $value ): ?WP_Error {
+ if ( null === $value ) {
+ return null;
+ }
+
+ if ( ! is_string( $value ) ) {
+ $error = new InvalidInputException( ValidationErrorCode::INVALID_TYPE, 'status', 'status must be a string.', array( 'type' => 'string' ) );
+ } elseif ( ! PlanStatus::is_registered( $value ) ) {
+ $error = new InvalidInputException( ValidationErrorCode::NOT_REGISTERED, 'status', 'status must be a registered plan status.', array( 'value' => $value ) );
+ } else {
+ return null;
+ }
+
+ $exception = ValidationException::from_core( $error );
+
+ return $this->get_validation_error( $exception );
+ }
+
/**
* Get multiple, valid extension slugs from an incoming request.
*
@@ -605,11 +798,11 @@ final class PlansController extends WP_REST_Controller {
private function get_multiple_extension_slugs( WP_REST_Request $request ) {
$raw = $request->get_param( 'extension_slug' );
if ( null === $raw ) {
- return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+ return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
}
$raw_string = trim( Coercion::coerce_string( $raw ) );
if ( '' === $raw_string ) {
- return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+ return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
}
if ( 'any' === $raw_string ) {
@@ -620,7 +813,7 @@ final class PlansController extends WP_REST_Controller {
foreach ( explode( ',', $raw_string ) as $possible_slug ) {
$slug = trim( $possible_slug );
if ( '' === $slug || 'any' === $slug || ! $this->is_valid_extension_slug( $slug ) ) {
- return $this->invalid_error( __( 'extension_slug must be "any" or a comma-separated list of extension slugs.', 'woocommerce-subscriptions-engine' ) );
+ return $this->get_extension_slug_error( ValidationErrorCode::INVALID_VALUE, 'extension_slug must be "any" or a comma-separated list of extension slugs.' );
}
$slugs[ $slug ] = $slug;
@@ -638,38 +831,43 @@ final class PlansController extends WP_REST_Controller {
private function get_single_extension_slug( WP_REST_Request $request ) {
$raw = $request->get_param( 'extension_slug' );
if ( null === $raw ) {
- return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+ return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
}
- $raw_string = trim( Coercion::coerce_string( $raw ) );
+ if ( ! is_string( $raw ) ) {
+ return $this->get_extension_slug_error( ValidationErrorCode::INVALID_TYPE, 'extension_slug must be a string.', array( 'type' => 'string' ) );
+ }
+ $raw_string = trim( $raw );
if ( '' === $raw_string ) {
- return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+ return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
}
if ( 'any' === $raw_string || false !== strpos( $raw_string, ',' ) || ! $this->is_valid_extension_slug( $raw_string ) ) {
- return $this->invalid_error( __( 'extension_slug must be a concrete extension slug.', 'woocommerce-subscriptions-engine' ) );
+ return $this->get_extension_slug_error( ValidationErrorCode::INVALID_VALUE, 'extension_slug must be a concrete extension slug.' );
}
return $raw_string;
}
/**
- * Whether a value is a valid extension slug.
+ * The `extension_slug` param error, in the validation error shape.
*
- * @param string $slug Possible extension slug.
+ * @param string $code Error code.
+ * @param string $message English developer message.
+ * @param array<string, mixed> $params Error params.
*/
- private function is_valid_extension_slug( string $slug ): bool {
- return '' !== $slug && sanitize_key( $slug ) === $slug;
+ private function get_extension_slug_error( string $code, string $message, array $params = array() ): WP_Error {
+ $exception = ValidationException::from_core( new InvalidInputException( $code, 'extension_slug', $message, $params ) );
+
+ return $this->get_validation_error( $exception );
}
/**
- * A sanitized string param.
+ * Whether a value is a valid extension slug.
*
- * @param WP_REST_Request $request Request.
- * @param string $key Param key.
- * @param string $fallback Fallback.
+ * @param string $slug Possible extension slug.
*/
- private function get_string_param( WP_REST_Request $request, string $key, string $fallback = '' ): string {
- return sanitize_text_field( Coercion::coerce_string( $request->get_param( $key ), $fallback ) );
+ private function is_valid_extension_slug( string $slug ): bool {
+ return '' !== $slug && sanitize_key( $slug ) === $slug;
}
/**
@@ -682,17 +880,4 @@ final class PlansController extends WP_REST_Controller {
array( 'status' => 404 )
);
}
-
- /**
- * Invalid request error.
- *
- * @param string $message Message.
- */
- private function invalid_error( string $message ): WP_Error {
- return new WP_Error(
- 'woocommerce_subscriptions_engine_invalid_plan',
- $message,
- array( 'status' => 400 )
- );
- }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ValidationException.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ValidationException.php
new file mode 100644
index 00000000000..ab64bfc7d24
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ValidationException.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * ValidationException - a facade call refused because its input is not valid.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use InvalidArgumentException;
+use Throwable;
+use WP_Error;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Thrown by the facades when they refuse input: an engine check (built with
+ * {@see self::from_core()}) or a `woocommerce_subscriptions_engine_validate_plan`
+ * callback. Callers read {@see self::get_errors()}: each error's code (a
+ * {@see ValidationErrorCode} or the extension's own), its translated message, and
+ * its data, with `field` (dot path) and `params` for engine errors.
+ * `getMessage()` stays English, for logs.
+ */
+final class ValidationException extends InvalidArgumentException {
+
+ /**
+ * Collected validation errors.
+ *
+ * @var WP_Error
+ */
+ private $errors;
+
+ /**
+ * Wrap the collected validation errors.
+ *
+ * @param WP_Error $errors Validation errors.
+ * @param string $message Escaped exception message; empty joins the error messages.
+ * @param Throwable|null $previous The exception that caused the refusal.
+ */
+ public function __construct( WP_Error $errors, string $message = '', ?Throwable $previous = null ) {
+ if ( '' === $message ) {
+ $message = esc_html( implode( ' ', $errors->get_error_messages() ) );
+ }
+
+ parent::__construct( $message, 0, $previous );
+
+ $this->errors = $errors;
+ }
+
+ /**
+ * Convert a Core validation error into translated errors, one entry per Core error, each
+ * with the engine code, a field-free message and `field` / `params` data. The Core
+ * exception is chained as the previous one.
+ *
+ * @param InvalidInputException $e Core validation error.
+ */
+ public static function from_core( InvalidInputException $e ): self {
+ $errors = new WP_Error();
+ foreach ( $e->get_errors() as $error ) {
+ $errors->add(
+ $error->get_error_code(),
+ self::get_translated_message( $error->get_error_code(), $error->get_params() ),
+ array(
+ 'field' => $error->get_field(),
+ 'params' => $error->get_params(),
+ )
+ );
+ }
+
+ return new self( $errors, esc_html( $e->getMessage() ), $e );
+ }
+
+ /**
+ * The validation errors, with their codes and data.
+ */
+ public function get_errors(): WP_Error {
+ return $this->errors;
+ }
+
+ /**
+ * Translated, field-free message for an error code. An unknown code, or params a
+ * message cannot use, get the generic message, so a code a third party constructs
+ * never fails here.
+ *
+ * @param string $code Error code.
+ * @param array<string, mixed> $params Message values.
+ */
+ private static function get_translated_message( string $code, array $params ): string {
+ $min = isset( $params['min'] ) && is_int( $params['min'] ) ? $params['min'] : null;
+ $max = isset( $params['max'] ) && is_int( $params['max'] ) ? $params['max'] : null;
+
+ switch ( $code ) {
+ case ValidationErrorCode::REQUIRED:
+ return __( 'This field is required.', 'woocommerce' );
+ case ValidationErrorCode::INVALID_TYPE:
+ if ( isset( $params['type'] ) && is_string( $params['type'] ) ) {
+ /* translators: %s: expected type, such as string or integer. */
+ return sprintf( __( 'Must be of type %s.', 'woocommerce' ), $params['type'] );
+ }
+ break;
+ case ValidationErrorCode::OUT_OF_BOUNDS:
+ if ( null !== $min && null !== $max ) {
+ /* translators: 1: lowest allowed number, 2: highest allowed number. */
+ return sprintf( __( 'Must be between %1$d and %2$d.', 'woocommerce' ), $min, $max );
+ }
+ if ( null !== $min ) {
+ /* translators: %d: lowest allowed number. */
+ return sprintf( __( 'Must be greater than or equal to %d.', 'woocommerce' ), $min );
+ }
+ if ( null !== $max ) {
+ /* translators: %d: highest allowed number. */
+ return sprintf( __( 'Must be less than or equal to %d.', 'woocommerce' ), $max );
+ }
+ break;
+ case ValidationErrorCode::NOT_IN_ENUM:
+ if ( isset( $params['allowed'] ) && is_array( $params['allowed'] ) ) {
+ /* translators: %l: list of allowed values, such as "day, week, and month". */
+ return wp_sprintf( __( 'Must be one of %l.', 'woocommerce' ), array_values( array_filter( $params['allowed'], 'is_string' ) ) );
+ }
+ break;
+ case ValidationErrorCode::NOT_REGISTERED:
+ if ( isset( $params['value'] ) && is_scalar( $params['value'] ) ) {
+ /* translators: %s: the status that is not registered. */
+ return sprintf( __( '"%s" is not registered.', 'woocommerce' ), (string) $params['value'] );
+ }
+ break;
+ }
+
+ return __( 'This value is not valid.', 'woocommerce' );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
index 35f9c649a6e..2af628e5883 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -25,9 +25,10 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
-use DomainException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
@@ -306,10 +307,11 @@ final class Contract {
/**
* Build a new, unsaved contract. `extension_slug` is required; `creation_key` is an
- * optional idempotency key of 1-100 characters, trimmed of surrounding whitespace.
+ * optional idempotency key of 1-100 characters, trimmed of surrounding whitespace. Every
+ * invalid attribute is reported in one exception.
*
* @param array<string, mixed> $args Contract attributes.
- * @throws DomainException If the contract attributes are not valid.
+ * @throws InvalidInputException If the contract attributes are not valid.
*/
public static function create( array $args ): self {
// A new contract is always unsaved and unlocked; never adopt a caller-supplied id or lock.
@@ -320,35 +322,46 @@ final class Contract {
$args['creation_key'] = trim( $args['creation_key'] );
}
- self::assert_creation_key( $args['creation_key'] ?? null );
+ $creation_key_error = self::get_creation_key_error( $args['creation_key'] ?? null );
+ if ( null !== $creation_key_error ) {
+ unset( $args['creation_key'] );
+ }
$contract = new self( $args );
- self::assert_status( $contract->status );
- self::assert_extension_slug( $contract->extension_slug );
- self::assert_schedule_source( $contract->schedule_source );
- $contract->assert_money_has_currency();
+ $errors = array_filter(
+ array(
+ $creation_key_error,
+ self::get_status_error( $contract->status ),
+ self::get_extension_slug_error( $contract->extension_slug ),
+ self::get_schedule_source_error( $contract->schedule_source ),
+ $contract->get_money_currency_error(),
+ )
+ );
+ if ( array() !== $errors ) {
+ throw InvalidInputException::from_errors( array_values( $errors ) );
+ }
return $contract;
}
/**
- * Refuse money without a currency: a contract with no currency must have every total at zero.
- * Cross-field, so callers that change several fields check it once after the last change.
- * Checked against the state the caller holds; the contract lock serializes facade writers.
- *
- * @throws DomainException If a total is non-zero while the currency is unset.
+ * The error for money without a currency, or null: a contract with no currency must have
+ * every total at zero. Cross-field, so callers that change several fields check it once
+ * after the last change. Checked against the state the caller holds; the contract lock serializes facade writers.
*/
- public function assert_money_has_currency(): void {
+ public function get_money_currency_error(): ?InvalidInputException {
if ( null !== $this->currency ) {
- return;
+ return null;
}
foreach ( array( $this->billing_total, $this->discount_total, $this->shipping_total, $this->tax_total ) as $total ) {
if ( 0.0 !== (float) $total ) {
- throw new DomainException( 'Contract: money totals require a currency.' );
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'currency', 'Contract: money totals require a currency.' );
}
}
+
+ return null;
}
/**
@@ -412,14 +425,17 @@ final class Contract {
* status is a no-op, so a hydrated unregistered status survives it.
*
* @param string $status Target status; must be registered.
- * @throws DomainException If `$status` is not a registered contract status.
+ * @throws InvalidInputException If `$status` is not a registered contract status.
*/
public function set_status( string $status ): void {
if ( $status === $this->status ) {
return;
}
- self::assert_status( $status );
+ $error = self::get_status_error( $status );
+ if ( null !== $error ) {
+ throw $error;
+ }
$this->status = $status;
}
@@ -736,10 +752,13 @@ final class Contract {
*
* @param string $schedule_source 'subscriptions_engine' or the slug of the gateway or
* extension that keeps its own schedule.
- * @throws DomainException If `$schedule_source` is not a valid slug.
+ * @throws InvalidInputException If `$schedule_source` is not a valid slug.
*/
public function set_schedule_source( string $schedule_source ): void {
- self::assert_schedule_source( $schedule_source );
+ $error = self::get_schedule_source_error( $schedule_source );
+ if ( null !== $error ) {
+ throw $error;
+ }
$this->schedule_source = $schedule_source;
}
@@ -854,56 +873,73 @@ final class Contract {
}
/**
- * Refuse a creation key that is not null or a string of 1-100 characters.
+ * The error for a creation key that is not null or a string of 1-100 characters, or null.
*
* @param mixed $creation_key Creation key to check.
- * @throws DomainException If `$creation_key` is not valid.
*/
- private static function assert_creation_key( $creation_key ): void {
+ private static function get_creation_key_error( $creation_key ): ?InvalidInputException {
if ( null === $creation_key ) {
- return;
+ return null;
+ }
+
+ if ( ! is_string( $creation_key ) ) {
+ return new InvalidInputException( ValidationErrorCode::INVALID_TYPE, 'creation_key', 'Contract: creation_key must be a string.', array( 'type' => 'string' ) );
}
- if ( ! is_string( $creation_key ) || '' === $creation_key || mb_strlen( $creation_key ) > 100 ) {
- throw new DomainException( 'Contract: creation_key must be a string of 1-100 characters.' );
+ if ( '' === $creation_key || mb_strlen( $creation_key ) > 100 ) {
+ return new InvalidInputException( ValidationErrorCode::INVALID_VALUE, 'creation_key', 'Contract: creation_key must be 1-100 characters.' );
}
+
+ return null;
}
/**
- * Refuse a status that is not a registered contract status.
+ * The error for a status that is not a registered contract status, or null.
*
* @param string $status Status to check.
- * @throws DomainException If `$status` is not registered.
*/
- private static function assert_status( string $status ): void {
+ private static function get_status_error( string $status ): ?InvalidInputException {
if ( ! ContractStatus::is_registered( $status ) ) {
- throw new DomainException( sprintf( 'Contract: status "%s" is not registered.', $status ) );
+ return new InvalidInputException(
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ sprintf( 'Contract: status "%s" is not registered.', $status ),
+ array( 'value' => $status )
+ );
}
+
+ return null;
}
/**
- * Refuse a missing or empty owning extension slug.
+ * The error for a missing or empty owning extension slug, or null.
*
* @param string|null $extension_slug Extension slug to check.
- * @throws DomainException If `$extension_slug` is null or empty.
*/
- private static function assert_extension_slug( ?string $extension_slug ): void {
+ private static function get_extension_slug_error( ?string $extension_slug ): ?InvalidInputException {
if ( null === $extension_slug || '' === $extension_slug ) {
- throw new DomainException( 'Contract: extension_slug is required and must be a non-empty string.' );
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'extension_slug', 'Contract: extension_slug is required and must be a non-empty string.' );
}
+
+ return null;
}
/**
- * Refuse a schedule source that is not a slug of 1-64 lowercase letters, digits,
+ * The error for a schedule source that is not a slug of 1-64 lowercase letters, digits,
* underscores or hyphens (the column is VARCHAR(64), wide enough for extension and
- * gateway slugs).
+ * gateway slugs), or null.
*
* @param string $schedule_source Schedule source to check.
- * @throws DomainException If `$schedule_source` is not a valid slug.
*/
- private static function assert_schedule_source( string $schedule_source ): void {
+ private static function get_schedule_source_error( string $schedule_source ): ?InvalidInputException {
if ( 1 !== preg_match( '/^[a-z0-9_-]{1,64}$/D', $schedule_source ) ) {
- throw new DomainException( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ) );
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_VALUE,
+ 'schedule_source',
+ sprintf( 'Contract: invalid schedule source "%s".', $schedule_source )
+ );
}
+
+ return null;
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
index 5d59fd446bf..c60b989efbb 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -16,10 +16,11 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
-use DomainException;
use LogicException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
@@ -197,27 +198,41 @@ final class Cycle {
* next position), `count` (absent or null for a non-counting cycle; the caller owns
* the numbering), `status` (defaults to `pending`), `kind` (defaults to billing), `reason`,
* `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`. Snapshot value
- * objects are attached only when a stored cycle is read.
+ * objects are attached only when a stored cycle is read. Every invalid attribute is
+ * reported in one exception.
*
* @param array<string, mixed> $args Cycle attributes.
- * @throws DomainException If the attributes are not valid.
+ * @throws InvalidInputException If the attributes are not valid.
*/
public static function create( array $args ): self {
// A new cycle is always unsaved; never adopt a caller-supplied id.
unset( $args['id'] );
+ // Checked before construction, which refuses a malformed status slug; a refused
+ // status is dropped so construction can still check the other attributes.
+ $status_error = self::get_status_error( $args['status'] ?? null );
+ if ( null !== $status_error ) {
+ unset( $args['status'] );
+ }
+
$cycle = new self( $args );
- self::assert_contract_id( $cycle->contract_id );
- self::assert_kind( $cycle->kind );
- // Null is allowed here for auto-assign sequence_no during append operation.
- if ( null !== $cycle->sequence_no ) {
- self::assert_sequence_no( $cycle->sequence_no );
+ $errors = array_filter(
+ array(
+ self::get_contract_id_error( $cycle->contract_id ),
+ self::get_kind_error( $cycle->kind ),
+ // Null is allowed here for auto-assign sequence_no during append operation.
+ null === $cycle->sequence_no ? null : self::get_sequence_no_error( $cycle->sequence_no ),
+ self::get_starts_at_gmt_error( $cycle->starts_at_gmt ),
+ self::get_ends_at_gmt_error( $cycle->ends_at_gmt ),
+ self::get_currency_error( $cycle->currency ),
+ self::get_count_error( $cycle->count ),
+ $status_error,
+ )
+ );
+ if ( array() !== $errors ) {
+ throw InvalidInputException::from_errors( array_values( $errors ) );
}
- self::assert_period( $cycle->starts_at_gmt, $cycle->ends_at_gmt );
- self::assert_currency( $cycle->currency );
- self::assert_count( $cycle->count );
- self::assert_status( $cycle->status );
return $cycle;
}
@@ -229,7 +244,7 @@ final class Cycle {
* written), so a value written by a since-deactivated extension round-trips unchanged.
*
* @param array<string, mixed> $row Cycle row.
- * @throws DomainException If the stored status is not a well-formed status slug.
+ * @throws InvalidInputException If the stored status is not a well-formed status slug.
*/
public static function from_storage( array $row ): self {
return new self( $row );
@@ -286,13 +301,16 @@ final class Cycle {
* @internal Used by the repository's append.
*
* @param int $sequence_no Position, monotonic from 1.
- * @throws DomainException If the position is already assigned or `$sequence_no` is not positive.
+ * @throws LogicException If the position is already assigned ({@see InvalidInputException}, a LogicException, if `$sequence_no` is not positive).
*/
public function assign_sequence_no( int $sequence_no ): void {
if ( null !== $this->sequence_no ) {
- throw new DomainException( 'Cycle: sequence_no is already assigned.' );
+ throw new LogicException( 'Cycle: sequence_no is already assigned.' );
+ }
+ $error = self::get_sequence_no_error( $sequence_no );
+ if ( null !== $error ) {
+ throw $error;
}
- self::assert_sequence_no( $sequence_no );
$this->sequence_no = $sequence_no;
}
@@ -400,11 +418,11 @@ final class Cycle {
* unset value object, never swaps the frozen snapshot.
*
* @param PlanSnapshot $plan_snapshot Snapshot value object.
- * @throws DomainException If a plan snapshot value object is already attached.
+ * @throws LogicException If a plan snapshot value object is already attached.
*/
public function set_plan_snapshot( PlanSnapshot $plan_snapshot ): void {
if ( null !== $this->plan_snapshot ) {
- throw new DomainException( 'Cycle: the plan snapshot is frozen and cannot be replaced.' );
+ throw new LogicException( 'Cycle: the plan snapshot is frozen and cannot be replaced.' );
}
$this->plan_snapshot = $plan_snapshot;
}
@@ -421,11 +439,11 @@ final class Cycle {
* {@see self::set_plan_snapshot()}.
*
* @param ItemsSnapshot $items_snapshot Snapshot value object.
- * @throws DomainException If an items snapshot value object is already attached.
+ * @throws LogicException If an items snapshot value object is already attached.
*/
public function set_items_snapshot( ItemsSnapshot $items_snapshot ): void {
if ( null !== $this->items_snapshot ) {
- throw new DomainException( 'Cycle: the items snapshot is frozen and cannot be replaced.' );
+ throw new LogicException( 'Cycle: the items snapshot is frozen and cannot be replaced.' );
}
$this->items_snapshot = $items_snapshot;
}
@@ -457,93 +475,136 @@ final class Cycle {
}
/**
- * Refuse a missing (non-positive) owning contract id.
+ * The error for a missing (non-positive) owning contract id, or null.
*
* @param int $contract_id Contract id to check.
- * @throws DomainException If `$contract_id` is not positive.
*/
- private static function assert_contract_id( int $contract_id ): void {
+ private static function get_contract_id_error( int $contract_id ): ?InvalidInputException {
if ( $contract_id < 1 ) {
- throw new DomainException( 'Cycle: contract_id is required.' );
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'contract_id',
+ sprintf( 'Cycle: contract_id must be 1 or greater, got %d.', $contract_id ),
+ array( 'min' => 1 )
+ );
}
+
+ return null;
}
/**
- * Validate a cycle kind. Known-but-extensible (deliberately not a sealed enum):
- * any non-empty kind is accepted so a third party may introduce its own.
+ * The error for an empty cycle kind, or null. Known-but-extensible (deliberately not a
+ * sealed enum): any non-empty kind is accepted so a third party may introduce its own.
*
- * @param string $kind Kind to validate.
- * @throws DomainException If `$kind` is empty.
+ * @param string $kind Kind to check.
*/
- private static function assert_kind( string $kind ): void {
+ private static function get_kind_error( string $kind ): ?InvalidInputException {
if ( '' === $kind ) {
- throw new DomainException( 'Cycle: kind must not be empty.' );
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'kind', 'Cycle: kind must not be empty.' );
}
+
+ return null;
}
/**
- * Refuse a cycle without a start or end moment.
+ * The error for a missing start moment, or null.
*
* @param string $starts_at_gmt Start (GMT).
- * @param string $ends_at_gmt End (GMT).
- * @throws DomainException If either moment is missing.
*/
- private static function assert_period( string $starts_at_gmt, string $ends_at_gmt ): void {
- if ( '' === $starts_at_gmt || '' === $ends_at_gmt ) {
- throw new DomainException( 'Cycle: starts_at_gmt and ends_at_gmt are required.' );
+ private static function get_starts_at_gmt_error( string $starts_at_gmt ): ?InvalidInputException {
+ if ( '' === $starts_at_gmt ) {
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'starts_at_gmt', 'Cycle: starts_at_gmt is required.' );
}
+
+ return null;
}
/**
- * Refuse a cycle without a currency.
+ * The error for a missing end moment, or null.
+ *
+ * @param string $ends_at_gmt End (GMT).
+ */
+ private static function get_ends_at_gmt_error( string $ends_at_gmt ): ?InvalidInputException {
+ if ( '' === $ends_at_gmt ) {
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'ends_at_gmt', 'Cycle: ends_at_gmt is required.' );
+ }
+
+ return null;
+ }
+
+ /**
+ * The error for a missing currency, or null.
*
* @param string $currency Currency code.
- * @throws DomainException If the currency is missing.
*/
- private static function assert_currency( string $currency ): void {
+ private static function get_currency_error( string $currency ): ?InvalidInputException {
if ( '' === $currency ) {
- throw new DomainException( 'Cycle: currency is required.' );
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'currency', 'Cycle: currency is required.' );
}
+
+ return null;
}
/**
- * Validate a sequence number.
+ * The error for a non-positive sequence number, or null.
*
- * @param int $sequence_no Sequence number to validate.
- * @throws DomainException If `$sequence_no` is not positive.
+ * @param int $sequence_no Sequence number to check.
*/
- private static function assert_sequence_no( int $sequence_no ): void {
+ private static function get_sequence_no_error( int $sequence_no ): ?InvalidInputException {
if ( $sequence_no < 1 ) {
- throw new DomainException(
- sprintf( 'Cycle: sequence_no must be 1 or greater, got %d.', $sequence_no )
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'sequence_no',
+ sprintf( 'Cycle: sequence_no must be 1 or greater, got %d.', $sequence_no ),
+ array( 'min' => 1 )
);
}
+
+ return null;
}
/**
- * Reject a status value that is not registered. Write path only
- * ({@see self::create()}); storage hydration keeps an
+ * The error for a status input a new cycle cannot take, or null: a malformed status slug
+ * (`invalid_value`) or one that is not registered (`not_registered`). Null means the
+ * default `pending`. Write path only ({@see self::create()}); storage hydration keeps an
* unregistered stored value verbatim.
*
- * @param CycleStatus $status Status to check.
- * @throws DomainException If the status is not registered.
+ * @param mixed $status Raw status value (a CycleStatus, null, or a status string).
*/
- private static function assert_status( CycleStatus $status ): void {
- if ( ! CycleStatus::is_registered( $status->get_value() ) ) {
- throw new DomainException(
- sprintf( 'Cycle: status "%s" is not registered.', $status->get_value() )
+ private static function get_status_error( $status ): ?InvalidInputException {
+ if ( null === $status ) {
+ return null;
+ }
+
+ $value = $status instanceof CycleStatus ? $status->get_value() : Coercion::coerce_string( $status );
+ if ( ! CycleStatus::is_valid( $value ) ) {
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_VALUE,
+ 'status',
+ sprintf( 'CycleStatus: "%s" is not a valid status slug.', $value )
+ );
+ }
+ if ( ! CycleStatus::is_registered( $value ) ) {
+ return new InvalidInputException(
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ sprintf( 'Cycle: status "%s" is not registered.', $value ),
+ array( 'value' => $value )
);
}
+
+ return null;
}
/**
* Resolve a status input into a typed {@see CycleStatus}. A `CycleStatus` passes
* through; null defaults to `pending`; a string is wrapped (slug format checked).
- * Registration is checked by {@see self::create()}, not here, so storage hydration keeps an unregistered stored value.
+ * {@see self::create()} checks the raw status first; registration is not checked here,
+ * so storage hydration keeps an unregistered stored value.
*
* @param mixed $status Raw status value (a CycleStatus, null, or a status string).
* @return CycleStatus
- * @throws DomainException If a status string is not a well-formed status slug.
+ * @throws InvalidInputException If a status string is not a well-formed status slug.
*/
private static function coerce_status( $status ): CycleStatus {
if ( $status instanceof CycleStatus ) {
@@ -558,16 +619,21 @@ final class Cycle {
}
/**
- * Validate a chargeable count: null (a non-counting cycle) or a positive integer.
+ * The error for a present count that is not positive, or null (a non-counting cycle
+ * has no count).
*
- * @param int|null $count Count to validate.
- * @throws DomainException If a present count is not positive.
+ * @param int|null $count Count to check.
*/
- private static function assert_count( ?int $count ): void {
+ private static function get_count_error( ?int $count ): ?InvalidInputException {
if ( null !== $count && $count < 1 ) {
- throw new DomainException(
- sprintf( 'Cycle: count must be 1 or greater when set, got %d.', $count )
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'count',
+ sprintf( 'Cycle: count must be 1 or greater when set, got %d.', $count ),
+ array( 'min' => 1 )
);
}
+
+ return null;
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
index 75f2ec2fcf1..6633e8e42c6 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -16,7 +16,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
-use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
defined( 'ABSPATH' ) || exit;
@@ -47,11 +48,13 @@ final class CycleStatus {
* Wrap a status slug.
*
* @param string $value Status slug.
- * @throws DomainException If `$value` is not a well-formed status slug.
+ * @throws InvalidInputException If `$value` is not a well-formed status slug.
*/
public function __construct( string $value ) {
if ( ! self::is_valid( $value ) ) {
- throw new DomainException(
+ throw new InvalidInputException(
+ ValidationErrorCode::INVALID_VALUE,
+ 'status',
sprintf( 'CycleStatus: "%s" is not a valid status slug.', $value )
);
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
index 4e51b175c81..9046c91dedc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -10,16 +10,18 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
-use DomainException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
defined( 'ABSPATH' ) || exit;
/**
* Plan entity.
*
- * Construct via {@see self::create()} for a new (unsaved) plan or
- * {@see self::from_storage()} when hydrating a stored row.
+ * Construct via {@see self::create()} for a new (unsaved) plan,
+ * {@see self::from_storage()} when hydrating a stored row, or {@see self::draft()}
+ * for the unchecked view of a refused write.
*/
final class Plan {
@@ -87,7 +89,7 @@ final class Plan {
private $date_updated_gmt;
/**
- * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+ * Use {@see self::create()}, {@see self::from_storage()} or {@see self::draft()}. Coerces each attribute
* to its property type; unknown keys are ignored, missing keys take the default.
*
* @param array<string, mixed> $data Raw attributes keyed by property name.
@@ -106,28 +108,51 @@ final class Plan {
/**
* Build a new, unsaved plan. `extension_slug` and a non-empty `name` are required.
+ * Every invalid attribute is reported in one exception.
*
* @param array<string, mixed> $args Keys `name`, `status` (default {@see PlanStatus::ACTIVE}), `extension_slug`, `billing_policy`, `pricing_policy`, `delivery_policy`.
- * @throws DomainException If the plan attributes are not valid.
+ * @throws InvalidInputException If the plan attributes are not valid.
*/
public static function create( array $args ): self {
// A new plan is always unsaved; never adopt a caller-supplied id or stored dates.
unset( $args['id'], $args['date_created_gmt'], $args['date_updated_gmt'] );
// Checked before construction, which coerces a non-array payload to null.
- self::assert_policy( 'billing_policy', $args['billing_policy'] ?? null );
- self::assert_policy( 'pricing_policy', $args['pricing_policy'] ?? null );
- self::assert_policy( 'delivery_policy', $args['delivery_policy'] ?? null );
+ $policy_errors = array(
+ self::get_policy_error( 'billing_policy', $args['billing_policy'] ?? null ),
+ self::get_policy_error( 'pricing_policy', $args['pricing_policy'] ?? null ),
+ self::get_policy_error( 'delivery_policy', $args['delivery_policy'] ?? null ),
+ );
$plan = new self( $args );
- self::assert_name( $plan->name );
- self::assert_status( $plan->status );
- self::assert_extension_slug( $plan->extension_slug );
+ $errors = array_filter(
+ array_merge(
+ array(
+ self::get_name_error( $plan->name ),
+ self::get_status_error( $plan->status ),
+ self::get_extension_slug_error( $plan->extension_slug ),
+ ),
+ $policy_errors
+ )
+ );
+ if ( array() !== $errors ) {
+ throw InvalidInputException::from_errors( array_values( $errors ) );
+ }
return $plan;
}
+ /**
+ * Build a plan from raw attributes without checking them: the would-be plan of a
+ * refused write, shown to validation callbacks. Never store it.
+ *
+ * @param array<string, mixed> $data Raw attributes keyed by property name.
+ */
+ public static function draft( array $data ): self {
+ return new self( $data );
+ }
+
/**
* Hydrate from a stored row, without validation. Policy columns arrive JSON-decoded.
*
@@ -167,10 +192,14 @@ final class Plan {
* Set the display name.
*
* @param string $name Display name; must not be empty.
- * @throws DomainException If the name is empty.
+ * @throws InvalidInputException If the name is empty.
*/
public function set_name( string $name ): void {
- self::assert_name( $name );
+ $error = self::get_name_error( $name );
+ if ( null !== $error ) {
+ throw $error;
+ }
+
$this->name = $name;
}
@@ -186,14 +215,18 @@ final class Plan {
* unregistered status survives it.
*
* @param string $status Plan status; must be registered.
- * @throws DomainException If the status is not registered.
+ * @throws InvalidInputException If the status is not registered.
*/
public function set_status( string $status ): void {
if ( $status === $this->status ) {
return;
}
- self::assert_status( $status );
+ $error = self::get_status_error( $status );
+ if ( null !== $error ) {
+ throw $error;
+ }
+
$this->status = $status;
}
@@ -217,10 +250,14 @@ final class Plan {
* Replace the billing payload.
*
* @param array<array-key, mixed>|null $billing_policy Billing payload; must be string-keyed.
- * @throws DomainException If the payload is not an object (string-keyed array).
+ * @throws InvalidInputException If the payload is not an object (string-keyed array).
*/
public function set_billing_policy( ?array $billing_policy ): void {
- self::assert_policy( 'billing_policy', $billing_policy );
+ $error = self::get_policy_error( 'billing_policy', $billing_policy );
+ if ( null !== $error ) {
+ throw $error;
+ }
+
$this->billing_policy = Coercion::coerce_nullable_string_keyed( $billing_policy );
}
@@ -237,10 +274,14 @@ final class Plan {
* Replace the pricing payload.
*
* @param array<array-key, mixed>|null $pricing_policy Pricing payload; must be string-keyed.
- * @throws DomainException If the payload is not an object (string-keyed array).
+ * @throws InvalidInputException If the payload is not an object (string-keyed array).
*/
public function set_pricing_policy( ?array $pricing_policy ): void {
- self::assert_policy( 'pricing_policy', $pricing_policy );
+ $error = self::get_policy_error( 'pricing_policy', $pricing_policy );
+ if ( null !== $error ) {
+ throw $error;
+ }
+
$this->pricing_policy = Coercion::coerce_nullable_string_keyed( $pricing_policy );
}
@@ -257,10 +298,14 @@ final class Plan {
* Replace the delivery payload.
*
* @param array<array-key, mixed>|null $delivery_policy Delivery payload; must be string-keyed.
- * @throws DomainException If the payload is not an object (string-keyed array).
+ * @throws InvalidInputException If the payload is not an object (string-keyed array).
*/
public function set_delivery_policy( ?array $delivery_policy ): void {
- self::assert_policy( 'delivery_policy', $delivery_policy );
+ $error = self::get_policy_error( 'delivery_policy', $delivery_policy );
+ if ( null !== $error ) {
+ throw $error;
+ }
+
$this->delivery_policy = Coercion::coerce_nullable_string_keyed( $delivery_policy );
}
@@ -314,59 +359,73 @@ final class Plan {
}
/**
- * Refuse an empty or whitespace-only name.
+ * The error for an empty or whitespace-only name, or null.
*
* @param string $name Name to check.
- * @throws DomainException If `$name` is empty.
*/
- private static function assert_name( string $name ): void {
+ private static function get_name_error( string $name ): ?InvalidInputException {
if ( '' === trim( $name ) ) {
- throw new DomainException( 'Plan: name is required and must be a non-empty string.' );
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required and must be a non-empty string.' );
}
+
+ return null;
}
/**
- * Refuse a status that is not a registered plan status.
+ * The error for a status that is not a registered plan status, or null.
*
* @param string $status Status to check.
- * @throws DomainException If `$status` is not registered.
*/
- private static function assert_status( string $status ): void {
+ private static function get_status_error( string $status ): ?InvalidInputException {
if ( ! PlanStatus::is_registered( $status ) ) {
- throw new DomainException( sprintf( 'Plan: status "%s" is not registered.', $status ) );
+ return new InvalidInputException(
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ sprintf( 'Plan: status "%s" is not registered.', $status ),
+ array( 'value' => $status )
+ );
}
+
+ return null;
}
/**
- * Refuse a missing or empty owning extension slug.
+ * The error for a missing or empty owning extension slug, or null.
*
* @param string|null $extension_slug Extension slug to check.
- * @throws DomainException If `$extension_slug` is null or empty.
*/
- private static function assert_extension_slug( ?string $extension_slug ): void {
+ private static function get_extension_slug_error( ?string $extension_slug ): ?InvalidInputException {
if ( null === $extension_slug || '' === $extension_slug ) {
- throw new DomainException( 'Plan: extension_slug is required and must be a non-empty string.' );
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'extension_slug', 'Plan: extension_slug is required and must be a non-empty string.' );
}
+
+ return null;
}
/**
- * Refuse a policy payload that is not an object or null. Any keyed array is an object,
- * including numeric keys (a JSON `{"123": ...}` decodes to an int key); only a list (keys
- * 0..n-1), which would come back as a JSON array, is refused. An empty array is accepted
- * (a JSON `{}` decodes to it). The same rule for each of the three policies.
+ * The error for a policy payload that is not an object or null, or null. Any keyed
+ * array is an object, including numeric keys (a JSON `{"123": ...}` decodes to an int
+ * key); only a list (keys 0..n-1), which would come back as a JSON array, is refused.
+ * An empty array is accepted (a JSON `{}` decodes to it). One rule for the three policies.
*
- * @param string $field Policy field name, for the error message.
+ * @param string $field Policy field name, reported as the error's field.
* @param mixed $value Candidate payload.
- * @throws DomainException If the value is a non-empty list or not an array.
*/
- private static function assert_policy( string $field, $value ): void {
+ private static function get_policy_error( string $field, $value ): ?InvalidInputException {
if ( null === $value ) {
- return;
+ return null;
}
$is_list = is_array( $value ) && array() !== $value && array_keys( $value ) === range( 0, count( $value ) - 1 );
if ( ! is_array( $value ) || $is_list ) {
- throw new DomainException( sprintf( 'Plan: %s must be an object or null.', $field ) );
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_TYPE,
+ $field,
+ sprintf( 'Plan: %s must be an object or null.', $field ),
+ array( 'type' => 'object' )
+ );
}
+
+ return null;
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ArgumentValidator.php
new file mode 100644
index 00000000000..b20cf12b349
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ArgumentValidator.php
@@ -0,0 +1,611 @@
+<?php
+/**
+ * Argument checks shared by the public facades. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use DateTimeZone;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validate caller argument values and return them normalized, in two forms:
+ * - the static `validate_*()` checks throw {@see InvalidInputException} (a
+ * {@see ValidationErrorCode}, the field and params) for a refused value;
+ * - an instance collects: each `get_*()` runs the matching check, records its errors and
+ * returns the normalized value or null, and {@see self::throw_errors()} throws them all.
+ *
+ * A check stops at a field's first failed rule; a list check reports every failed item.
+ *
+ * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
+ */
+final class ArgumentValidator {
+
+ /**
+ * Errors recorded by the collecting form, in recording order.
+ *
+ * @var list<InvalidInputException>
+ */
+ private $errors = array();
+
+ /**
+ * Record an error found outside the checks.
+ *
+ * @param InvalidInputException $e Validation error.
+ */
+ public function add( InvalidInputException $e ): void {
+ foreach ( $e->get_errors() as $error ) {
+ $this->errors[] = $error;
+ }
+ }
+
+ /**
+ * Throw every recorded error at once; do nothing when nothing failed.
+ *
+ * @throws InvalidInputException If an error was recorded.
+ */
+ public function throw_errors(): void {
+ if ( array() !== $this->errors ) {
+ throw InvalidInputException::from_errors( $this->errors );
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_currency()}: the value, or null when refused.
+ *
+ * @param mixed $value Caller value.
+ */
+ public function get_currency( $value ): ?string {
+ try {
+ return self::validate_currency( $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_string()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_string( string $key, $value ): ?string {
+ try {
+ return self::validate_string( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_non_empty_string()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_non_empty_string( string $key, $value ): ?string {
+ try {
+ return self::validate_non_empty_string( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_nullable_string()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_nullable_string( string $key, $value ): ?string {
+ try {
+ return self::validate_nullable_string( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_nullable_id()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_nullable_id( string $key, $value ): ?int {
+ try {
+ return self::validate_nullable_id( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_positive_int()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_positive_int( string $key, $value ): ?int {
+ try {
+ return self::validate_positive_int( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_non_negative_int()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_non_negative_int( string $key, $value ): ?int {
+ try {
+ return self::validate_non_negative_int( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_id_list()}: the list, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, int>|null
+ */
+ public function get_id_list( string $key, $value ): ?array {
+ try {
+ return self::validate_id_list( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_string_list()}: the list, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, string>|null
+ */
+ public function get_string_list( string $key, $value ): ?array {
+ try {
+ return self::validate_string_list( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_nullable_array()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int|string, mixed>|null
+ */
+ public function get_nullable_array( string $key, $value ): ?array {
+ try {
+ return self::validate_nullable_array( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_nullable_date()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_nullable_date( string $key, $value ): ?string {
+ try {
+ return self::validate_nullable_date( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_money()}: the value, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ */
+ public function get_money( string $key, $value ): ?string {
+ try {
+ return self::validate_money( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_list_of_arrays()}: the rows, or null when refused.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, array<string, mixed>>|null
+ */
+ public function get_list_of_arrays( string $key, $value ): ?array {
+ try {
+ return self::validate_list_of_arrays( $key, $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Collecting {@see self::validate_contract_addresses()}: the addresses, or null when refused.
+ *
+ * @param mixed $value Caller value.
+ * @return array<string, array<int|string, mixed>>|null
+ */
+ public function get_contract_addresses( $value ): ?array {
+ try {
+ return self::validate_contract_addresses( $value );
+ } catch ( InvalidInputException $e ) {
+ $this->add( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Validate and return a currency code, or null.
+ *
+ * @param mixed $value Caller value.
+ * @throws InvalidInputException If the value is not null or a three-letter uppercase code.
+ */
+ public static function validate_currency( $value ): ?string {
+ if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}\z/', $value ) ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_VALUE, 'currency', 'must be null or a three-letter uppercase ISO-4217 code' );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate and return a string.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidInputException If the value is not a string.
+ */
+ public static function validate_string( string $key, $value ): string {
+ if ( ! is_string( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a string', array( 'type' => 'string' ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate and return a non-empty string. Null (a missing argument) and '' are reported
+ * as required.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidInputException If the value is missing, empty or not a string.
+ */
+ public static function validate_non_empty_string( string $key, $value ): string {
+ if ( null === $value || '' === $value ) {
+ throw self::get_invalid_input( ValidationErrorCode::REQUIRED, $key, 'must be a non-empty string' );
+ }
+ if ( ! is_string( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a non-empty string', array( 'type' => 'string' ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate and return a string, or null.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidInputException If the value is not null or a string.
+ */
+ public static function validate_nullable_string( string $key, $value ): ?string {
+ if ( null !== $value && ! is_string( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be null or a string', array( 'type' => 'string' ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate and return a positive integer id (a digit string is cast), or null.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidInputException If the value is not null or a positive integer.
+ */
+ public static function validate_nullable_id( string $key, $value ): ?int {
+ if ( null === $value ) {
+ return null;
+ }
+
+ return self::validate_int_at_least( $key, $value, 1, 'must be null or a positive integer' );
+ }
+
+ /**
+ * Validate and return a positive integer (a digit string is cast).
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidInputException If the value is not a positive integer.
+ */
+ public static function validate_positive_int( string $key, $value ): int {
+ return self::validate_int_at_least( $key, $value, 1, 'must be a positive integer' );
+ }
+
+ /**
+ * Validate and return a non-negative integer (a digit string is cast).
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidInputException If the value is not a non-negative integer.
+ */
+ public static function validate_non_negative_int( string $key, $value ): int {
+ return self::validate_int_at_least( $key, $value, 0, 'must be a non-negative integer' );
+ }
+
+ /**
+ * Validate a list of positive integer ids (digit strings are cast) and return it.
+ * Each failed item is reported by its index (`ids.1`).
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, int>
+ * @throws InvalidInputException If the value is not a list of positive integers.
+ */
+ public static function validate_id_list( string $key, $value ): array {
+ if ( ! self::is_list( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a list of positive integers', array( 'type' => 'array' ) );
+ }
+
+ $items = new self();
+ $ids = array();
+ foreach ( $value as $index => $id ) {
+ $ids[] = (int) $items->get_positive_int( self::get_item_field( $key, $index ), $id );
+ }
+ $items->throw_errors();
+
+ return $ids;
+ }
+
+ /**
+ * Validate a non-empty string or a list of them and return it as a list. Each failed
+ * item is reported by its index (`status.1`).
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, string>
+ * @throws InvalidInputException If the value is not a non-empty string or a list of them.
+ */
+ public static function validate_string_list( string $key, $value ): array {
+ if ( is_string( $value ) ) {
+ return array( self::validate_non_empty_string( $key, $value ) );
+ }
+ if ( ! self::is_list( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a non-empty string or a list of them', array( 'type' => 'array' ) );
+ }
+
+ $items = new self();
+ $strings = array();
+ foreach ( $value as $index => $item ) {
+ $strings[] = (string) $items->get_non_empty_string( self::get_item_field( $key, $index ), $item );
+ }
+ $items->throw_errors();
+
+ return $strings;
+ }
+
+ /**
+ * Validate and return an array, or null.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int|string, mixed>|null
+ * @throws InvalidInputException If the value is not null or an array.
+ */
+ public static function validate_nullable_array( string $key, $value ): ?array {
+ if ( null !== $value && ! is_array( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be null or an array', array( 'type' => 'object' ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate a GMT datetime, or null, and return it as a UTC `Y-m-d H:i:s` string.
+ *
+ * @param string $key Field name.
+ * @param mixed $value `DateTimeInterface`, GMT `Y-m-d H:i:s` string, or null.
+ * @throws InvalidInputException If the value is not a valid datetime.
+ */
+ public static function validate_nullable_date( string $key, $value ): ?string {
+ if ( null === $value ) {
+ return null;
+ }
+
+ if ( $value instanceof DateTimeInterface ) {
+ return ( new DateTimeImmutable( '@' . $value->getTimestamp() ) )->format( 'Y-m-d H:i:s' );
+ }
+
+ if ( is_string( $value ) ) {
+ $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new DateTimeZone( 'UTC' ) );
+ if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $value ) {
+ return $value;
+ }
+ }
+
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_VALUE, $key, 'must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string' );
+ }
+
+ /**
+ * Validate a money value and return it normalized to the storage scale; null is 0.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Number, numeric string, or null.
+ * @throws InvalidInputException If the value is not numeric.
+ */
+ public static function validate_money( string $key, $value ): string {
+ if ( null === $value ) {
+ return MoneyScale::normalize_money( 0 );
+ }
+
+ if ( ! is_int( $value ) && ! is_float( $value ) && ! ( is_string( $value ) && is_numeric( $value ) ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a number or a numeric string', array( 'type' => 'number' ) );
+ }
+
+ return MoneyScale::normalize_money( $value );
+ }
+
+ /**
+ * Validate a list of arrays and return it with string-keyed rows. Each failed row is
+ * reported by its index (`items.0`).
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, array<string, mixed>>
+ * @throws InvalidInputException If the value is not a list of arrays.
+ */
+ public static function validate_list_of_arrays( string $key, $value ): array {
+ if ( ! self::is_list( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a list of arrays', array( 'type' => 'array' ) );
+ }
+
+ $items = new self();
+ $rows = array();
+ foreach ( $value as $index => $row ) {
+ if ( ! is_array( $row ) ) {
+ $items->add( self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, self::get_item_field( $key, $index ), 'must be an array', array( 'type' => 'object' ) ) );
+ continue;
+ }
+ $rows[] = Coercion::coerce_string_keyed( $row );
+ }
+ $items->throw_errors();
+
+ return $rows;
+ }
+
+ /**
+ * Validate the shape of contract addresses: a map keyed `billing` / `shipping` with
+ * array values. Each failed address is reported by its type (`addresses.billing`).
+ *
+ * @param mixed $value Caller value.
+ * @return array<string, array<int|string, mixed>>
+ * @throws InvalidInputException If the value is not such a map.
+ */
+ public static function validate_contract_addresses( $value ): array {
+ if ( ! is_array( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, 'addresses', 'must be an array keyed "billing" / "shipping"', array( 'type' => 'object' ) );
+ }
+
+ $types = array( Contract::ADDRESS_BILLING, Contract::ADDRESS_SHIPPING );
+ $items = new self();
+ $addresses = array();
+ foreach ( $value as $type => $address ) {
+ $field = self::get_item_field( 'addresses', $type );
+ if ( ! in_array( $type, $types, true ) ) {
+ $items->add( self::get_invalid_input( ValidationErrorCode::NOT_IN_ENUM, $field, 'is not an address type; use "billing" or "shipping"', array( 'allowed' => $types ) ) );
+ } elseif ( ! is_array( $address ) ) {
+ $items->add( self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $field, 'must be an array', array( 'type' => 'object' ) ) );
+ } else {
+ $addresses[ $type ] = $address;
+ }
+ }
+ $items->throw_errors();
+
+ return $addresses;
+ }
+
+ /**
+ * Validate an integer (a digit string is cast) of at least `$min`.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @param int $min Lowest allowed value.
+ * @param string $requirement What the value must be, for the message.
+ * @throws InvalidInputException If the value is not an integer, or is below `$min`.
+ */
+ private static function validate_int_at_least( string $key, $value, int $min, string $requirement ): int {
+ $value = self::cast_digit_string( $value );
+ if ( ! is_int( $value ) ) {
+ throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, $requirement, array( 'type' => 'integer' ) );
+ }
+ if ( $value < $min ) {
+ throw self::get_invalid_input( ValidationErrorCode::OUT_OF_BOUNDS, $key, $requirement, array( 'min' => $min ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Build the validation error for a refused value; the message quotes the field.
+ *
+ * @param string $code Error code.
+ * @param string $field Field, in dot notation.
+ * @param string $requirement What the value must be, for the message.
+ * @param array<string, mixed> $params Message values.
+ */
+ private static function get_invalid_input( string $code, string $field, string $requirement, array $params = array() ): InvalidInputException {
+ return new InvalidInputException( $code, $field, sprintf( '"%s" %s.', $field, $requirement ), $params );
+ }
+
+ /**
+ * Dot path of a list item or map entry.
+ *
+ * @param string $key Field name.
+ * @param int|string $index Item index or map key.
+ */
+ private static function get_item_field( string $key, $index ): string {
+ return $key . '.' . $index;
+ }
+
+ /**
+ * Whether a value is a list: an array with consecutive int keys from 0 (an empty array is one).
+ *
+ * @param mixed $value Caller value.
+ * @phpstan-assert-if-true array<int, mixed> $value
+ */
+ private static function is_list( $value ): bool {
+ return is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) );
+ }
+
+ /**
+ * Cast a digit string to an int; any other value is returned unchanged.
+ *
+ * @param mixed $value Caller value.
+ * @return mixed
+ */
+ private static function cast_digit_string( $value ) {
+ if ( is_string( $value ) && ctype_digit( $value ) ) {
+ return (int) $value;
+ }
+
+ return $value;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/InvalidInputException.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/InvalidInputException.php
new file mode 100644
index 00000000000..b1b977ac0d7
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/InvalidInputException.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * InvalidInputException - a refused input value, reported as a stable error code,
+ * the field it concerns and the values a message needs. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+use DomainException;
+use LogicException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validation error thrown by Core checks, entities and value objects.
+ *
+ * Carries one or more errors. Each has a code (one of {@see ValidationErrorCode}), a
+ * field as a dot path (`name`, `trial_duration.length`, `items.2`) and the params the
+ * code's message needs; {@see self::get_errors()} lists them, and the getters return the
+ * first one's. The message is an English developer message, for logs.
+ */
+final class InvalidInputException extends DomainException {
+
+ /**
+ * Stable error code.
+ *
+ * @var string
+ */
+ private $error_code;
+
+ /**
+ * Field the error concerns, in dot notation.
+ *
+ * @var string
+ */
+ private $field;
+
+ /**
+ * Values the code's message needs, keyed as documented on the code.
+ *
+ * @var array<string, mixed>
+ */
+ private $params;
+
+ /**
+ * Every single error, when built from several; empty for a single error.
+ *
+ * @var list<InvalidInputException>
+ */
+ private $errors = array();
+
+ /**
+ * Build the exception.
+ *
+ * @param string $error_code Stable error code, one of {@see ValidationErrorCode}.
+ * @param string $field Field the error concerns, in dot notation.
+ * @param string $message English developer message.
+ * @param array<string, mixed> $params Values the code's message needs.
+ */
+ public function __construct( string $error_code, string $field, string $message, array $params = array() ) {
+ parent::__construct( $message );
+ $this->error_code = $error_code;
+ $this->field = $field;
+ $this->params = $params;
+ }
+
+ /**
+ * Stable error code, one of {@see ValidationErrorCode}.
+ */
+ public function get_error_code(): string {
+ return $this->error_code;
+ }
+
+ /**
+ * Field the error concerns, in dot notation.
+ */
+ public function get_field(): string {
+ return $this->field;
+ }
+
+ /**
+ * Values the code's message needs.
+ *
+ * @return array<string, mixed>
+ */
+ public function get_params(): array {
+ return $this->params;
+ }
+
+ /**
+ * Combine errors into one exception. Each element's errors are flattened in, in order;
+ * a single resulting error is returned as is. Several keep the first one's code, field
+ * and params, and join the English messages.
+ *
+ * @param array<int, InvalidInputException> $errors Errors, at least one.
+ * @phpstan-param list<InvalidInputException> $errors
+ * @throws LogicException If `$errors` is empty (an engine bug, not input).
+ */
+ public static function from_errors( array $errors ): self {
+ $flattened = array();
+ foreach ( $errors as $error ) {
+ foreach ( $error->get_errors() as $single ) {
+ $flattened[] = $single;
+ }
+ }
+
+ if ( array() === $flattened ) {
+ throw new LogicException( 'InvalidInputException::from_errors() needs at least one error.' );
+ }
+ if ( 1 === count( $flattened ) ) {
+ return $flattened[0];
+ }
+
+ $messages = array();
+ foreach ( $flattened as $single ) {
+ $messages[] = $single->getMessage();
+ }
+
+ $first = $flattened[0];
+ $combined = new self( $first->get_error_code(), $first->get_field(), implode( ' ', $messages ), $first->get_params() );
+ $combined->errors = $flattened;
+
+ return $combined;
+ }
+
+ /**
+ * Every single error, in order: this exception itself when it carries one.
+ *
+ * @return array<int, InvalidInputException>
+ * @phpstan-return non-empty-list<InvalidInputException>
+ */
+ public function get_errors(): array {
+ return array() === $this->errors ? array( $this ) : $this->errors;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ValidationErrorCode.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ValidationErrorCode.php
new file mode 100644
index 00000000000..f4a8a6282e3
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ValidationErrorCode.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * ValidationErrorCode - the stable reasons a validation error reports.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validation error codes, named after WordPress REST's validation reasons.
+ *
+ * A code says why a value was refused and the error's field says where. Once
+ * released, a check's code and field are frozen and its params keys are
+ * additive-only: a more precise error becomes a new check with a new code.
+ */
+final class ValidationErrorCode {
+
+ /**
+ * A value is missing or empty. No params.
+ */
+ public const REQUIRED = 'required';
+
+ /**
+ * A value has the wrong type. Params: `type`, a JSON-schema type
+ * (`string`, `integer`, `number`, `boolean`, `array`, `object`).
+ */
+ public const INVALID_TYPE = 'invalid_type';
+
+ /**
+ * A number is outside its bounds. Params: `min` and/or `max` (int).
+ */
+ public const OUT_OF_BOUNDS = 'out_of_bounds';
+
+ /**
+ * A value is not one of a fixed set. Params: `allowed` (list of strings).
+ */
+ public const NOT_IN_ENUM = 'not_in_enum';
+
+ /**
+ * A well-typed value is malformed or forbidden. No params.
+ */
+ public const INVALID_VALUE = 'invalid_value';
+
+ /**
+ * A status is not registered. Params: `value` (the refused status).
+ */
+ public const NOT_REGISTERED = 'not_registered';
+
+ /**
+ * Constants only.
+ */
+ private function __construct() {}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
index 41ee494bdd1..5ee5a6f9cfc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -4,8 +4,8 @@
*
* An extension may use {@see self::from_array()} to parse its plan billing arrays and
* compute due moments from them. The engine stores plan policies opaquely and does not
- * read them itself. A policy always has a usable cadence: construction refuses an unknown
- * period or a non-positive interval. The array shape it parses:
+ * read them itself. A policy always has a usable cadence and trial: construction refuses an
+ * unknown period or trial unit and a non-positive interval or trial length. The array shape it parses:
* {
* period: 'day' | 'week' | 'month' | 'year',
* interval: int,
@@ -26,8 +26,9 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
use DateTimeImmutable;
use DateTimeZone;
-use DomainException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
defined( 'ABSPATH' ) || exit;
@@ -75,69 +76,77 @@ final class BillingPolicy {
private $trial_duration;
/**
- * Build a billing policy.
+ * Build a billing policy. Every invalid value is reported in one exception.
*
* @param string $period One of 'day' | 'week' | 'month' | 'year'.
* @param int $interval Period count.
* @param int|null $min_cycles Minimum cycles before cancellation is allowed.
* @param int|null $max_cycles Total cycles before the contract ends.
* @param array{length: int, unit: string}|null $trial_duration Native trial; null if none.
- * @throws DomainException If the period is unknown, the interval is not positive, or the cycle bounds are invalid.
+ * @throws InvalidInputException If the period is unknown, the interval is not positive, the cycle bounds or the trial are invalid.
*/
public function __construct( string $period, int $interval, ?int $min_cycles, ?int $max_cycles, ?array $trial_duration ) {
- if ( $interval <= 0 ) {
- throw new DomainException(
- sprintf( 'BillingPolicy: interval must be positive, got %d.', $interval )
- );
- }
- $this->normalize_unit( $period, 'period' );
- $this->validate_min_max_cycles( $min_cycles, $max_cycles );
+ self::throw_found_errors(
+ array_merge(
+ array(
+ self::get_period_error( $period ),
+ self::get_interval_error( $interval ),
+ ),
+ self::get_cycle_errors( $min_cycles, $max_cycles ),
+ self::get_trial_errors( $trial_duration )
+ )
+ );
$this->period = $period;
$this->interval = $interval;
$this->min_cycles = $min_cycles;
$this->max_cycles = $max_cycles;
- $this->trial_duration = self::normalize_trial_duration( $trial_duration );
+ $this->trial_duration = null === $trial_duration ? null : array(
+ 'length' => (int) $trial_duration['length'],
+ 'unit' => (string) $trial_duration['unit'],
+ );
}
/**
* Hydrate from the JSON-decoded `billing_policy` column shape.
*
* Missing nullable keys default to null. `period` and `interval` are required, and
- * must form a usable cadence (see the constructor).
+ * must form a usable cadence (see the constructor). Every invalid value is reported
+ * in one exception.
*
* @param array<string, mixed> $data Decoded billing_policy row.
- * @throws DomainException If the data is not valid.
+ * @throws InvalidInputException If the data is not valid.
*/
public static function from_array( array $data ): self {
- if ( ! array_key_exists( 'period', $data ) ) {
- throw new DomainException( 'BillingPolicy: period is required, but not supplied.' );
- }
- if ( ! array_key_exists( 'interval', $data ) ) {
- throw new DomainException( 'BillingPolicy: interval is required, but not supplied.' );
- }
- if ( ! is_string( $data['period'] ) ) {
- throw new DomainException( 'BillingPolicy: period must be a string, got ' . gettype( $data['period'] ) . '.' );
- }
- if ( ! is_int( $data['interval'] ) ) {
- throw new DomainException( 'BillingPolicy: interval must be an integer, got ' . gettype( $data['interval'] ) . '.' );
- }
-
- $trial = $data['trial_duration'] ?? null;
- if ( null !== $trial && ! is_array( $trial ) ) {
- throw new DomainException(
- sprintf( 'BillingPolicy: trial_duration must be null or an array, got %s.', gettype( $trial ) )
- );
- }
-
- $trial = self::normalize_trial_duration( $trial );
+ $min_cycles = Coercion::coerce_nullable_int( $data['min_cycles'] ?? null );
+ $max_cycles = Coercion::coerce_nullable_int( $data['max_cycles'] ?? null );
+ $trial = $data['trial_duration'] ?? null;
+
+ self::throw_found_errors(
+ array_merge(
+ array(
+ array_key_exists( 'period', $data )
+ ? self::get_period_error( $data['period'] )
+ : new InvalidInputException( ValidationErrorCode::REQUIRED, 'period', 'BillingPolicy: period is required, but not supplied.' ),
+ array_key_exists( 'interval', $data )
+ ? self::get_interval_error( $data['interval'] )
+ : new InvalidInputException( ValidationErrorCode::REQUIRED, 'interval', 'BillingPolicy: interval is required, but not supplied.' ),
+ ),
+ self::get_cycle_errors( $min_cycles, $max_cycles ),
+ array( self::get_trial_duration_error( $trial ) ),
+ is_array( $trial ) ? self::get_trial_errors( $trial ) : array()
+ )
+ );
return new self(
- (string) $data['period'],
- (int) $data['interval'],
- Coercion::coerce_nullable_int( $data['min_cycles'] ?? null ),
- Coercion::coerce_nullable_int( $data['max_cycles'] ?? null ),
- $trial
+ Coercion::coerce_string( $data['period'] ),
+ Coercion::coerce_int( $data['interval'] ),
+ $min_cycles,
+ $max_cycles,
+ is_array( $trial ) ? array(
+ 'length' => Coercion::coerce_int( $trial['length'] ?? null ),
+ 'unit' => Coercion::coerce_string( $trial['unit'] ?? null ),
+ ) : null
);
}
@@ -203,27 +212,15 @@ final class BillingPolicy {
*
* @param DateTimeImmutable $contract_start Moment the contract was created.
* @return DateTimeImmutable The first renewal moment in UTC.
- * @throws DomainException If the trial length is not positive or the trial unit is unknown.
*/
public function compute_first_renewal_from( DateTimeImmutable $contract_start ): DateTimeImmutable {
if ( null === $this->trial_duration ) {
return $this->compute_next_renewal_from( $contract_start );
}
- $length = (int) $this->trial_duration['length'];
- $unit = (string) $this->trial_duration['unit'];
-
- if ( $length <= 0 ) {
- throw new DomainException(
- sprintf( 'BillingPolicy::compute_first_renewal_from(): trial length must be positive, got %d.', $length )
- );
- }
-
- $normalized_unit = $this->normalize_unit( $unit, 'trial unit' );
-
return $contract_start
->setTimezone( new DateTimeZone( 'UTC' ) )
- ->modify( sprintf( '+%d %s', $length, $normalized_unit ) );
+ ->modify( sprintf( '+%d %s', $this->trial_duration['length'], $this->trial_duration['unit'] ) );
}
/**
@@ -242,81 +239,234 @@ final class BillingPolicy {
}
/**
- * Validate and pass through a period/trial unit.
+ * The error for a unit that is not one of day/week/month/year, or null.
*
* @param string $unit The raw unit.
- * @param string $label Where the unit came from, for the error message.
- * @throws DomainException If the unit is not one of day/week/month/year.
+ * @param string $field Field the unit came from, in dot notation.
*/
- private function normalize_unit( string $unit, string $label ): string {
- if ( ! in_array( $unit, array( 'day', 'week', 'month', 'year' ), true ) ) {
- throw new DomainException(
- sprintf( 'BillingPolicy: invalid %s "%s".', $label, $unit )
+ private static function get_unit_error( string $unit, string $field ): ?InvalidInputException {
+ $allowed = array( 'day', 'week', 'month', 'year' );
+ if ( ! in_array( $unit, $allowed, true ) ) {
+ return new InvalidInputException(
+ ValidationErrorCode::NOT_IN_ENUM,
+ $field,
+ sprintf( 'BillingPolicy: invalid %s "%s".', $field, $unit ),
+ array( 'allowed' => $allowed )
);
}
- return $unit;
+ return null;
}
/**
- * Validate the min_cycles and max_cycles fields.
+ * Throw the non-null errors at once; do nothing when there are none.
+ *
+ * @param array<int, InvalidInputException|null> $errors Errors, null where a check passed.
+ * @throws InvalidInputException If an error is present.
+ */
+ private static function throw_found_errors( array $errors ): void {
+ $errors = array_values( array_filter( $errors ) );
+ if ( array() !== $errors ) {
+ throw InvalidInputException::from_errors( $errors );
+ }
+ }
+
+ /**
+ * The error for a period that is not a string or not a known unit, or null.
+ *
+ * @param mixed $period Candidate period.
+ */
+ private static function get_period_error( $period ): ?InvalidInputException {
+ if ( ! is_string( $period ) ) {
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_TYPE,
+ 'period',
+ 'BillingPolicy: period must be a string, got ' . gettype( $period ) . '.',
+ array( 'type' => 'string' )
+ );
+ }
+
+ return self::get_unit_error( $period, 'period' );
+ }
+
+ /**
+ * The error for an interval that is not a positive integer, or null.
+ *
+ * @param mixed $interval Candidate interval.
+ */
+ private static function get_interval_error( $interval ): ?InvalidInputException {
+ if ( ! is_int( $interval ) ) {
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_TYPE,
+ 'interval',
+ 'BillingPolicy: interval must be an integer, got ' . gettype( $interval ) . '.',
+ array( 'type' => 'integer' )
+ );
+ }
+ if ( $interval <= 0 ) {
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'interval',
+ sprintf( 'BillingPolicy: interval must be positive, got %d.', $interval ),
+ array( 'min' => 1 )
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * The cycle bound errors: each bound's own, then `min_cycles` above `max_cycles` only
+ * when both passed theirs.
*
* @param int|null $min_cycles Minimum cycles before cancellation is allowed.
* @param int|null $max_cycles Total cycles before the contract ends.
- * @throws DomainException If min_cycles or max_cycles are not valid.
+ * @return array<int, InvalidInputException|null>
*/
- private function validate_min_max_cycles( ?int $min_cycles, ?int $max_cycles ): void {
+ private static function get_cycle_errors( ?int $min_cycles, ?int $max_cycles ): array {
+ $min_error = self::get_min_cycles_error( $min_cycles );
+ $max_error = self::get_max_cycles_error( $max_cycles );
+ if ( null !== $min_error || null !== $max_error ) {
+ return array( $min_error, $max_error );
+ }
+
+ return array( self::get_cycle_bounds_error( $min_cycles, $max_cycles ) );
+ }
+
+ /**
+ * The error for a negative `min_cycles`, or null.
+ *
+ * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
+ */
+ private static function get_min_cycles_error( ?int $min_cycles ): ?InvalidInputException {
if ( null !== $min_cycles && $min_cycles < 0 ) {
- throw new DomainException(
- sprintf( 'BillingPolicy: min_cycles must be 0 or greater, got %d.', $min_cycles )
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'min_cycles',
+ sprintf( 'BillingPolicy: min_cycles must be 0 or greater, got %d.', $min_cycles ),
+ array( 'min' => 0 )
);
}
+ return null;
+ }
+
+ /**
+ * The error for a negative `max_cycles`, or null.
+ *
+ * @param int|null $max_cycles Total cycles before the contract ends.
+ */
+ private static function get_max_cycles_error( ?int $max_cycles ): ?InvalidInputException {
if ( null !== $max_cycles && $max_cycles < 0 ) {
- throw new DomainException(
- sprintf( 'BillingPolicy: max_cycles must be 0 or greater, got %d.', $max_cycles )
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'max_cycles',
+ sprintf( 'BillingPolicy: max_cycles must be 0 or greater, got %d.', $max_cycles ),
+ array( 'min' => 0 )
);
}
+ return null;
+ }
+
+ /**
+ * The error for `min_cycles` above `max_cycles`, or null.
+ *
+ * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
+ * @param int|null $max_cycles Total cycles before the contract ends.
+ */
+ private static function get_cycle_bounds_error( ?int $min_cycles, ?int $max_cycles ): ?InvalidInputException {
if ( null !== $min_cycles && null !== $max_cycles && $min_cycles > $max_cycles ) {
- throw new DomainException(
- sprintf( 'BillingPolicy: min_cycles cannot exceed max_cycles, got %d and %d.', $min_cycles, $max_cycles )
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'min_cycles',
+ sprintf( 'BillingPolicy: min_cycles cannot exceed max_cycles, got %d and %d.', $min_cycles, $max_cycles ),
+ array( 'max' => $max_cycles )
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * The error for a trial duration that is neither null nor an array, or null.
+ *
+ * @param mixed $trial_duration Candidate trial duration.
+ */
+ private static function get_trial_duration_error( $trial_duration ): ?InvalidInputException {
+ if ( null !== $trial_duration && ! is_array( $trial_duration ) ) {
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_TYPE,
+ 'trial_duration',
+ sprintf( 'BillingPolicy: trial_duration must be null or an array, got %s.', gettype( $trial_duration ) ),
+ array( 'type' => 'object' )
);
}
+
+ return null;
}
/**
- * Normalize the trial duration.
+ * The trial errors: the length's, then the unit's. None for no trial.
*
- * @param array<array-key, mixed>|null $trial_duration The trial duration.
- * @return array{length: int, unit: string}|null The normalized trial duration.
- * @throws DomainException If the trial duration is not valid.
+ * @param array<array-key, mixed>|null $trial_duration Trial duration.
+ * @return array<int, InvalidInputException|null>
*/
- private static function normalize_trial_duration( ?array $trial_duration ): ?array {
+ private static function get_trial_errors( ?array $trial_duration ): array {
if ( null === $trial_duration ) {
- return null;
+ return array();
}
+ return array( self::get_trial_length_error( $trial_duration ), self::get_trial_unit_error( $trial_duration ) );
+ }
+
+ /**
+ * The error for a missing, non-integer or non-positive trial length, or null.
+ *
+ * @param array<array-key, mixed> $trial_duration Trial duration.
+ */
+ private static function get_trial_length_error( array $trial_duration ): ?InvalidInputException {
if ( ! array_key_exists( 'length', $trial_duration ) ) {
- throw new DomainException( "BillingPolicy: trial_duration['length'] is required." );
- }
- if ( ! array_key_exists( 'unit', $trial_duration ) ) {
- throw new DomainException( "BillingPolicy: trial_duration['unit'] is required." );
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'trial_duration.length', 'BillingPolicy: trial_duration.length is required.' );
}
if ( ! is_int( $trial_duration['length'] ) ) {
- throw new DomainException(
- sprintf( "BillingPolicy: trial_duration['length'] must be an integer, got %s.", gettype( $trial_duration['length'] ) )
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_TYPE,
+ 'trial_duration.length',
+ sprintf( 'BillingPolicy: trial_duration.length must be an integer, got %s.', gettype( $trial_duration['length'] ) ),
+ array( 'type' => 'integer' )
+ );
+ }
+ if ( $trial_duration['length'] <= 0 ) {
+ return new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'trial_duration.length',
+ sprintf( 'BillingPolicy: trial_duration.length must be positive, got %d.', $trial_duration['length'] ),
+ array( 'min' => 1 )
);
}
+
+ return null;
+ }
+
+ /**
+ * The error for a missing, non-string or unknown trial unit, or null.
+ *
+ * @param array<array-key, mixed> $trial_duration Trial duration.
+ */
+ private static function get_trial_unit_error( array $trial_duration ): ?InvalidInputException {
+ if ( ! array_key_exists( 'unit', $trial_duration ) ) {
+ return new InvalidInputException( ValidationErrorCode::REQUIRED, 'trial_duration.unit', 'BillingPolicy: trial_duration.unit is required.' );
+ }
if ( ! is_string( $trial_duration['unit'] ) ) {
- throw new DomainException(
- sprintf( "BillingPolicy: trial_duration['unit'] must be a string, got %s.", gettype( $trial_duration['unit'] ) )
+ return new InvalidInputException(
+ ValidationErrorCode::INVALID_TYPE,
+ 'trial_duration.unit',
+ sprintf( 'BillingPolicy: trial_duration.unit must be a string, got %s.', gettype( $trial_duration['unit'] ) ),
+ array( 'type' => 'string' )
);
}
- return array(
- 'length' => (int) $trial_duration['length'],
- 'unit' => (string) $trial_duration['unit'],
- );
+ return self::get_unit_error( $trial_duration['unit'], 'trial_duration.unit' );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
index 1c96d8841d4..062b6a32b73 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
@@ -15,7 +15,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
-use DomainException;
+use LogicException;
defined( 'ABSPATH' ) || exit;
@@ -46,11 +46,11 @@ final class ItemsSnapshot {
*
* @param array<int, array<string, mixed>> $items Ordered line items.
* @param int $schema_version Payload-format version.
- * @throws DomainException If the schema version is not positive.
+ * @throws LogicException If the schema version is not positive.
*/
private function __construct( array $items, int $schema_version ) {
if ( $schema_version < 1 ) {
- throw new DomainException(
+ throw new LogicException(
sprintf( 'ItemsSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
);
}
@@ -64,7 +64,7 @@ final class ItemsSnapshot {
*
* @param array<int, array<string, mixed>> $items Ordered line items.
* @param int $schema_version Payload-format version. Defaults to 1.
- * @throws DomainException If the schema version is not positive.
+ * @throws LogicException If the schema version is not positive.
*/
public static function from_items( array $items, int $schema_version = 1 ): self {
return new self( $items, $schema_version );
@@ -76,7 +76,7 @@ final class ItemsSnapshot {
*
* @param array<int, array<string, mixed>> $payload Serialized ordered line items.
* @param int $schema_version Payload-format version the payload was written in.
- * @throws DomainException If the schema version is not positive.
+ * @throws LogicException If the schema version is not positive.
*/
public static function from_payload( array $payload, int $schema_version = 1 ): self {
return new self( $payload, $schema_version );
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
index c157de3a275..5f4b4a3f766 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -16,7 +16,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
-use DomainException;
+use LogicException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
defined( 'ABSPATH' ) || exit;
@@ -49,11 +50,11 @@ final class PlanSnapshot {
*
* @param array<string, mixed> $data Plan terms payload.
* @param int $schema_version Payload-format version.
- * @throws DomainException If the schema version is not positive.
+ * @throws LogicException If the schema version is not positive.
*/
private function __construct( array $data, int $schema_version ) {
if ( $schema_version < 1 ) {
- throw new DomainException(
+ throw new LogicException(
sprintf( 'PlanSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
);
}
@@ -67,7 +68,7 @@ final class PlanSnapshot {
*
* @param array<string, mixed> $data Plan terms payload.
* @param int $schema_version Payload-format version. Defaults to 1.
- * @throws DomainException If the schema version is not positive.
+ * @throws LogicException If the schema version is not positive.
*/
public static function from_array( array $data, int $schema_version = 1 ): self {
return new self( $data, $schema_version );
@@ -79,7 +80,7 @@ final class PlanSnapshot {
*
* @param array<string, mixed> $payload Serialized plan terms payload.
* @param int $schema_version Payload-format version the payload was written in.
- * @throws DomainException If the schema version is not positive.
+ * @throws LogicException If the schema version is not positive.
*/
public static function from_payload( array $payload, int $schema_version = 1 ): self {
return new self( $payload, $schema_version );
@@ -106,7 +107,7 @@ final class PlanSnapshot {
* policy array; throws when one is present but unusable, so a caller can log why
* before falling back.
*
- * @throws DomainException If the stored policy does not parse or has no usable cadence.
+ * @throws InvalidInputException If the stored policy does not parse or has no usable cadence.
*/
public function read_billing_policy(): ?BillingPolicy {
$policy = $this->data['billing_policy'] ?? null;
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
index 4099cf2da24..77a13162c1a 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
@@ -18,7 +18,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;
defined( 'ABSPATH' ) || exit;
@@ -72,7 +72,7 @@ final class ConsumerRegistry {
return;
}
- $filtered_config = ArgumentValidator::filter_known_keys( __METHOD__, $config, self::CONFIG_KEYS );
+ $filtered_config = ArgumentFilter::filter_known_keys( __METHOD__, $config, self::CONFIG_KEYS );
$registration = array();
if ( array_key_exists( 'contract_due_handler', $filtered_config ) ) {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
index c6e8f66ba87..04e2f66a0de 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -32,6 +32,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
@@ -1732,7 +1734,7 @@ final class ContractRepository {
* @param array<string, mixed> $changes Optional `order_id` (?int), `reason` (?string); present keys are written, null clears.
* @param ContractLock $lock The caller's lock on the cycle's contract.
* @return bool True when this caller won the transition (for a same-status call: the cycle holds the status and the values).
- * @throws \DomainException If `$to_status` is not a registered cycle status.
+ * @throws InvalidInputException If `$to_status` is not a registered cycle status.
* @throws \InvalidArgumentException If the cycle does not exist, or belongs to another contract than `$lock`.
* @throws ContractLockedException If `$lock` was lost.
* @throws \RuntimeException If the write fails.
@@ -1741,7 +1743,13 @@ final class ContractRepository {
global $wpdb;
if ( ! CycleStatus::is_registered( $to_status ) ) {
- throw new \DomainException( esc_html( sprintf( 'ContractRepository::transition_cycle(): cycle status "%s" is not registered.', $to_status ) ) );
+ $error = new InvalidInputException(
+ ValidationErrorCode::NOT_REGISTERED,
+ 'to_status',
+ sprintf( 'ContractRepository::transition_cycle(): cycle status "%s" is not registered.', $to_status ),
+ array( 'value' => $to_status )
+ );
+ throw $error; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the message is not output; the facade escapes it.
}
// The contract match is in the UPDATE's WHERE clause: this method knows only the cycle id.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentFilter.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentFilter.php
new file mode 100644
index 00000000000..57ed95c15ee
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentFilter.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Unknown-key filtering shared by the public facades.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Drop unknown argument keys with a `_doing_it_wrong()` notice. Values are checked by
+ * {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator}.
+ *
+ * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
+ */
+final class ArgumentFilter {
+
+ /**
+ * Static helper only.
+ */
+ private function __construct() {}
+
+ /**
+ * Keep the keys in `$allowed`; each other key raises a `_doing_it_wrong()` notice and is dropped.
+ *
+ * @param string $method Public facade method, for the notice.
+ * @param array<int|string, mixed> $args Caller arguments.
+ * @param array<string, true> $allowed Accepted keys, as a key map.
+ * @param string $what What the keys belong to, for the notice.
+ * @return array<string, mixed> The arguments with known keys only.
+ */
+ public static function filter_known_keys( string $method, array $args, array $allowed, string $what = 'key' ): array {
+ $filtered = array();
+ foreach ( $args as $key => $value ) {
+ if ( isset( $allowed[ $key ] ) ) {
+ $filtered[ (string) $key ] = $value;
+ continue;
+ }
+
+ _doing_it_wrong(
+ esc_html( $method ),
+ sprintf( 'Unknown %s "%s" ignored.', esc_html( $what ), esc_html( (string) $key ) ),
+ '0.0.1'
+ );
+ }
+
+ return $filtered;
+ }
+
+ /**
+ * Apply {@see self::filter_known_keys()} to each row of a collection, keeping the row keys.
+ *
+ * @template TKey of array-key
+ * @param string $method Public facade method, for the notice.
+ * @param array<TKey, array<int|string, mixed>> $rows Rows, shape already checked.
+ * @param array<string, true> $allowed Accepted row keys, as a key map.
+ * @param string $what What the keys belong to, for the notice.
+ * @return array<TKey, array<string, mixed>>
+ */
+ public static function filter_known_keys_in_rows( string $method, array $rows, array $allowed, string $what = 'key' ): array {
+ $filtered = array();
+ foreach ( $rows as $index => $row ) {
+ $filtered[ $index ] = self::filter_known_keys( $method, $row, $allowed, $what );
+ }
+
+ return $filtered;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
deleted file mode 100644
index 22b17374ad5..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
+++ /dev/null
@@ -1,354 +0,0 @@
-<?php
-/**
- * Argument validators shared by the public facades.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
-
-use DateTimeImmutable;
-use DateTimeInterface;
-use DateTimeZone;
-use InvalidArgumentException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Validate caller argument values and return them normalized.
- *
- * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
- */
-final class ArgumentValidator {
-
- /**
- * Keep the keys in `$allowed`; each other key raises a `_doing_it_wrong()` notice and is dropped.
- *
- * @param string $method Public facade method, for the notice.
- * @param array<int|string, mixed> $args Caller arguments.
- * @param array<string, true> $allowed Accepted keys, as a key map.
- * @param string $what What the keys belong to, for the notice.
- * @return array<string, mixed> The arguments with known keys only.
- */
- public static function filter_known_keys( string $method, array $args, array $allowed, string $what = 'key' ): array {
- $filtered = array();
- foreach ( $args as $key => $value ) {
- if ( isset( $allowed[ $key ] ) ) {
- $filtered[ (string) $key ] = $value;
- continue;
- }
-
- _doing_it_wrong(
- esc_html( $method ),
- sprintf( 'Unknown %s "%s" ignored.', esc_html( $what ), esc_html( (string) $key ) ),
- '0.0.1'
- );
- }
-
- return $filtered;
- }
-
- /**
- * Validate and return a currency code, or null.
- *
- * @param mixed $value Caller value.
- * @throws InvalidArgumentException If the value is not null or a three-letter uppercase code.
- */
- public static function validate_currency( $value ): ?string {
- if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}\z/', $value ) ) ) {
- throw new InvalidArgumentException( '"currency" must be null or a three-letter uppercase ISO-4217 code.' );
- }
-
- return $value;
- }
-
- /**
- * Validate and return a string.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @throws InvalidArgumentException If the value is not a string.
- */
- public static function validate_string( string $key, $value ): string {
- if ( ! is_string( $value ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a string.', esc_html( $key ) ) );
- }
-
- return $value;
- }
-
- /**
- * Validate and return a non-empty string.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @throws InvalidArgumentException If the value is not a non-empty string.
- */
- public static function validate_non_empty_string( string $key, $value ): string {
- if ( ! is_string( $value ) || '' === $value ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string.', esc_html( $key ) ) );
- }
-
- return $value;
- }
-
- /**
- * Validate and return a string, or null.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @throws InvalidArgumentException If the value is not null or a string.
- */
- public static function validate_nullable_string( string $key, $value ): ?string {
- if ( null !== $value && ! is_string( $value ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be null or a string.', esc_html( $key ) ) );
- }
-
- return $value;
- }
-
- /**
- * Validate and return a positive integer id (a digit string is cast), or null.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @throws InvalidArgumentException If the value is not null or a positive integer.
- */
- public static function validate_nullable_id( string $key, $value ): ?int {
- if ( null === $value ) {
- return null;
- }
-
- $value = self::cast_digit_string( $value );
- if ( ! is_int( $value ) || $value <= 0 ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be null or a positive integer.', esc_html( $key ) ) );
- }
-
- return $value;
- }
-
- /**
- * Validate and return a non-negative integer (a digit string is cast).
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @throws InvalidArgumentException If the value is not a non-negative integer.
- */
- public static function validate_non_negative_int( string $key, $value ): int {
- $value = self::cast_digit_string( $value );
- if ( ! is_int( $value ) || $value < 0 ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a non-negative integer.', esc_html( $key ) ) );
- }
-
- return $value;
- }
-
- /**
- * Validate a list of positive integer ids (digit strings are cast) and return it.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @return array<int, int>
- * @throws InvalidArgumentException If the value is not a list of positive integers.
- */
- public static function validate_id_list( string $key, $value ): array {
- if ( ! self::is_list( $value ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a list of positive integers.', esc_html( $key ) ) );
- }
-
- $ids = array();
- foreach ( $value as $id ) {
- $id = self::cast_digit_string( $id );
- if ( ! is_int( $id ) || $id <= 0 ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a list of positive integers.', esc_html( $key ) ) );
- }
- $ids[] = $id;
- }
-
- return $ids;
- }
-
- /**
- * Validate a non-empty string or a list of them and return it as a list.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @return array<int, string>
- * @throws InvalidArgumentException If the value is not a non-empty string or a list of them.
- */
- public static function validate_string_list( string $key, $value ): array {
- $values = is_string( $value ) ? array( $value ) : $value;
- if ( ! self::is_list( $values ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string or a list of them.', esc_html( $key ) ) );
- }
-
- $strings = array();
- foreach ( $values as $item ) {
- if ( ! is_string( $item ) || '' === $item ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string or a list of them.', esc_html( $key ) ) );
- }
- $strings[] = $item;
- }
-
- return $strings;
- }
-
- /**
- * Validate and return an array, or null.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @return array<int|string, mixed>|null
- * @throws InvalidArgumentException If the value is not null or an array.
- */
- public static function validate_nullable_array( string $key, $value ): ?array {
- if ( null !== $value && ! is_array( $value ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be null or an array.', esc_html( $key ) ) );
- }
-
- return $value;
- }
-
- /**
- * Validate a GMT datetime, or null, and return it as a UTC `Y-m-d H:i:s` string.
- *
- * @param string $key Field name.
- * @param mixed $value `DateTimeInterface`, GMT `Y-m-d H:i:s` string, or null.
- * @throws InvalidArgumentException If the value is not a valid datetime.
- */
- public static function validate_nullable_date( string $key, $value ): ?string {
- if ( null === $value ) {
- return null;
- }
-
- if ( $value instanceof DateTimeInterface ) {
- return ( new DateTimeImmutable( '@' . $value->getTimestamp() ) )->format( 'Y-m-d H:i:s' );
- }
-
- if ( is_string( $value ) ) {
- $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new DateTimeZone( 'UTC' ) );
- if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $value ) {
- return $value;
- }
- }
-
- throw new InvalidArgumentException( sprintf( '"%s" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.', esc_html( $key ) ) );
- }
-
- /**
- * Validate a money value and return it normalized to the storage scale; null is 0.
- *
- * @param string $key Field name.
- * @param mixed $value Number, numeric string, or null.
- * @throws InvalidArgumentException If the value is not numeric.
- */
- public static function validate_money( string $key, $value ): string {
- if ( null === $value ) {
- return MoneyScale::normalize_money( 0 );
- }
-
- if ( ! is_int( $value ) && ! is_float( $value ) && ! ( is_string( $value ) && is_numeric( $value ) ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a number or a numeric string.', esc_html( $key ) ) );
- }
-
- return MoneyScale::normalize_money( $value );
- }
-
- /**
- * Validate a list of arrays and return it with string-keyed rows.
- *
- * @param string $key Field name.
- * @param mixed $value Caller value.
- * @return array<int, array<string, mixed>>
- * @throws InvalidArgumentException If the value is not a list of arrays.
- */
- public static function validate_list_of_arrays( string $key, $value ): array {
- if ( ! self::is_list( $value ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
- }
-
- $rows = array();
- foreach ( $value as $row ) {
- if ( ! is_array( $row ) ) {
- throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
- }
- $rows[] = Coercion::coerce_string_keyed( $row );
- }
-
- return $rows;
- }
-
- /**
- * Validate contract item rows; unknown row keys are dropped with a notice.
- *
- * @param mixed $value Caller value.
- * @param string $function_name Function named in the notice; defaults to this method.
- * @return array<int, array<string, mixed>>
- * @throws InvalidArgumentException If the value is not a list of item rows.
- */
- public static function validate_contract_items( $value, string $function_name = __METHOD__ ): array {
- $allowed = array_fill_keys( Contract::ITEM_FIELDS, true );
- $rows = array();
- foreach ( self::validate_list_of_arrays( 'items', $value ) as $row ) {
- $rows[] = self::filter_known_keys( $function_name, $row, $allowed, 'item key' );
- }
-
- return $rows;
- }
-
- /**
- * Validate contract addresses keyed `billing` / `shipping`; unknown address keys are
- * dropped with a notice.
- *
- * @param mixed $value Caller value.
- * @param string $function_name Function named in the notice; defaults to this method.
- * @return array<string, array<string, mixed>>
- * @throws InvalidArgumentException If the map is not keyed `billing` / `shipping` with array values.
- */
- public static function validate_contract_addresses( $value, string $function_name = __METHOD__ ): array {
- if ( ! is_array( $value ) ) {
- throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping".' );
- }
-
- $allowed = array_fill_keys( Contract::ADDRESS_FIELDS, true );
- $addresses = array();
- foreach ( $value as $type => $address ) {
- if ( ! in_array( $type, array( Contract::ADDRESS_BILLING, Contract::ADDRESS_SHIPPING ), true ) || ! is_array( $address ) ) {
- throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
- }
-
- $addresses[ $type ] = self::filter_known_keys( $function_name, $address, $allowed, 'address key' );
- }
-
- return $addresses;
- }
-
- /**
- * Whether a value is a list: an array with consecutive int keys from 0 (an empty array is one).
- *
- * @param mixed $value Caller value.
- * @phpstan-assert-if-true array<int, mixed> $value
- */
- private static function is_list( $value ): bool {
- return is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) );
- }
-
- /**
- * Cast a digit string to an int; any other value is returned unchanged.
- *
- * @param mixed $value Caller value.
- * @return mixed
- */
- private static function cast_digit_string( $value ) {
- if ( is_string( $value ) && ctype_digit( $value ) ) {
- return (int) $value;
- }
-
- return $value;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
index 835bf86db46..e37d94c67b0 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -11,12 +11,12 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
use DateTimeImmutable;
use DateTimeZone;
-use DomainException;
use EngineIntegrationTestCase;
use InvalidArgumentException;
use WC_Order;
use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
@@ -24,14 +24,19 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\AssertsValidationErrors;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts
*/
class ContractsTest extends EngineIntegrationTestCase {
+ use AssertsValidationErrors;
+
private const EXTENSION_SLUG = 'acme-subs';
/**
@@ -319,13 +324,260 @@ class ContractsTest extends EngineIntegrationTestCase {
'status' => 'nonsense',
)
);
- $this->fail( 'Expected an InvalidArgumentException.' );
- } catch ( InvalidArgumentException $e ) {
- $this->assertInstanceOf( DomainException::class, $e->getPrevious() );
- $this->assertSame( $e->getPrevious()->getMessage(), $e->getMessage() );
+ $this->fail( 'Expected a ValidationException.' );
+ } catch ( ValidationException $e ) {
+ $this->assertInstanceOf( InvalidInputException::class, $e->getPrevious() );
+ $this->assertSame( esc_html( $e->getPrevious()->getMessage() ), $e->getMessage() );
+ $this->assertSame( array( ValidationErrorCode::NOT_REGISTERED ), $e->get_errors()->get_error_codes() );
}
}
+ /**
+ * @testdox Contract writes report the error code and field.
+ */
+ public function test_contract_writes_report_the_error_code_and_field(): void {
+ $this->assert_validation_errors(
+ static function (): void {
+ Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'currency' => 'usd',
+ )
+ );
+ },
+ array( array( ValidationErrorCode::INVALID_VALUE, 'currency' ) )
+ );
+ $this->assert_validation_errors(
+ static function (): void {
+ Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'billing_total' => '10',
+ )
+ );
+ },
+ array( array( ValidationErrorCode::REQUIRED, 'currency' ) )
+ );
+ $this->assert_validation_errors(
+ static function (): void {
+ Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'schedule_source' => 'Not a slug',
+ )
+ );
+ },
+ array( array( ValidationErrorCode::INVALID_VALUE, 'schedule_source' ) )
+ );
+
+ $id = $this->contract_with_currency();
+ $this->assert_validation_errors(
+ static function () use ( $id ): void {
+ Contracts::update( $id, array( 'addresses' => array( 'home' => array( 'first_name' => 'Ada' ) ) ) );
+ },
+ array( array( ValidationErrorCode::NOT_IN_ENUM, 'addresses.home' ) )
+ );
+ }
+
+ /**
+ * @testdox Cycle writes and reads report the error code and field.
+ */
+ public function test_cycle_writes_report_the_error_code_and_field(): void {
+ $id = $this->contract_with_currency();
+ $cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+ $this->assert_validation_errors(
+ function () use ( $id ): void {
+ $this->add_cycle_locked( $id, array( 'currency' => 'USD' ) );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'starts_at_gmt' ),
+ array( ValidationErrorCode::REQUIRED, 'ends_at_gmt' ),
+ )
+ );
+ $this->assert_validation_errors(
+ function () use ( $cycle ): void {
+ $this->transition_cycle_locked(
+ $cycle->get_id(),
+ array(
+ 'from_status' => CycleStatus::PENDING,
+ 'to_status' => 'never-registered',
+ )
+ );
+ },
+ array( array( ValidationErrorCode::NOT_REGISTERED, 'to_status' ) )
+ );
+ $this->assert_validation_errors(
+ static function () use ( $id ): void {
+ Contracts::get_cycles( $id, -1 );
+ },
+ array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit' ) )
+ );
+ }
+
+ /**
+ * @testdox create reports every ill-typed argument at once and writes nothing.
+ */
+ public function test_create_reports_every_ill_typed_argument(): void {
+ $before = Contracts::count();
+
+ $this->assert_validation_errors(
+ static function (): void {
+ Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'currency' => 'usd',
+ 'customer_id' => 'x',
+ 'addresses' => array( 'home' => array( 'first_name' => 'Ada' ) ),
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::INVALID_TYPE, 'customer_id', array( 'type' => 'integer' ) ),
+ array( ValidationErrorCode::INVALID_VALUE, 'currency', array() ),
+ array( ValidationErrorCode::NOT_IN_ENUM, 'addresses.home', array( 'allowed' => array( 'billing', 'shipping' ) ) ),
+ )
+ );
+ $this->assertSame( $before, Contracts::count() );
+ }
+
+ /**
+ * @testdox create reports every contract refusal at once.
+ */
+ public function test_create_reports_every_contract_refusal(): void {
+ $this->assert_validation_errors(
+ static function (): void {
+ Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'creation_key' => str_repeat( 'k', 101 ),
+ 'status' => 'never-registered',
+ 'schedule_source' => 'Not a slug',
+ 'billing_total' => '10',
+ )
+ );
+ },
+ array(
+ // The facade error groups messages by code, so both `invalid_value` errors come first.
+ array( ValidationErrorCode::INVALID_VALUE, 'creation_key' ),
+ array( ValidationErrorCode::INVALID_VALUE, 'schedule_source' ),
+ array( ValidationErrorCode::NOT_REGISTERED, 'status' ),
+ array( ValidationErrorCode::REQUIRED, 'currency' ),
+ )
+ );
+ }
+
+ /**
+ * @testdox update reports every contract refusal at once and writes nothing.
+ */
+ public function test_update_reports_every_contract_refusal(): void {
+ $id = $this->contract_with_currency();
+ $before = $this->entity( $id )->to_storage();
+
+ $this->assert_validation_errors(
+ static function () use ( $id ): void {
+ Contracts::update(
+ $id,
+ array(
+ 'status' => 'never-registered',
+ 'schedule_source' => 'Not a slug',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::NOT_REGISTERED, 'status' ),
+ array( ValidationErrorCode::INVALID_VALUE, 'schedule_source' ),
+ )
+ );
+ $this->assertSame( $before, $this->entity( $id )->to_storage() );
+ }
+
+ /**
+ * @testdox update checks its arguments before reading, so invalid arguments throw for a missing contract.
+ */
+ public function test_update_checks_its_arguments_before_reading(): void {
+ $this->assert_validation_errors(
+ static function (): void {
+ Contracts::update( 999999, array( 'customer_id' => 0 ) );
+ },
+ array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'customer_id' ) )
+ );
+ $this->assertNull( Contracts::update( 999999, array( 'customer_id' => 5 ) ) );
+ }
+
+ /**
+ * @testdox add_cycle reports every refusal at once: ill-typed arguments before the cycle is built.
+ */
+ public function test_add_cycle_reports_every_refusal(): void {
+ $id = $this->contract_with_currency();
+
+ $this->assert_validation_errors(
+ function () use ( $id ): void {
+ $this->add_cycle_locked( $id, array( 'kind' => '' ) );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'kind' ),
+ array( ValidationErrorCode::REQUIRED, 'starts_at_gmt' ),
+ array( ValidationErrorCode::REQUIRED, 'ends_at_gmt' ),
+ array( ValidationErrorCode::REQUIRED, 'currency' ),
+ )
+ );
+ $this->assert_validation_errors(
+ function () use ( $id ): void {
+ $this->add_cycle_locked(
+ $id,
+ array(
+ 'count' => 0,
+ 'starts_at_gmt' => 'soon',
+ 'ends_at_gmt' => 'later',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'count' ),
+ array( ValidationErrorCode::INVALID_VALUE, 'starts_at_gmt' ),
+ array( ValidationErrorCode::INVALID_VALUE, 'ends_at_gmt' ),
+ )
+ );
+ $this->assertSame( 0, $this->stored_cycle_count( $id ) );
+ }
+
+ /**
+ * @testdox transition_cycle and get_cycles report every invalid value at once.
+ */
+ public function test_cycle_tools_report_every_invalid_value(): void {
+ $id = $this->contract_with_currency();
+ $cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+ $this->assert_validation_errors(
+ function () use ( $cycle ): void {
+ $this->transition_cycle_locked(
+ $cycle->get_id(),
+ array(
+ 'from_status' => '',
+ 'to_status' => CycleStatus::BILLED,
+ 'order_id' => 'x',
+ 'reason' => 5,
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'from_status' ),
+ array( ValidationErrorCode::INVALID_TYPE, 'order_id' ),
+ array( ValidationErrorCode::INVALID_TYPE, 'reason' ),
+ )
+ );
+ $this->assert_validation_errors(
+ static function () use ( $id ): void {
+ Contracts::get_cycles( $id, -1, -2 );
+ },
+ array(
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 0 ) ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'offset', array( 'min' => 0 ) ),
+ )
+ );
+ }
+
/**
* @dataProvider provide_invalid_fields
*
@@ -1477,11 +1729,17 @@ class ContractsTest extends EngineIntegrationTestCase {
$this->assertNotNull( $created );
$this->assertSame( 'create', $created['lock']->get_operation() );
- foreach ( array( 'Checkout', 5 ) as $operation ) {
+ // A non-string label is an invalid argument; a malformed string is refused by the lock.
+ $labels = array(
+ ValidationException::class => 5,
+ InvalidArgumentException::class => 'Checkout',
+ );
+ foreach ( $labels as $expected => $operation ) {
try {
Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ), array( 'operation' => $operation ) );
$this->fail( 'Expected an invalid operation label to throw.' );
} catch ( InvalidArgumentException $e ) {
+ $this->assertSame( $expected, get_class( $e ) );
$this->assertStringContainsString( 'operation', $e->getMessage() );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
index c68a368c62d..2061cf8e354 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
@@ -9,26 +9,30 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
-use DomainException;
use EngineIntegrationTestCase;
use InvalidArgumentException;
use RuntimeException;
use WP_Error;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\AssertsValidationErrors;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Plans
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException
*/
class PlansTest extends EngineIntegrationTestCase {
+ use AssertsValidationErrors;
+
private const OWNER = 'acme-subs';
private const HOOK = 'woocommerce_subscriptions_engine_validate_plan';
@@ -144,37 +148,40 @@ class PlansTest extends EngineIntegrationTestCase {
}
/**
- * @return array<string, array{0: array<string, mixed>}>
+ * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
*/
public function provide_invalid_create_args(): array {
return array(
- 'unregistered status' => array( array( 'status' => 'seasonal' ) ),
- 'non-string status' => array( array( 'status' => 5 ) ),
- 'empty name' => array( array( 'name' => '' ) ),
- 'whitespace name' => array( array( 'name' => ' ' ) ),
- 'non-string name' => array( array( 'name' => 12 ) ),
- 'list policy' => array( array( 'pricing_policy' => array( 'a', 'b' ) ) ),
- 'scalar policy' => array( array( 'billing_policy' => 'monthly' ) ),
- 'missing slug' => array( array( 'extension_slug' => null ) ),
- 'empty slug' => array( array( 'extension_slug' => '' ) ),
+ 'unregistered status' => array( array( 'status' => 'seasonal' ), ValidationErrorCode::NOT_REGISTERED, 'status' ),
+ 'non-string status' => array( array( 'status' => 5 ), ValidationErrorCode::INVALID_TYPE, 'status' ),
+ 'empty name' => array( array( 'name' => '' ), ValidationErrorCode::REQUIRED, 'name' ),
+ 'whitespace name' => array( array( 'name' => ' ' ), ValidationErrorCode::REQUIRED, 'name' ),
+ 'non-string name' => array( array( 'name' => 12 ), ValidationErrorCode::INVALID_TYPE, 'name' ),
+ 'list policy' => array( array( 'pricing_policy' => array( 'a', 'b' ) ), ValidationErrorCode::INVALID_TYPE, 'pricing_policy' ),
+ 'scalar policy' => array( array( 'billing_policy' => 'monthly' ), ValidationErrorCode::INVALID_TYPE, 'billing_policy' ),
+ 'missing slug' => array( array( 'extension_slug' => null ), ValidationErrorCode::REQUIRED, 'extension_slug' ),
+ 'empty slug' => array( array( 'extension_slug' => '' ), ValidationErrorCode::REQUIRED, 'extension_slug' ),
+ 'non-string slug' => array( array( 'extension_slug' => 5 ), ValidationErrorCode::INVALID_TYPE, 'extension_slug' ),
);
}
/**
- * @testdox create rejects invalid args and stores nothing.
+ * @testdox create rejects invalid args with the code and field, and stores nothing.
* @dataProvider provide_invalid_create_args
*
* @param array<string, mixed> $overrides Invalid overrides.
+ * @param string $code Expected error code.
+ * @param string $field Expected field.
*/
- public function test_create_rejects_invalid_args_and_stores_nothing( array $overrides ): void {
+ public function test_create_rejects_invalid_args_and_stores_nothing( array $overrides, string $code, string $field ): void {
$before = $this->plan_count();
- try {
- $this->create( $overrides );
- $this->fail( 'Expected InvalidArgumentException.' );
- } catch ( InvalidArgumentException $e ) {
- $this->assertNotInstanceOf( PlanValidationException::class, $e );
- }
+ $this->assert_validation_errors(
+ function () use ( $overrides ): void {
+ $this->create( $overrides );
+ },
+ array( array( $code, $field ) )
+ );
$this->assertSame( $before, $this->plan_count() );
}
@@ -183,35 +190,42 @@ class PlansTest extends EngineIntegrationTestCase {
* @testdox an entity invariant failure is reported as invalid input, on create and on update.
*/
public function test_an_entity_invariant_failure_is_reported_as_invalid_input(): void {
- try {
- $this->create( array( 'status' => 'nonsense' ) );
- $this->fail( 'Expected an InvalidArgumentException.' );
- } catch ( InvalidArgumentException $e ) {
- $this->assertInstanceOf( DomainException::class, $e->getPrevious() );
- $this->assertSame( $e->getPrevious()->getMessage(), $e->getMessage() );
- }
-
- try {
- Plans::update(
- $this->create(),
- array(
- 'extension_slug' => self::OWNER,
- 'name' => ' ',
- )
- );
- $this->fail( 'Expected an InvalidArgumentException.' );
- } catch ( InvalidArgumentException $e ) {
- $this->assertInstanceOf( DomainException::class, $e->getPrevious() );
- }
+ $e = $this->assert_validation_errors(
+ function (): void {
+ $this->create( array( 'status' => 'nonsense' ) );
+ },
+ array( array( ValidationErrorCode::NOT_REGISTERED, 'status' ) )
+ );
+ $this->assertSame( '"nonsense" is not registered.', $e->get_errors()->get_error_message( ValidationErrorCode::NOT_REGISTERED ) );
+ $previous = $e->getPrevious();
+ $this->assertInstanceOf( InvalidInputException::class, $previous );
+ $this->assertSame( esc_html( $previous->getMessage() ), $e->getMessage(), 'The exception message stays the English Core message.' );
+
+ $e = $this->assert_validation_errors(
+ function (): void {
+ Plans::update(
+ $this->create(),
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => ' ',
+ )
+ );
+ },
+ array( array( ValidationErrorCode::REQUIRED, 'name' ) )
+ );
+ $this->assertSame( 'This field is required.', $e->get_errors()->get_error_message( ValidationErrorCode::REQUIRED ) );
}
/**
* @testdox create requires a name.
*/
public function test_create_requires_a_name(): void {
- $this->expectException( InvalidArgumentException::class );
-
- Plans::create( array( 'extension_slug' => self::OWNER ) );
+ $this->assert_validation_errors(
+ static function (): void {
+ Plans::create( array( 'extension_slug' => self::OWNER ) );
+ },
+ array( array( ValidationErrorCode::REQUIRED, 'name' ) )
+ );
}
/**
@@ -458,28 +472,31 @@ class PlansTest extends EngineIntegrationTestCase {
}
/**
- * @return array<string, array{0: array<string, mixed>}>
+ * @return array<string, array{0: array<string, mixed>, 1: string}>
*/
public function provide_invalid_update_scopes(): array {
return array(
- 'missing slug' => array( array( 'name' => 'Changed' ) ),
+ 'missing slug' => array( array( 'name' => 'Changed' ), ValidationErrorCode::REQUIRED ),
'null slug' => array(
array(
'extension_slug' => null,
'name' => 'Changed',
),
+ ValidationErrorCode::REQUIRED,
),
'empty slug' => array(
array(
'extension_slug' => '',
'name' => 'Changed',
),
+ ValidationErrorCode::REQUIRED,
),
'non-string slug' => array(
array(
'extension_slug' => 5,
'name' => 'Changed',
),
+ ValidationErrorCode::INVALID_TYPE,
),
);
}
@@ -489,17 +506,18 @@ class PlansTest extends EngineIntegrationTestCase {
* @dataProvider provide_invalid_update_scopes
*
* @param array<string, mixed> $args Update args without a valid extension slug.
+ * @param string $code Expected error code.
*/
- public function test_update_without_a_valid_extension_slug_throws( array $args ): void {
+ public function test_update_without_a_valid_extension_slug_throws( array $args, string $code ): void {
$id = $this->create();
$before = $this->stored( $id )->to_storage();
- try {
- Plans::update( $id, $args );
- $this->fail( 'Expected InvalidArgumentException.' );
- } catch ( InvalidArgumentException $e ) {
- $this->assertNotInstanceOf( PlanValidationException::class, $e );
- }
+ $this->assert_validation_errors(
+ static function () use ( $args, $id ): void {
+ Plans::update( $id, $args );
+ },
+ array( array( $code, 'extension_slug' ) )
+ );
$this->assertSame( $before, $this->stored( $id )->to_storage() );
}
@@ -554,22 +572,24 @@ class PlansTest extends EngineIntegrationTestCase {
}
/**
- * @return array<string, array{0: array<string, mixed>}>
+ * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
*/
public function provide_invalid_update_args(): array {
return array(
- 'unregistered status' => array( array( 'status' => 'seasonal' ) ),
- 'non-string status' => array( array( 'status' => 5 ) ),
- 'empty name' => array( array( 'name' => '' ) ),
- 'whitespace name' => array( array( 'name' => ' ' ) ),
- 'non-string name' => array( array( 'name' => 12 ) ),
- 'list policy' => array( array( 'pricing_policy' => array( 'a', 'b' ) ) ),
- 'scalar policy' => array( array( 'billing_policy' => 'monthly' ) ),
+ 'unregistered status' => array( array( 'status' => 'seasonal' ), ValidationErrorCode::NOT_REGISTERED, 'status' ),
+ 'non-string status' => array( array( 'status' => 5 ), ValidationErrorCode::INVALID_TYPE, 'status' ),
+ 'empty name' => array( array( 'name' => '' ), ValidationErrorCode::REQUIRED, 'name' ),
+ 'whitespace name' => array( array( 'name' => ' ' ), ValidationErrorCode::REQUIRED, 'name' ),
+ 'non-string name' => array( array( 'name' => 12 ), ValidationErrorCode::INVALID_TYPE, 'name' ),
+ 'list policy' => array( array( 'pricing_policy' => array( 'a', 'b' ) ), ValidationErrorCode::INVALID_TYPE, 'pricing_policy' ),
+ 'scalar policy' => array( array( 'billing_policy' => 'monthly' ), ValidationErrorCode::INVALID_TYPE, 'billing_policy' ),
'valid then invalid' => array(
array(
'name' => 'Changed',
'status' => 'seasonal',
),
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
),
);
}
@@ -578,18 +598,20 @@ class PlansTest extends EngineIntegrationTestCase {
* @testdox update rejects invalid args and leaves the plan unchanged.
* @dataProvider provide_invalid_update_args
*
- * @param array<string, mixed> $args Invalid update args.
+ * @param array<string, mixed> $args Invalid update args.
+ * @param string $code Expected error code.
+ * @param string $field Expected field.
*/
- public function test_update_rejects_invalid_args_and_leaves_the_plan_unchanged( array $args ): void {
+ public function test_update_rejects_invalid_args_and_leaves_the_plan_unchanged( array $args, string $code, string $field ): void {
$id = $this->create();
$before = $this->stored( $id )->to_storage();
- try {
- Plans::update( $id, array( 'extension_slug' => self::OWNER ) + $args );
- $this->fail( 'Expected InvalidArgumentException.' );
- } catch ( InvalidArgumentException $e ) {
- $this->assertNotInstanceOf( PlanValidationException::class, $e );
- }
+ $this->assert_validation_errors(
+ static function () use ( $args, $id ): void {
+ Plans::update( $id, array( 'extension_slug' => self::OWNER ) + $args );
+ },
+ array( array( $code, $field ) )
+ );
$this->assertSame( $before, $this->stored( $id )->to_storage() );
}
@@ -763,11 +785,12 @@ class PlansTest extends EngineIntegrationTestCase {
try {
$this->create();
- $this->fail( 'Expected PlanValidationException.' );
- } catch ( PlanValidationException $e ) {
+ $this->fail( 'Expected ValidationException.' );
+ } catch ( ValidationException $e ) {
$this->assertSame( array( 'acme_bad_pricing', 'acme_bad_billing' ), $e->get_errors()->get_error_codes() );
$this->assertSame( array( 'status' => 400 ), $e->get_errors()->get_error_data( 'acme_bad_pricing' ) );
$this->assertSame( 'Pricing is wrong. Billing is wrong.', $e->getMessage() );
+ $this->assertNull( $e->getPrevious(), 'An extension error has no Core error behind it.' );
}
$this->assertSame( $before, $this->plan_count() );
@@ -793,8 +816,8 @@ class PlansTest extends EngineIntegrationTestCase {
'name' => 'Refused',
)
);
- $this->fail( 'Expected PlanValidationException.' );
- } catch ( PlanValidationException $e ) {
+ $this->fail( 'Expected ValidationException.' );
+ } catch ( ValidationException $e ) {
$this->assertSame( array( 'acme_no' ), $e->get_errors()->get_error_codes() );
}
@@ -823,6 +846,163 @@ class PlansTest extends EngineIntegrationTestCase {
$this->assertSame( $before, $this->plan_count() );
}
+ /**
+ * @testdox create reports every ill-typed argument at once and does not ask the extension.
+ */
+ public function test_create_reports_every_ill_typed_argument_without_asking_the_extension(): void {
+ $calls = 0;
+ add_action(
+ self::HOOK,
+ static function () use ( &$calls ): void {
+ ++$calls;
+ }
+ );
+
+ $this->assert_validation_errors(
+ function (): void {
+ $this->create(
+ array(
+ 'name' => 5,
+ 'billing_policy' => 'monthly',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::INVALID_TYPE, 'name', array( 'type' => 'string' ) ),
+ array( ValidationErrorCode::INVALID_TYPE, 'billing_policy', array( 'type' => 'object' ) ),
+ )
+ );
+ $this->assertSame( 0, $calls );
+ }
+
+ /**
+ * @testdox create reports the plan's refusals and the extension's together, the plan's first.
+ */
+ public function test_create_reports_the_plan_and_extension_refusals_together(): void {
+ $before = $this->plan_count();
+ $views = array();
+ add_action(
+ self::HOOK,
+ static function ( WP_Error $errors, PlanView $plan ) use ( &$views ): void {
+ $views[] = $plan;
+ $errors->add( 'out_of_bounds', 'Must not exceed 100 for a percentage policy.', array( 'field' => 'pricing_policy.policies.0.value' ) );
+ },
+ 10,
+ 2
+ );
+
+ $e = $this->assert_validation_errors(
+ function (): void {
+ $this->create(
+ array(
+ 'name' => '',
+ 'status' => 'never-registered',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'name' ),
+ array( ValidationErrorCode::NOT_REGISTERED, 'status' ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'pricing_policy.policies.0.value' ),
+ )
+ );
+
+ $this->assertCount( 1, $views );
+ $this->assertSame( '', $views[0]->get_name() );
+ $this->assertSame( 'never-registered', $views[0]->get_status() );
+ $this->assertInstanceOf( InvalidInputException::class, $e->getPrevious() );
+ $this->assertStringContainsString( 'Must not exceed 100 for a percentage policy.', $e->getMessage() );
+ $this->assertSame( $before, $this->plan_count() );
+ }
+
+ /**
+ * @testdox update asks the extension about the would-be plan when the plan refuses, and writes nothing.
+ */
+ public function test_update_asks_the_extension_about_the_refused_would_be_plan(): void {
+ $id = $this->create( array( 'pricing_policy' => array( 'policies' => array() ) ) );
+ $before = $this->stored( $id )->to_storage();
+ $views = array();
+ add_action(
+ self::HOOK,
+ static function ( WP_Error $errors, PlanView $plan ) use ( &$views ): void {
+ $views[] = $plan;
+ $errors->add( 'acme_refused', 'Refused.', array( 'field' => 'pricing_policy' ) );
+ },
+ 10,
+ 2
+ );
+
+ $this->assert_validation_errors(
+ static function () use ( $id ): void {
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => '',
+ 'pricing_policy' => array( 'policies' => array( 'x' ) ),
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'name' ),
+ array( 'acme_refused', 'pricing_policy' ),
+ )
+ );
+
+ $this->assertCount( 1, $views );
+ $this->assertSame( $id, $views[0]->get_id() );
+ $this->assertSame( '', $views[0]->get_name() );
+ $this->assertSame( array( 'policies' => array( 'x' ) ), $views[0]->get_pricing_policy() );
+ $this->assertSame( $before['billing_policy'], $views[0]->get_billing_policy() );
+ $this->assertSame( $before, $this->stored( $id )->to_storage() );
+ }
+
+ /**
+ * @testdox update checks its arguments before reading, so ill-typed arguments throw for a missing plan.
+ */
+ public function test_update_checks_its_arguments_before_reading(): void {
+ $this->assert_validation_errors(
+ static function (): void {
+ Plans::update(
+ 999999,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 5,
+ )
+ );
+ },
+ array( array( ValidationErrorCode::INVALID_TYPE, 'name' ) )
+ );
+ $this->assertNull(
+ Plans::update(
+ 999999,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Fine',
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox a throwing callback is a runtime exception even when the plan itself is refused.
+ */
+ public function test_a_throwing_callback_wins_over_a_plan_refusal(): void {
+ add_action(
+ self::HOOK,
+ static function (): void {
+ throw new \LogicException( 'boom' );
+ }
+ );
+
+ try {
+ $this->create( array( 'name' => '' ) );
+ $this->fail( 'Expected RuntimeException.' );
+ } catch ( RuntimeException $e ) {
+ $this->assertInstanceOf( \LogicException::class, $e->getPrevious() );
+ }
+ }
+
/**
* @testdox a failed insert throws a runtime exception without a previous exception.
*/
@@ -1086,27 +1266,60 @@ class PlansTest extends EngineIntegrationTestCase {
* @testdox list rejects an invalid value.
* @dataProvider provide_invalid_list_args
*
- * @param array<string, mixed> $args List args.
+ * @param array<string, mixed> $args List args.
+ * @param string $code Expected error code.
+ * @param string $field Expected field.
*/
- public function test_list_rejects_an_invalid_value( array $args ): void {
- $this->expectException( InvalidArgumentException::class );
+ public function test_list_rejects_an_invalid_value( array $args, string $code, string $field ): void {
+ $this->assert_validation_errors(
+ static function () use ( $args ): void {
+ Plans::list( $args );
+ },
+ array( array( $code, $field ) )
+ );
+ }
- Plans::list( $args );
+ /**
+ * @testdox list reports every invalid value at once.
+ */
+ public function test_list_reports_every_invalid_value(): void {
+ $this->assert_validation_errors(
+ static function (): void {
+ Plans::list(
+ array(
+ 'limit' => 0,
+ 'offset' => -1,
+ 'extension_slug' => array( 'a', 'any' ),
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'offset', array( 'min' => 0 ) ),
+ array( ValidationErrorCode::INVALID_VALUE, 'extension_slug', array() ),
+ )
+ );
+ $this->assert_validation_errors(
+ static function (): void {
+ Plans::list( array( 'ids' => array( 1, 0 ) ) );
+ },
+ array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'ids.1', array( 'min' => 1 ) ) )
+ );
}
/**
- * @return array<string, array{0: array<string, mixed>}>
+ * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
*/
public function provide_invalid_list_args(): array {
return array(
- 'empty extension slug' => array( array( 'extension_slug' => '' ) ),
- 'any extension slug' => array( array( 'extension_slug' => 'any' ) ),
- 'any in a slug list' => array( array( 'extension_slug' => array( self::OWNER, 'any' ) ) ),
- 'status not a string' => array( array( 'status' => 5 ) ),
- 'id not positive' => array( array( 'ids' => array( 1, 0 ) ) ),
- 'ids not a list' => array( array( 'ids' => array( 'a' => 1 ) ) ),
- 'zero limit' => array( array( 'limit' => 0 ) ),
- 'negative offset' => array( array( 'offset' => -1 ) ),
+ 'empty extension slug' => array( array( 'extension_slug' => '' ), ValidationErrorCode::REQUIRED, 'extension_slug' ),
+ 'any extension slug' => array( array( 'extension_slug' => 'any' ), ValidationErrorCode::INVALID_VALUE, 'extension_slug' ),
+ 'any in a slug list' => array( array( 'extension_slug' => array( self::OWNER, 'any' ) ), ValidationErrorCode::INVALID_VALUE, 'extension_slug' ),
+ 'status not a string' => array( array( 'status' => 5 ), ValidationErrorCode::INVALID_TYPE, 'status' ),
+ 'id not positive' => array( array( 'ids' => array( 0, 1 ) ), ValidationErrorCode::OUT_OF_BOUNDS, 'ids.0' ),
+ 'ids not a list' => array( array( 'ids' => array( 'a' => 1 ) ), ValidationErrorCode::INVALID_TYPE, 'ids' ),
+ 'zero limit' => array( array( 'limit' => 0 ), ValidationErrorCode::OUT_OF_BOUNDS, 'limit' ),
+ 'negative offset' => array( array( 'offset' => -1 ), ValidationErrorCode::OUT_OF_BOUNDS, 'offset' ),
);
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
index ff41ae8a449..1ff3ac39196 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
@@ -295,11 +295,9 @@ class PlansControllerTest extends EngineIntegrationTestCase {
/**
* @dataProvider provide_non_object_pricing_policies
*
- * @param mixed $invalid Non-object pricing payload.
- * @param string $create_code Create error code: core schema validation rejects a scalar first.
- * @param string $patch_code PATCH error code: the route has no arg schema, so the facade rejects.
+ * @param mixed $invalid Non-object pricing payload.
*/
- public function test_non_object_pricing_policy_is_rejected( $invalid, string $create_code, string $patch_code ): void {
+ public function test_non_object_pricing_policy_is_rejected( $invalid ): void {
wp_set_current_user( $this->admin_id );
$id = $this->create_plan( 'Patch target' );
@@ -317,10 +315,9 @@ class PlansControllerTest extends EngineIntegrationTestCase {
'pricing_policy' => $invalid,
)
);
- $this->assertSame( 400, $created->get_status() );
- $this->assertSame( $create_code, $this->response_data( $created )['code'] );
+ $this->assert_invalid_param( $created, 'pricing_policy', 'invalid_type' );
- $patched = $this->request(
+ $patched = $this->request(
'PATCH',
self::BASE . '/' . $id,
array(
@@ -328,13 +325,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {
'pricing_policy' => $invalid,
)
);
- $this->assertSame( 400, $patched->get_status() );
- $patched_data = $this->response_data( $patched );
- $this->assertSame( $patch_code, $patched_data['code'] );
- $this->assertIsString( $patched_data['message'] );
- if ( 'woocommerce_subscriptions_engine_invalid_plan' === $patch_code ) {
- $this->assertStringContainsString( 'pricing_policy', $patched_data['message'], 'REST errors carry the facade message, which names the invalid field.' );
- }
+ $patched_data = $this->assert_invalid_param( $patched, 'pricing_policy', 'invalid_type' );
+ $this->assertSame( 'pricing_policy: Must be of type object.', $this->nested( $patched_data, array( 'data', 'params', 'pricing_policy' ) ) );
// The rejected writes left the plan untouched and created nothing.
$fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
@@ -346,16 +338,17 @@ class PlansControllerTest extends EngineIntegrationTestCase {
}
/**
- * @return array<string, array{0: mixed, 1: string, 2: string}>
+ * @return array<string, array{0: mixed}>
*/
public function provide_non_object_pricing_policies(): array {
return array(
- 'list' => array( array( array( 'type' => 'bogo' ) ), 'woocommerce_subscriptions_engine_invalid_plan', 'woocommerce_subscriptions_engine_invalid_plan' ),
- 'string' => array( 'bogo', 'rest_invalid_param', 'woocommerce_subscriptions_engine_invalid_plan' ),
+ 'list' => array( array( array( 'type' => 'bogo' ) ) ),
+ 'string' => array( 'bogo' ),
+ 'empty string' => array( '' ),
);
}
- public function test_update_rejects_an_empty_name_with_a_message_naming_the_field(): void {
+ public function test_an_empty_name_is_reported_per_field_on_create_and_update(): void {
wp_set_current_user( $this->admin_id );
$id = $this->create_plan( 'Monthly' );
@@ -367,12 +360,571 @@ class PlansControllerTest extends EngineIntegrationTestCase {
'name' => '',
)
);
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ )
+ );
+
+ foreach ( array( $patched, $created ) as $response ) {
+ $data = $this->assert_invalid_param( $response, 'name', 'required' );
+ $this->assertSame( 'Invalid parameter(s): name', $data['message'] );
+ $this->assertSame( array( 'name' => 'name: This field is required.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+ $this->assertEquals(
+ array(
+ 'name' => array(
+ 'code' => 'required',
+ 'message' => 'name: This field is required.',
+ 'data' => array(
+ 'field' => 'name',
+ 'params' => (object) array(),
+ ),
+ ),
+ ),
+ $this->nested( $data, array( 'data', 'details' ) )
+ );
+ $this->assertEquals(
+ array(
+ 'name' => array(
+ array(
+ 'code' => 'required',
+ 'message' => 'This field is required.',
+ 'params' => (object) array(),
+ ),
+ ),
+ ),
+ $this->nested( $data, array( 'data', 'errors' ) )
+ );
+ }
+ }
+
+ public function test_empty_error_params_serialize_as_a_json_object(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ )
+ );
+ $json = (string) wp_json_encode( rest_get_server()->response_to_data( $created, false ) );
+
+ $this->assertStringContainsString( '"data":{"field":"name","params":{}}', $json );
+ $this->assertStringContainsString( '"errors":{"name":[{"code":"required","message":"This field is required.","params":{}}]}', $json );
+ $this->assertStringNotContainsString( '"params":[]', $json );
+ }
+
+ public function test_create_reports_an_unregistered_status_with_every_other_error(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ 'status' => 'never-registered',
+ )
+ );
+
+ $data = $this->assert_invalid_param( $created, 'status', 'not_registered' );
+ $this->assertSame( 'required', $this->nested( $data, array( 'data', 'details', 'name', 'code' ) ) );
+ $this->assertSame(
+ array(
+ 'name' => 'name: This field is required.',
+ 'status' => 'status: "never-registered" is not registered.',
+ ),
+ $this->nested( $data, array( 'data', 'params' ) )
+ );
+ $this->assertSame( array( 'name', 'status' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ $this->assertSame( 'required', $this->nested( $data, array( 'data', 'errors', 'name', 0, 'code' ) ) );
+ $this->assertSame( 'not_registered', $this->nested( $data, array( 'data', 'errors', 'status', 0, 'code' ) ) );
+ }
+
+ public function test_create_reports_every_field_type_error_together(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 5,
+ 'status' => 5,
+ 'billing_policy' => 'x',
+ )
+ );
+
+ $data = $this->assert_invalid_param( $created, 'billing_policy', 'invalid_type' );
+ foreach ( array( 'name', 'status', 'billing_policy' ) as $field ) {
+ $this->assertIsString( $this->nested( $data, array( 'data', 'params', $field ) ) );
+ $this->assertSame( 'invalid_type', $this->nested( $data, array( 'data', 'details', $field, 'code' ) ) );
+ $this->assertSame( 'invalid_type', $this->nested( $data, array( 'data', 'errors', $field, 0, 'code' ) ) );
+ }
+ $this->assertEqualsCanonicalizing( array( 'name', 'status', 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ $this->assertSame( '0', $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_headers()['X-WP-Total'] );
+ }
+
+ public function test_create_reports_every_field_invariant_error_together(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ 'status' => 'never-registered',
+ 'billing_policy' => array( 'x' ),
+ )
+ );
+
+ // A list passes the argument type check; the plan refuses it as a non-object policy.
+ $data = $this->assert_invalid_param( $created, 'billing_policy', 'invalid_type' );
+ $this->assertEqualsCanonicalizing( array( 'name', 'status', 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'params' ) ) );
+ $this->assertSame( 'required', $this->nested( $data, array( 'data', 'details', 'name', 'code' ) ) );
+ $this->assertSame( 'not_registered', $this->nested( $data, array( 'data', 'details', 'status', 'code' ) ) );
+ $this->assertEqualsCanonicalizing( array( 'name', 'status', 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ }
+
+ public function test_a_non_string_extension_slug_is_reported_in_the_validation_shape(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => 5,
+ 'name' => 'Typed slug',
+ )
+ );
+
+ $data = $this->assert_invalid_param( $created, 'extension_slug', 'invalid_type' );
+ $this->assertSame( array( 'type' => 'string' ), $this->nested( $data, array( 'data', 'errors', 'extension_slug', 0, 'params' ) ) );
+ }
+
+ public function test_create_reports_a_non_string_status_through_the_facade(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Typed',
+ 'status' => 5,
+ )
+ );
+
+ $data = $this->assert_invalid_param( $created, 'status', 'invalid_type' );
+ $this->assertSame( array( 'type' => 'string' ), $this->nested( $data, array( 'data', 'errors', 'status', 0, 'params' ) ) );
+ }
+
+ public function test_the_per_field_message_is_translated(): void {
+ wp_set_current_user( $this->admin_id );
+ add_filter(
+ 'gettext',
+ static function ( $translation, $text, $domain ) {
+ return 'woocommerce' === $domain && 'This field is required.' === $text ? 'Ce champ est obligatoire.' : $translation;
+ },
+ 10,
+ 3
+ );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ )
+ );
+
+ $data = $this->assert_invalid_param( $created, 'name', 'required' );
+ $this->assertSame( 'name: Ce champ est obligatoire.', $this->nested( $data, array( 'data', 'params', 'name' ) ) );
+ }
+
+ public function test_a_callback_error_with_a_field_is_reported_under_its_top_level_arg(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add(
+ 'out_of_bounds',
+ 'Interval must be at least 1.',
+ array(
+ 'field' => 'billing_policy.interval',
+ 'params' => array( 'min' => 1 ),
+ )
+ );
+ }
+ );
+
+ $data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'out_of_bounds' );
+
+ $this->assertSame( 'Invalid parameter(s): billing_policy', $data['message'] );
+ $this->assertSame( array( 'billing_policy' => 'billing_policy.interval: Interval must be at least 1.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+ $this->assertSame(
+ array(
+ 'code' => 'out_of_bounds',
+ 'message' => 'billing_policy.interval: Interval must be at least 1.',
+ 'data' => array(
+ 'field' => 'billing_policy.interval',
+ 'params' => array( 'min' => 1 ),
+ ),
+ ),
+ $this->nested( $data, array( 'data', 'details', 'billing_policy' ) )
+ );
+ }
+
+ public function test_two_same_code_callback_errors_on_different_fields_are_both_reported(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'invalid_value', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+ $errors->add( 'invalid_value', 'Bad amount.', array( 'field' => 'pricing_policy.one_time_fees.0.amount' ) );
+ }
+ );
+
+ $data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'invalid_value' );
+
+ $this->assertSame(
+ array(
+ 'billing_policy' => 'billing_policy.interval: Bad interval.',
+ 'pricing_policy' => 'pricing_policy.one_time_fees.0.amount: Bad amount.',
+ ),
+ $this->nested( $data, array( 'data', 'params' ) )
+ );
+ $this->assertSame( 'pricing_policy.one_time_fees.0.amount', $this->nested( $data, array( 'data', 'details', 'pricing_policy', 'data', 'field' ) ) );
+ }
+
+ public function test_fielded_and_fieldless_callback_errors_return_one_400_with_both(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'acme_general', 'Something is off.' );
+ $errors->add( 'invalid_value', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+ }
+ );
+
+ $data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'invalid_value' );
+
+ $this->assertSame( array( 'billing_policy' => 'billing_policy.interval: Bad interval.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+ $this->assertSame(
+ array(
+ array(
+ 'code' => 'acme_general',
+ 'message' => 'Something is off.',
+ 'data' => array( 'status' => 400 ),
+ ),
+ ),
+ $data['additional_errors']
+ );
+ }
+
+ public function test_errors_map_and_first_error_details_per_arg(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add(
+ 'out_of_bounds',
+ 'Must be less than or equal to 3.',
+ array(
+ 'field' => 'billing_policy.min_cycles',
+ 'params' => array( 'max' => 3 ),
+ )
+ );
+ foreach ( array( 0, 1 ) as $index ) {
+ $errors->add(
+ 'out_of_bounds',
+ 'Must not exceed 100 for a percentage policy.',
+ array(
+ 'field' => 'pricing_policy.policies.' . $index . '.value',
+ 'params' => array( 'max' => 100 ),
+ )
+ );
+ }
+ }
+ );
+
+ $data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'out_of_bounds' );
+
+ $this->assertSame( 'Invalid parameter(s): billing_policy, pricing_policy', $data['message'] );
+ $this->assertSame( 400, $this->nested( $data, array( 'data', 'status' ) ) );
+ $this->assertSame(
+ array(
+ 'billing_policy' => 'billing_policy.min_cycles: Must be less than or equal to 3.',
+ 'pricing_policy' => 'pricing_policy.policies.0.value: Must not exceed 100 for a percentage policy. pricing_policy.policies.1.value: Must not exceed 100 for a percentage policy.',
+ ),
+ $this->nested( $data, array( 'data', 'params' ) )
+ );
+ $this->assertSame(
+ array(
+ 'billing_policy' => array(
+ 'code' => 'out_of_bounds',
+ 'message' => 'billing_policy.min_cycles: Must be less than or equal to 3.',
+ 'data' => array(
+ 'field' => 'billing_policy.min_cycles',
+ 'params' => array( 'max' => 3 ),
+ ),
+ ),
+ 'pricing_policy' => array(
+ 'code' => 'out_of_bounds',
+ 'message' => 'pricing_policy.policies.0.value: Must not exceed 100 for a percentage policy.',
+ 'data' => array(
+ 'field' => 'pricing_policy.policies.0.value',
+ 'params' => array( 'max' => 100 ),
+ ),
+ ),
+ ),
+ $this->nested( $data, array( 'data', 'details' ) )
+ );
+ $this->assertSame(
+ array(
+ 'billing_policy.min_cycles' => array(
+ array(
+ 'code' => 'out_of_bounds',
+ 'message' => 'Must be less than or equal to 3.',
+ 'params' => array( 'max' => 3 ),
+ ),
+ ),
+ 'pricing_policy.policies.0.value' => array(
+ array(
+ 'code' => 'out_of_bounds',
+ 'message' => 'Must not exceed 100 for a percentage policy.',
+ 'params' => array( 'max' => 100 ),
+ ),
+ ),
+ 'pricing_policy.policies.1.value' => array(
+ array(
+ 'code' => 'out_of_bounds',
+ 'message' => 'Must not exceed 100 for a percentage policy.',
+ 'params' => array( 'max' => 100 ),
+ ),
+ ),
+ ),
+ $this->nested( $data, array( 'data', 'errors' ) )
+ );
+ }
+
+ public function test_details_carry_the_first_error_of_an_arg_with_its_own_data(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'invalid_value', 'First.', array( 'field' => 'pricing_policy.policies.0.type' ) );
+ $errors->add( 'invalid_value', 'Second.', array( 'field' => 'pricing_policy.policies.1.type' ) );
+ }
+ );
+
+ $data = $this->assert_invalid_param( $this->create_rejected_plan(), 'pricing_policy', 'invalid_value' );
+
+ $this->assertSame( 'pricing_policy.policies.0.type: First.', $this->nested( $data, array( 'data', 'details', 'pricing_policy', 'message' ) ) );
+ $this->assertSame( array( 'field' => 'pricing_policy.policies.0.type' ), $this->nested( $data, array( 'data', 'details', 'pricing_policy', 'data' ) ) );
+ $this->assertSame( array( 'pricing_policy.policies.0.type', 'pricing_policy.policies.1.type' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ }
+
+ public function test_a_fieldless_rest_invalid_param_error_never_replaces_the_response_data(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'rest_invalid_param', 'Something general.', array( 'params' => array( 'acme' => 'Hijack.' ) ) );
+ $errors->add( 'invalid_value', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+ }
+ );
+
+ $data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'invalid_value' );
+
+ $this->assertSame( array( 'billing_policy' => 'billing_policy.interval: Bad interval.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+ $this->assertSame( array( 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'details' ) ) );
+ $this->assertSame( array( 'billing_policy.interval' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+
+ $additional = $this->array_value( $data, 'additional_errors' );
+ $this->assertCount( 1, $additional );
+ $this->assertSame( 'rest_invalid_param', $this->nested( $additional, array( 0, 'code' ) ) );
+ $this->assertSame( 'Something general.', $this->nested( $additional, array( 0, 'message' ) ) );
+ }
+
+ public function test_a_parent_and_a_child_field_are_separate_errors_under_one_arg(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'out_of_bounds', 'Too high.', array( 'field' => 'pricing_policy.policies.0.value' ) );
+ }
+ );
+
+ $response = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Parent and child',
+ 'pricing_policy' => array( array( 'type' => 'bogo' ) ),
+ )
+ );
+ $data = $this->assert_invalid_param( $response, 'pricing_policy', 'invalid_type' );
+
+ $this->assertSame(
+ 'pricing_policy: Must be of type object. pricing_policy.policies.0.value: Too high.',
+ $this->nested( $data, array( 'data', 'params', 'pricing_policy' ) )
+ );
+ $this->assertSame( array( 'pricing_policy', 'pricing_policy.policies.0.value' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ }
+
+ public function test_engine_and_extension_errors_arrive_in_one_400(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add(
+ 'out_of_bounds',
+ 'Must not exceed 100 for a percentage policy.',
+ array(
+ 'field' => 'pricing_policy.policies.0.value',
+ 'params' => array( 'max' => 100 ),
+ )
+ );
+ }
+ );
+
+ $response = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ 'pricing_policy' => array( 'policies' => array( array( 'value' => 150 ) ) ),
+ )
+ );
+ $data = $this->assert_invalid_param( $response, 'name', 'required' );
+
+ $this->assertSame( 'Invalid parameter(s): name, pricing_policy', $data['message'] );
+ $this->assertSame(
+ array(
+ 'name' => 'name: This field is required.',
+ 'pricing_policy' => 'pricing_policy.policies.0.value: Must not exceed 100 for a percentage policy.',
+ ),
+ $this->nested( $data, array( 'data', 'params' ) )
+ );
+ $this->assertSame( array( 'name', 'pricing_policy.policies.0.value' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ }
+
+ public function test_an_extension_error_without_data_under_an_engine_code_keeps_the_engine_field(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'required', 'Something is missing.' );
+ }
+ );
+
+ $response = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ )
+ );
+ $data = $this->assert_invalid_param( $response, 'name', 'required' );
+
+ $this->assertSame( array( 'name' => 'name: This field is required.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+ $this->assertSame( array( 'name' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ $this->assertSame(
+ array(
+ array(
+ 'code' => 'required',
+ 'message' => 'Something is missing.',
+ 'data' => array( 'status' => 400 ),
+ ),
+ ),
+ $data['additional_errors']
+ );
+ }
+
+ public function test_a_data_less_extension_error_before_a_fielded_one_makes_its_code_fieldless(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'acme_invalid', 'A general problem.' );
+ $errors->add( 'acme_invalid', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+ }
+ );
+
+ $response = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Ambiguous',
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $data = $this->response_data( $response );
+ $this->assertSame( 'acme_invalid', $data['code'] );
+ $this->assertSame( 'A general problem.', $data['message'] );
+ $this->assertSame( array( 'status' => 400 ), $data['data'], 'The field is not put on the wrong message.' );
+ $this->assertSame(
+ array(
+ array(
+ 'code' => 'acme_invalid',
+ 'message' => 'Bad interval.',
+ 'data' => array( 'status' => 400 ),
+ ),
+ ),
+ $data['additional_errors']
+ );
+ }
+
+ public function test_a_data_less_extension_error_before_a_fielded_one_under_an_engine_code_keeps_the_engine_field(): void {
+ wp_set_current_user( $this->admin_id );
+ add_action(
+ 'woocommerce_subscriptions_engine_validate_plan',
+ static function ( WP_Error $errors ): void {
+ $errors->add( 'required', 'Something is missing.' );
+ $errors->add( 'required', 'The interval is missing.', array( 'field' => 'billing_policy.interval' ) );
+ }
+ );
+
+ $response = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ )
+ );
+ $data = $this->assert_invalid_param( $response, 'name', 'required' );
- $this->assertSame( 400, $patched->get_status() );
- $data = $this->response_data( $patched );
- $this->assertSame( 'woocommerce_subscriptions_engine_invalid_plan', $data['code'] );
- $this->assertIsString( $data['message'] );
- $this->assertStringContainsString( 'name', $data['message'] );
+ $this->assertSame( array( 'name' => 'name: This field is required.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+ $this->assertSame( array( 'name' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+ $this->assertSame(
+ array(
+ array(
+ 'code' => 'required',
+ 'message' => 'Something is missing.',
+ 'data' => array( 'status' => 400 ),
+ ),
+ array(
+ 'code' => 'required',
+ 'message' => 'The interval is missing.',
+ 'data' => array( 'status' => 400 ),
+ ),
+ ),
+ $data['additional_errors']
+ );
}
public function test_validate_action_receives_errors_a_plan_view_and_slug(): void {
@@ -744,8 +1296,11 @@ class PlansControllerTest extends EngineIntegrationTestCase {
foreach ( array( 'any,' . self::EXTENSION_SLUG, self::EXTENSION_SLUG . ',', ',' . self::EXTENSION_SLUG, 'lite,,test' ) as $extension_slug ) {
$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => $extension_slug ) );
- $this->assertSame( 400, $list->get_status(), 'Failed for extension_slug=' . $extension_slug );
+ $this->assert_invalid_param( $list, 'extension_slug', 'invalid_value' );
}
+
+ $blank = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => ' ' ) );
+ $this->assert_invalid_param( $blank, 'extension_slug', 'required' );
}
public function test_list_with_any_extension_slug_returns_all_plans(): void {
@@ -839,7 +1394,32 @@ class PlansControllerTest extends EngineIntegrationTestCase {
'status' => 'never-registered',
)
);
- $this->assertSame( 400, $unknown->get_status() );
+ $data = $this->assert_invalid_param( $unknown, 'status', 'not_registered' );
+ $this->assertSame( 'status: "never-registered" is not registered.', $this->nested( $data, array( 'data', 'params', 'status' ) ) );
+ $this->assertSame(
+ array(
+ 'status' => array(
+ array(
+ 'code' => 'not_registered',
+ 'message' => '"never-registered" is not registered.',
+ 'params' => array( 'value' => 'never-registered' ),
+ ),
+ ),
+ ),
+ $this->nested( $data, array( 'data', 'errors' ) )
+ );
+
+ $not_a_string = $this->request(
+ 'GET',
+ self::BASE,
+ array(),
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => array( 'seasonal' ),
+ )
+ );
+ $data = $this->assert_invalid_param( $not_a_string, 'status', 'invalid_type' );
+ $this->assertSame( array( 'status' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
$bad_patch = $this->request(
'PATCH',
@@ -849,7 +1429,9 @@ class PlansControllerTest extends EngineIntegrationTestCase {
'status' => 'never-registered',
)
);
- $this->assertSame( 400, $bad_patch->get_status() );
+ $data = $this->assert_invalid_param( $bad_patch, 'status', 'not_registered' );
+ $this->assertSame( 'not_registered', $this->nested( $data, array( 'data', 'errors', 'status', 0, 'code' ) ) );
+ $this->assertSame( array( 'value' => 'never-registered' ), $this->nested( $data, array( 'data', 'errors', 'status', 0, 'params' ) ) );
}
public function test_single_plan_routes_reject_wildcard_and_list_extension_slugs(): void {
@@ -921,8 +1503,12 @@ class PlansControllerTest extends EngineIntegrationTestCase {
)
);
- $this->assertSame( 400, $response->get_status() );
+ $this->assert_invalid_param( $response, 'extension_slug', 'invalid_value' );
}
+
+ $missing = $this->request( 'POST', self::BASE, array( 'name' => 'No scope' ) );
+ $data = $this->assert_invalid_param( $missing, 'extension_slug', 'required' );
+ $this->assertSame( 'extension_slug: This field is required.', $this->nested( $data, array( 'data', 'params', 'extension_slug' ) ) );
}
public function test_create_validates_the_status_and_keeps_the_callback_out_of_the_schema(): void {
@@ -937,8 +1523,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {
'status' => 'never-registered',
)
);
- $this->assertSame( 400, $created->get_status() );
- $this->assertSame( 'rest_invalid_param', $this->response_data( $created )['code'] );
+ $data = $this->assert_invalid_param( $created, 'status', 'not_registered' );
+ $this->assertSame( 'status: "never-registered" is not registered.', $this->nested( $data, array( 'data', 'params', 'status' ) ) );
$schema = ( new PlansController() )->get_public_item_schema();
$this->assertIsArray( $schema['properties']['status'] );
@@ -1063,6 +1649,44 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertContains( $response->get_status(), array( 404, 405 ), 'DELETE must not remove plans.' );
}
+ /**
+ * Assert a WordPress-shaped invalid param error: 400 `rest_invalid_param` with a
+ * message under `params[ $arg ]` and the error code under `details[ $arg ]`.
+ *
+ * @param WP_REST_Response $response Response.
+ * @param string $arg Top-level arg.
+ * @param string $code Expected detail error code.
+ * @return array<array-key, mixed> The response data.
+ */
+ private function assert_invalid_param( WP_REST_Response $response, string $arg, string $code ): array {
+ $this->assertSame( 400, $response->get_status() );
+ $data = $this->response_data( $response );
+ $this->assertSame( 'rest_invalid_param', $data['code'] );
+ $this->assertIsString( $this->nested( $data, array( 'data', 'params', $arg ) ) );
+ $this->assertNotSame( '', $this->nested( $data, array( 'data', 'params', $arg ) ) );
+ $this->assertSame( $code, $this->nested( $data, array( 'data', 'details', $arg, 'code' ) ) );
+
+ return $data;
+ }
+
+ /**
+ * POST a valid plan that a validate_plan callback is expected to refuse.
+ */
+ private function create_rejected_plan(): WP_REST_Response {
+ return $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Rejected',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ )
+ );
+ }
+
/**
* Create a basic plan and return its id.
*
@@ -1138,6 +1762,24 @@ class PlansControllerTest extends EngineIntegrationTestCase {
return $ids;
}
+ /**
+ * Get a value at a key path, asserting each level is an array that has the key.
+ *
+ * @param array<array-key, mixed> $data Data.
+ * @param array<int, array-key> $path Keys, outermost first.
+ * @return mixed
+ */
+ private function nested( array $data, array $path ) {
+ $value = $data;
+ foreach ( $path as $key ) {
+ $this->assertIsArray( $value );
+ $this->assertArrayHasKey( $key, $value );
+ $value = $value[ $key ];
+ }
+
+ return $value;
+ }
+
/**
* Get a nested array value.
*
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ValidationExceptionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ValidationExceptionTest.php
new file mode 100644
index 00000000000..ccbd4be3206
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ValidationExceptionTest.php
@@ -0,0 +1,223 @@
+<?php
+/**
+ * Integration tests for the facade validation exception and its message map.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use ReflectionClass;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException
+ */
+class ValidationExceptionTest extends EngineIntegrationTestCase {
+
+ /**
+ * Params a message for each code needs.
+ */
+ private const SAMPLE_PARAMS = array(
+ ValidationErrorCode::REQUIRED => array(),
+ ValidationErrorCode::INVALID_TYPE => array( 'type' => 'string' ),
+ ValidationErrorCode::OUT_OF_BOUNDS => array( 'min' => 1 ),
+ ValidationErrorCode::NOT_IN_ENUM => array( 'allowed' => array( 'day', 'week' ) ),
+ ValidationErrorCode::INVALID_VALUE => array(),
+ ValidationErrorCode::NOT_REGISTERED => array( 'value' => 'seasonal' ),
+ );
+
+ /**
+ * Translated message for a Core error.
+ *
+ * @param string $code Error code.
+ * @param string $field Field.
+ * @param array<string, mixed> $params Params.
+ */
+ private static function message_for( string $code, string $field, array $params = array() ): string {
+ $e = ValidationException::from_core( new InvalidInputException( $code, $field, 'English.', $params ) );
+
+ return $e->get_errors()->get_error_message( $code );
+ }
+
+ /**
+ * @testdox Every error code has its own message, and no message names the field.
+ */
+ public function test_every_error_code_has_a_message(): void {
+ $codes = ( new ReflectionClass( ValidationErrorCode::class ) )->getConstants();
+ $generic = self::message_for( 'acme_unknown', 'name' );
+
+ $this->assertNotEmpty( $codes );
+ foreach ( $codes as $code ) {
+ $this->assertIsString( $code );
+ $this->assertArrayHasKey( $code, self::SAMPLE_PARAMS, sprintf( 'Add sample params and a message for "%s".', $code ) );
+
+ $message = self::message_for( $code, 'acme_field', self::SAMPLE_PARAMS[ $code ] );
+
+ $this->assertStringNotContainsString( 'acme_field', $message, $code );
+ if ( ValidationErrorCode::INVALID_VALUE !== $code ) {
+ $this->assertNotSame( $generic, $message, sprintf( '"%s" falls back to the generic message.', $code ) );
+ }
+ }
+ }
+
+ /**
+ * @testdox The messages interpolate the params.
+ */
+ public function test_the_messages_interpolate_the_params(): void {
+ $this->assertSame( 'This field is required.', self::message_for( ValidationErrorCode::REQUIRED, 'name' ) );
+ $this->assertSame( 'Must be of type integer.', self::message_for( ValidationErrorCode::INVALID_TYPE, 'interval', array( 'type' => 'integer' ) ) );
+ $this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::INVALID_VALUE, 'currency' ) );
+ $this->assertSame( '"seasonal" is not registered.', self::message_for( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'seasonal' ) ) );
+ }
+
+ /**
+ * @testdox not_in_enum lists the allowed values with translated separators.
+ */
+ public function test_not_in_enum_lists_the_allowed_values_with_translated_separators(): void {
+ $allowed = array( 'allowed' => array( 'day', 'week', 'month', 'year' ) );
+
+ $this->assertSame( wp_sprintf( 'Must be one of %l.', $allowed['allowed'] ), self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', $allowed ) );
+ $this->assertSame( 'Must be one of day, week, month, and year.', self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', $allowed ) );
+
+ add_filter(
+ 'gettext',
+ static function ( $translation, $text, $domain ) {
+ if ( 'default' !== $domain ) {
+ return $translation;
+ }
+ if ( '%1$s, %2$s' === $text ) {
+ return '%1$s; %2$s';
+ }
+
+ return '%1$s, and %2$s' === $text ? '%1$s or %2$s' : $translation;
+ },
+ 10,
+ 3
+ );
+
+ $this->assertSame( 'Must be one of day; week; month or year.', self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', $allowed ) );
+ }
+
+ /**
+ * @testdox out_of_bounds picks its message by the bounds present.
+ */
+ public function test_out_of_bounds_picks_its_message_by_the_bounds_present(): void {
+ $this->assertSame( 'Must be greater than or equal to 1.', self::message_for( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 1 ) ) );
+ $this->assertSame( 'Must be less than or equal to 2.', self::message_for( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'max' => 2 ) ) );
+ $this->assertSame(
+ 'Must be between 0 and 100.',
+ self::message_for(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'value',
+ array(
+ 'min' => 0,
+ 'max' => 100,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox An unknown code, or params a message cannot use, get the generic message.
+ */
+ public function test_an_unknown_code_gets_the_generic_message(): void {
+ $this->assertSame( 'This value is not valid.', self::message_for( 'acme_unknown', 'name' ) );
+ $this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::OUT_OF_BOUNDS, 'limit' ) );
+ $this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::INVALID_TYPE, 'name', array( 'type' => 3 ) ) );
+ $this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => 'day' ) ) );
+ }
+
+ /**
+ * @testdox from_core keeps the English message, chains the Core error and carries field and params data.
+ *
+ * @throws ValidationException Thrown and caught as an InvalidArgumentException.
+ */
+ public function test_from_core_keeps_the_english_message_and_chains_the_core_error(): void {
+ $core = new InvalidInputException( ValidationErrorCode::NOT_REGISTERED, 'status', 'Plan: status "<b>" is not registered.', array( 'value' => '<b>' ) );
+
+ try {
+ throw ValidationException::from_core( $core );
+ } catch ( InvalidArgumentException $caught ) {
+ $e = $caught;
+ }
+
+ $this->assertInstanceOf( ValidationException::class, $e, 'Callers catching InvalidArgumentException keep working.' );
+ $this->assertSame( esc_html( $core->getMessage() ), $e->getMessage() );
+ $this->assertSame( $core, $e->getPrevious() );
+ $this->assertSame( array( ValidationErrorCode::NOT_REGISTERED ), $e->get_errors()->get_error_codes() );
+ $this->assertSame(
+ array(
+ 'field' => 'status',
+ 'params' => array( 'value' => '<b>' ),
+ ),
+ $e->get_errors()->get_error_data( ValidationErrorCode::NOT_REGISTERED )
+ );
+ }
+
+ /**
+ * @testdox from_core adds one entry per Core error, each with its own field and params.
+ */
+ public function test_from_core_adds_one_entry_per_core_error(): void {
+ $core = InvalidInputException::from_errors(
+ array(
+ new InvalidInputException( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', 'Interval.', array( 'min' => 1 ) ),
+ new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Name.' ),
+ new InvalidInputException( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', 'Min cycles.', array( 'max' => 2 ) ),
+ )
+ );
+
+ $e = ValidationException::from_core( $core );
+ $errors = $e->get_errors();
+
+ $this->assertSame( $core, $e->getPrevious() );
+ $this->assertSame( 'Interval. Name. Min cycles.', $e->getMessage() );
+ $this->assertSame( array( ValidationErrorCode::OUT_OF_BOUNDS, ValidationErrorCode::REQUIRED ), $errors->get_error_codes() );
+ $this->assertSame(
+ array( 'Must be greater than or equal to 1.', 'Must be less than or equal to 2.' ),
+ $errors->get_error_messages( ValidationErrorCode::OUT_OF_BOUNDS )
+ );
+ $this->assertSame(
+ array(
+ array(
+ 'field' => 'interval',
+ 'params' => array( 'min' => 1 ),
+ ),
+ array(
+ 'field' => 'min_cycles',
+ 'params' => array( 'max' => 2 ),
+ ),
+ ),
+ $errors->get_all_error_data( ValidationErrorCode::OUT_OF_BOUNDS )
+ );
+ $this->assertSame(
+ array(
+ 'field' => 'name',
+ 'params' => array(),
+ ),
+ $errors->get_error_data( ValidationErrorCode::REQUIRED )
+ );
+ }
+
+ /**
+ * @testdox The message is translated in the woocommerce text domain.
+ */
+ public function test_the_message_is_translated(): void {
+ add_filter(
+ 'gettext',
+ static function ( $translation, $text, $domain ) {
+ return 'woocommerce' === $domain && 'This field is required.' === $text ? 'Ce champ est obligatoire.' : $translation;
+ },
+ 10,
+ 3
+ );
+
+ $this->assertSame( 'Ce champ est obligatoire.', self::message_for( ValidationErrorCode::REQUIRED, 'name' ) );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/AssertsValidationErrors.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/AssertsValidationErrors.php
new file mode 100644
index 00000000000..30cddeab3a6
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/AssertsValidationErrors.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Assertion helper for facade validation errors.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration;
+
+use WP_Error;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
+
+/**
+ * Asserts that a facade call is refused with given codes, fields and params.
+ */
+trait AssertsValidationErrors {
+
+ /**
+ * Run the operation and assert it throws a ValidationException carrying exactly the
+ * expected errors, in `WP_Error` order (codes in first-seen order, then each code's
+ * errors in addition order). Each expected entry is `array( code, field )`, or
+ * `array( code, field, params )` to compare the params too; a fieldless error has a
+ * null field. Every error must carry data: `WP_Error` skips empty data, so a data-less
+ * error cannot be paired and fails the assertion.
+ *
+ * @param callable $operation Operation expected to throw.
+ * @param array<int, mixed> $expected Expected errors.
+ * @return ValidationException The caught exception, for further assertions.
+ */
+ protected function assert_validation_errors( callable $operation, array $expected ): ValidationException {
+ try {
+ $operation();
+ } catch ( ValidationException $e ) {
+ $this->assertSame( $expected, self::describe_validation_errors( $e->get_errors(), $expected ), 'Errors' );
+ return $e;
+ }
+
+ $this->fail( sprintf( 'Expected a ValidationException with %d errors.', count( $expected ) ) );
+ }
+
+ /**
+ * The errors in the expected entries' shape, each message paired with its own data.
+ *
+ * @param WP_Error $errors Validation errors.
+ * @param array<int, mixed> $expected Expected errors, for which entries compare params.
+ * @return array<int, array<int, mixed>>
+ */
+ private static function describe_validation_errors( WP_Error $errors, array $expected ): array {
+ $actual = array();
+ foreach ( $errors->get_error_codes() as $code ) {
+ $all_data = array_values( $errors->get_all_error_data( $code ) );
+ $messages = array_values( $errors->get_error_messages( $code ) );
+ self::assertCount( count( $messages ), $all_data, sprintf( 'Every "%s" error must carry data to be paired with it.', $code ) );
+
+ foreach ( $messages as $index => $message ) {
+ self::assertNotSame( '', $message, sprintf( 'An error "%s" has no message.', $code ) );
+
+ $data = $all_data[ $index ];
+ $entry = array( $code, is_array( $data ) && isset( $data['field'] ) ? $data['field'] : null );
+ $slot = $expected[ count( $actual ) ] ?? null;
+ if ( is_array( $slot ) && array_key_exists( 2, $slot ) ) {
+ $entry[] = is_array( $data ) && isset( $data['params'] ) ? $data['params'] : null;
+ }
+ $actual[] = $entry;
+ }
+ }
+
+ return $actual;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index 010c71c9475..114a8192af9 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
@@ -18,6 +18,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
@@ -1517,8 +1519,11 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
try {
$this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, 'never-registered', array( 'order_id' => 4242 ) );
- $this->fail( 'Expected a DomainException for an unregistered target status.' );
- } catch ( \DomainException $e ) {
+ $this->fail( 'Expected an InvalidInputException for an unregistered target status.' );
+ } catch ( InvalidInputException $e ) {
+ $this->assertSame( ValidationErrorCode::NOT_REGISTERED, $e->get_error_code() );
+ $this->assertSame( 'to_status', $e->get_field() );
+ $this->assertSame( array( 'value' => 'never-registered' ), $e->get_params() );
$this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentFilterTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentFilterTest.php
new file mode 100644
index 00000000000..e0bea828928
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentFilterTest.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Integration tests for the shared facade unknown-key filtering.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Support;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter
+ */
+class ArgumentFilterTest extends EngineIntegrationTestCase {
+
+ public function test_filter_known_keys_drops_unknown_keys_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( 'Acme::write' );
+ $messages = array();
+ add_action(
+ 'doing_it_wrong_run',
+ static function ( $function_name, $message ) use ( &$messages ): void {
+ $messages[] = $message;
+ },
+ 10,
+ 2
+ );
+
+ $filtered = ArgumentFilter::filter_known_keys(
+ 'Acme::write',
+ array(
+ 'known' => 1,
+ 'other' => 2,
+ ),
+ array( 'known' => true ),
+ 'item key'
+ );
+
+ $this->assertSame( array( 'known' => 1 ), $filtered );
+ $this->assertSame( array( 'Unknown item key "other" ignored.' ), $messages );
+ }
+
+ public function test_rows_keep_their_keys_and_drop_unknown_keys_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( 'facade' );
+
+ $rows = ArgumentFilter::filter_known_keys_in_rows(
+ 'facade',
+ array(
+ 'billing' => array(
+ 'first_name' => 'Ada',
+ 'zip' => '1',
+ ),
+ 'shipping' => array( 'first_name' => 'Grace' ),
+ ),
+ array( 'first_name' => true ),
+ 'address key'
+ );
+
+ $this->assertSame(
+ array(
+ 'billing' => array( 'first_name' => 'Ada' ),
+ 'shipping' => array( 'first_name' => 'Grace' ),
+ ),
+ $rows
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
deleted file mode 100644
index 9671edfa1bc..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
+++ /dev/null
@@ -1,164 +0,0 @@
-<?php
-/**
- * Integration tests for the shared facade argument validators.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Support;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use EngineIntegrationTestCase;
-use InvalidArgumentException;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator
- */
-class ArgumentValidatorTest extends EngineIntegrationTestCase {
-
- /**
- * @dataProvider provide_valid_values
- *
- * @param string $method Validator method.
- * @param array<int, mixed> $args Validator arguments.
- * @param mixed $expected Normalized value.
- */
- public function test_a_valid_value_is_returned_normalized( string $method, array $args, $expected ): void {
- $this->assertSame( $expected, ArgumentValidator::$method( ...$args ) );
- }
-
- /**
- * @return array<string, array{0: string, 1: array<int, mixed>, 2: mixed}>
- */
- public function provide_valid_values(): array {
- return array(
- 'currency' => array( 'validate_currency', array( 'EUR' ), 'EUR' ),
- 'string' => array( 'validate_string', array( 'status', 'active' ), 'active' ),
- 'nullable string' => array( 'validate_nullable_string', array( 'title', null ), null ),
- 'nullable id as digits' => array( 'validate_nullable_id', array( 'order_id', '42' ), 42 ),
- 'nullable date object' => array(
- 'validate_nullable_date',
- array( 'start_gmt', new DateTimeImmutable( '2026-01-01 03:00:00', new DateTimeZone( 'Europe/Berlin' ) ) ),
- '2026-01-01 02:00:00',
- ),
- 'money' => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
- 'money null is zero' => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
- 'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
- 'non-negative int zero' => array( 'validate_non_negative_int', array( 'offset', 0 ), 0 ),
- 'non-negative digits' => array( 'validate_non_negative_int', array( 'offset', '7' ), 7 ),
- 'id list' => array( 'validate_id_list', array( 'ids', array( 3, '4' ) ), array( 3, 4 ) ),
- 'empty id list' => array( 'validate_id_list', array( 'ids', array() ), array() ),
- 'string as list' => array( 'validate_string_list', array( 'status', 'active' ), array( 'active' ) ),
- 'string list' => array( 'validate_string_list', array( 'status', array( 'active', 'archived' ) ), array( 'active', 'archived' ) ),
- 'nullable array' => array( 'validate_nullable_array', array( 'billing_policy', array( 'period' => 'month' ) ), array( 'period' => 'month' ) ),
- 'nullable array null' => array( 'validate_nullable_array', array( 'billing_policy', null ), null ),
- );
- }
-
- /**
- * @dataProvider provide_invalid_values
- *
- * @param string $method Validator method.
- * @param array<int, mixed> $args Validator arguments.
- * @param string $message Expected exception message.
- */
- public function test_an_invalid_value_is_rejected_naming_the_field( string $method, array $args, string $message ): void {
- $this->expectException( InvalidArgumentException::class );
- $this->expectExceptionMessage( $message );
-
- ArgumentValidator::$method( ...$args );
- }
-
- /**
- * @return array<string, array{0: string, 1: array<int, mixed>, 2: string}>
- */
- public function provide_invalid_values(): array {
- return array(
- 'currency' => array( 'validate_currency', array( 'eur' ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
- 'string' => array( 'validate_string', array( 'status', 5 ), '"status" must be a string.' ),
- 'nullable string' => array( 'validate_nullable_string', array( 'title', 5 ), '"title" must be null or a string.' ),
- 'nullable id' => array( 'validate_nullable_id', array( 'order_id', 0 ), '"order_id" must be null or a positive integer.' ),
- 'nullable date' => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), '"start_gmt" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.' ),
- 'money' => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
- 'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
- 'negative int' => array( 'validate_non_negative_int', array( 'offset', -1 ), '"offset" must be a non-negative integer.' ),
- 'id list entry' => array( 'validate_id_list', array( 'ids', array( 3, 0 ) ), '"ids" must be a list of positive integers.' ),
- 'id list null' => array( 'validate_id_list', array( 'ids', array( null ) ), '"ids" must be a list of positive integers.' ),
- 'id list map' => array( 'validate_id_list', array( 'ids', array( 'a' => 3 ) ), '"ids" must be a list of positive integers.' ),
- 'id newline' => array( 'validate_nullable_id', array( 'order_id', "5\n" ), '"order_id" must be null or a positive integer.' ),
- 'currency newline' => array( 'validate_currency', array( "EUR\n" ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
- 'string list' => array( 'validate_string_list', array( 'status', array( 'active', '' ) ), '"status" must be a non-empty string or a list of them.' ),
- 'string list int' => array( 'validate_string_list', array( 'status', 5 ), '"status" must be a non-empty string or a list of them.' ),
- 'nullable array' => array( 'validate_nullable_array', array( 'billing_policy', 'monthly' ), '"billing_policy" must be null or an array.' ),
- );
- }
-
- public function test_filter_known_keys_drops_unknown_keys_with_a_notice(): void {
- $this->setExpectedIncorrectUsage( 'Acme::write' );
- $messages = array();
- add_action(
- 'doing_it_wrong_run',
- static function ( $function_name, $message ) use ( &$messages ): void {
- $messages[] = $message;
- },
- 10,
- 2
- );
-
- $filtered = ArgumentValidator::filter_known_keys(
- 'Acme::write',
- array(
- 'known' => 1,
- 'other' => 2,
- ),
- array( 'known' => true ),
- 'item key'
- );
-
- $this->assertSame( array( 'known' => 1 ), $filtered );
- $this->assertSame( array( 'Unknown item key "other" ignored.' ), $messages );
- }
-
- public function test_contract_items_keep_item_fields_and_drop_unknown_keys_with_a_notice(): void {
- $this->setExpectedIncorrectUsage( 'facade' );
-
- $rows = ArgumentValidator::validate_contract_items(
- array(
- array(
- 'item_name' => 'Coffee',
- 'price' => '1',
- ),
- ),
- 'facade'
- );
-
- $this->assertSame( array( array( 'item_name' => 'Coffee' ) ), $rows );
- }
-
- public function test_contract_addresses_reject_an_unknown_address_type(): void {
- $this->expectException( InvalidArgumentException::class );
- $this->expectExceptionMessage( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
-
- ArgumentValidator::validate_contract_addresses( array( 'home' => array( 'first_name' => 'Ada' ) ) );
- }
-
- public function test_contract_addresses_keep_address_fields_and_drop_unknown_keys_with_a_notice(): void {
- $this->setExpectedIncorrectUsage( 'facade' );
-
- $addresses = ArgumentValidator::validate_contract_addresses(
- array(
- 'billing' => array(
- 'first_name' => 'Ada',
- 'zip' => '1',
- ),
- ),
- 'facade'
- );
-
- $this->assertSame( array( 'billing' => array( 'first_name' => 'Ada' ) ), $addresses );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInternalInvariant.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInternalInvariant.php
new file mode 100644
index 00000000000..ea9fe31b021
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInternalInvariant.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Assertion helper for internal invariants.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit;
+
+use LogicException;
+
+/**
+ * Asserts that an operation breaks an internal invariant: a plain LogicException, not a
+ * validation error (which is a LogicException subclass too).
+ */
+trait AssertsInternalInvariant {
+
+ /**
+ * Run the operation and assert it throws exactly a LogicException.
+ *
+ * @param callable $operation Operation expected to throw.
+ * @return LogicException The caught exception, for further assertions.
+ */
+ protected function assert_internal_invariant( callable $operation ): LogicException {
+ try {
+ $operation();
+ } catch ( LogicException $e ) {
+ $this->assertSame( LogicException::class, get_class( $e ), 'An internal invariant throws a plain LogicException.' );
+ return $e;
+ }
+
+ $this->fail( 'Expected a LogicException.' );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInvalidInput.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInvalidInput.php
new file mode 100644
index 00000000000..777e6cff73a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInvalidInput.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Assertion helper for Core validation errors.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+
+/**
+ * Asserts that an operation is refused with given codes, fields and params.
+ */
+trait AssertsInvalidInput {
+
+ /**
+ * Run the operation and assert it throws an InvalidInputException carrying exactly one
+ * error, with the given code, field and params.
+ *
+ * @param callable $operation Operation expected to throw.
+ * @param string $code Expected error code.
+ * @param string $field Expected field.
+ * @param array<string, mixed> $params Expected params.
+ * @return InvalidInputException The caught exception, for further assertions.
+ */
+ protected function assert_invalid_input( callable $operation, string $code, string $field, array $params = array() ): InvalidInputException {
+ return $this->assert_invalid_inputs( $operation, array( array( $code, $field, $params ) ) );
+ }
+
+ /**
+ * Run the operation and assert it throws an InvalidInputException carrying exactly the
+ * expected errors, in order. Each expected entry is `array( code, field )`, or
+ * `array( code, field, params )` to compare the params too.
+ *
+ * @param callable $operation Operation expected to throw.
+ * @param array<int, mixed> $expected Expected errors.
+ * @return InvalidInputException The caught exception, for further assertions.
+ */
+ protected function assert_invalid_inputs( callable $operation, array $expected ): InvalidInputException {
+ try {
+ $operation();
+ } catch ( InvalidInputException $e ) {
+ $this->assertSame( $expected, self::describe_errors( $e, $expected ), 'Errors' );
+ return $e;
+ }
+
+ $this->fail( sprintf( 'Expected an InvalidInputException with %d errors.', count( $expected ) ) );
+ }
+
+ /**
+ * The exception's errors in the expected entries' shape.
+ *
+ * @param InvalidInputException $e Caught exception.
+ * @param array<int, mixed> $expected Expected errors, for which entries compare params.
+ * @return array<int, array<int, mixed>>
+ */
+ private static function describe_errors( InvalidInputException $e, array $expected ): array {
+ $actual = array();
+ foreach ( $e->get_errors() as $index => $error ) {
+ $entry = array( $error->get_error_code(), $error->get_field() );
+ if ( isset( $expected[ $index ] ) && is_array( $expected[ $index ] ) && array_key_exists( 2, $expected[ $index ] ) ) {
+ $entry[] = $error->get_params();
+ }
+ $actual[] = $entry;
+ }
+
+ return $actual;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
index 5dc3ddbe532..8d3c744bb12 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -15,19 +15,23 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract
*/
class ContractTest extends TestCase {
+ use AssertsInvalidInput;
+
protected function tearDown(): void {
StatusRegistry::reset();
parent::tearDown();
@@ -101,15 +105,20 @@ class ContractTest extends TestCase {
* @testdox create() rejects a non-zero total without a currency.
*/
public function test_create_rejects_a_non_zero_total_without_a_currency(): void {
- $this->expectException( DomainException::class );
- $this->expectExceptionMessage( 'money totals require a currency' );
-
- Contract::create(
- array(
- 'extension_slug' => 'acme-subs',
- 'billing_total' => '10',
- )
+ $e = $this->assert_invalid_input(
+ static function (): void {
+ Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'billing_total' => '10',
+ )
+ );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'currency'
);
+
+ $this->assertStringContainsString( 'money totals require a currency', $e->getMessage() );
}
/**
@@ -123,7 +132,7 @@ class ContractTest extends TestCase {
)
);
- $contract->assert_money_has_currency();
+ $this->assertNull( $contract->get_money_currency_error() );
$this->assertNull( $contract->get_currency() );
}
@@ -140,27 +149,61 @@ class ContractTest extends TestCase {
);
$contract->set_currency( null );
- $this->expectException( DomainException::class );
- $contract->assert_money_has_currency();
+ $error = $contract->get_money_currency_error();
+ $this->assertInstanceOf( InvalidInputException::class, $error );
+ $this->assertSame( ValidationErrorCode::REQUIRED, $error->get_error_code() );
+ $this->assertSame( 'currency', $error->get_field() );
+ }
+
+ /**
+ * @testdox create() reports every invalid attribute in one exception.
+ */
+ public function test_create_reports_every_invalid_attribute(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'status' => 'never-registered',
+ 'schedule_source' => 'Not a slug',
+ 'billing_total' => '10',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'never-registered' ) ),
+ array( ValidationErrorCode::INVALID_VALUE, 'schedule_source', array() ),
+ array( ValidationErrorCode::REQUIRED, 'currency', array() ),
+ )
+ );
}
/**
* @testdox create() requires an extension_slug.
*/
public function test_create_requires_an_extension_slug(): void {
- $this->expectException( DomainException::class );
- $this->expectExceptionMessage( 'Contract: extension_slug is required' );
+ $e = $this->assert_invalid_input(
+ static function (): void {
+ Contract::create( array() );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'extension_slug'
+ );
- Contract::create( array() );
+ $this->assertStringContainsString( 'Contract: extension_slug is required', $e->getMessage() );
}
/**
* @testdox create() rejects an empty extension_slug.
*/
public function test_create_rejects_an_empty_extension_slug(): void {
- $this->expectException( DomainException::class );
-
- Contract::create( array( 'extension_slug' => '' ) );
+ $this->assert_invalid_input(
+ static function (): void {
+ Contract::create( array( 'extension_slug' => '' ) );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'extension_slug'
+ );
}
/**
@@ -218,10 +261,15 @@ class ContractTest extends TestCase {
public function test_set_schedule_source_rejects_an_invalid_slug( string $schedule_source ): void {
$contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
- $this->expectException( DomainException::class );
- $this->expectExceptionMessage( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ) );
+ $e = $this->assert_invalid_input(
+ static function () use ( $contract, $schedule_source ): void {
+ $contract->set_schedule_source( $schedule_source );
+ },
+ ValidationErrorCode::INVALID_VALUE,
+ 'schedule_source'
+ );
- $contract->set_schedule_source( $schedule_source );
+ $this->assertSame( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ), $e->getMessage() );
}
/**
@@ -346,18 +394,23 @@ class ContractTest extends TestCase {
* @testdox create() rejects an invalid status.
*/
public function test_create_rejects_an_invalid_status(): void {
- $this->expectException( DomainException::class );
-
- Contract::create(
- array(
- 'extension_slug' => 'acme-subs',
- 'customer_id' => 1,
- 'currency' => 'USD',
- 'selling_plan_id' => 2,
- 'origin_order_id' => 3,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'status' => 'nonsense',
- )
+ $this->assert_invalid_input(
+ static function (): void {
+ Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'status' => 'nonsense',
+ )
+ );
+ },
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ array( 'value' => 'nonsense' )
);
}
@@ -365,18 +418,22 @@ class ContractTest extends TestCase {
* @testdox create() rejects an invalid schedule source.
*/
public function test_create_rejects_an_invalid_schedule_source(): void {
- $this->expectException( DomainException::class );
-
- Contract::create(
- array(
- 'extension_slug' => 'acme-subs',
- 'customer_id' => 1,
- 'currency' => 'USD',
- 'selling_plan_id' => 2,
- 'origin_order_id' => 3,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'schedule_source' => 'Not a slug',
- )
+ $this->assert_invalid_input(
+ static function (): void {
+ Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'schedule_source' => 'Not a slug',
+ )
+ );
+ },
+ ValidationErrorCode::INVALID_VALUE,
+ 'schedule_source'
);
}
@@ -632,8 +689,11 @@ class ContractTest extends TestCase {
try {
$contract->set_status( 'never-registered' );
- $this->fail( 'Expected a DomainException for an unregistered status.' );
- } catch ( DomainException $e ) {
+ $this->fail( 'Expected an InvalidInputException for an unregistered status.' );
+ } catch ( InvalidInputException $e ) {
+ $this->assertSame( ValidationErrorCode::NOT_REGISTERED, $e->get_error_code() );
+ $this->assertSame( 'status', $e->get_field() );
+ $this->assertSame( array( 'value' => 'never-registered' ), $e->get_params() );
$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
}
}
@@ -711,31 +771,38 @@ class ContractTest extends TestCase {
* @testdox create() rejects an empty, over-long or non-string creation key.
* @dataProvider invalid_creation_keys
*
- * @param mixed $creation_key Invalid creation key.
- */
- public function test_create_rejects_an_invalid_creation_key( $creation_key ): void {
- $this->expectException( DomainException::class );
-
- Contract::create(
- array(
- 'extension_slug' => 'acme-subs',
- 'creation_key' => $creation_key,
- )
+ * @param mixed $creation_key Invalid creation key.
+ * @param string $code Expected error code.
+ * @param array<string, mixed> $params Expected error params.
+ */
+ public function test_create_rejects_an_invalid_creation_key( $creation_key, string $code, array $params ): void {
+ $this->assert_invalid_input(
+ static function () use ( $creation_key ): void {
+ Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'creation_key' => $creation_key,
+ )
+ );
+ },
+ $code,
+ 'creation_key',
+ $params
);
}
/**
* Invalid creation keys.
*
- * @return array<string, array{0: mixed}>
+ * @return array<string, array{0: mixed, 1: string, 2: array<string, mixed>}>
*/
public function invalid_creation_keys(): array {
return array(
- 'empty' => array( '' ),
- 'only space' => array( " \t" ),
- '192 chars' => array( str_repeat( 'k', 192 ) ),
- '101 chars' => array( str_repeat( 'k', 101 ) ),
- 'non-string' => array( 42 ),
+ 'empty' => array( '', ValidationErrorCode::INVALID_VALUE, array() ),
+ 'only space' => array( " \t", ValidationErrorCode::INVALID_VALUE, array() ),
+ '192 chars' => array( str_repeat( 'k', 192 ), ValidationErrorCode::INVALID_VALUE, array() ),
+ '101 chars' => array( str_repeat( 'k', 101 ), ValidationErrorCode::INVALID_VALUE, array() ),
+ 'non-string' => array( 42, ValidationErrorCode::INVALID_TYPE, array( 'type' => 'string' ) ),
);
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
index a5878a1fd91..45becb5c33a 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
@@ -9,16 +9,19 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus
*/
class CycleStatusTest extends TestCase {
+ use AssertsInvalidInput;
+
protected function tearDown(): void {
StatusRegistry::reset();
parent::tearDown();
@@ -64,9 +67,13 @@ class CycleStatusTest extends TestCase {
* @param string $slug Malformed slug.
*/
public function test_constructor_rejects_a_malformed_slug( string $slug ): void {
- $this->expectException( DomainException::class );
-
- new CycleStatus( $slug );
+ $this->assert_invalid_input(
+ static function () use ( $slug ): void {
+ new CycleStatus( $slug );
+ },
+ ValidationErrorCode::INVALID_VALUE,
+ 'status'
+ );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
index 6797c6623d4..21e5e91e683 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -9,20 +9,24 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-use DomainException;
-use LogicException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInternalInvariant;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle
*/
class CycleTest extends TestCase {
+ use AssertsInternalInvariant;
+ use AssertsInvalidInput;
+
protected function tearDown(): void {
StatusRegistry::reset();
parent::tearDown();
@@ -87,16 +91,21 @@ class CycleTest extends TestCase {
}
public function test_create_requires_a_contract_id(): void {
- $this->expectException( DomainException::class );
-
- Cycle::create(
- array(
- 'sequence_no' => 1,
- 'starts_at_gmt' => '2026-02-01 00:00:00',
- 'ends_at_gmt' => '2026-03-01 00:00:00',
- 'expected_total' => '20.00',
- 'currency' => 'USD',
- )
+ $this->assert_invalid_input(
+ function (): void {
+ Cycle::create(
+ array(
+ 'sequence_no' => 1,
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ )
+ );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'contract_id',
+ array( 'min' => 1 )
);
}
@@ -115,35 +124,67 @@ class CycleTest extends TestCase {
}
public function test_create_rejects_an_empty_kind(): void {
- $this->expectException( DomainException::class );
-
- $this->make_pending( array( 'kind' => '' ) );
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'kind' => '' ) );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'kind'
+ );
}
public function test_create_rejects_a_non_positive_sequence_no(): void {
- $this->expectException( DomainException::class );
-
- $this->make_pending( array( 'sequence_no' => 0 ) );
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'sequence_no' => 0 ) );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'sequence_no',
+ array( 'min' => 1 )
+ );
}
- public function test_create_requires_a_start_and_an_end(): void {
- $this->expectException( DomainException::class );
- $this->expectExceptionMessage( 'starts_at_gmt and ends_at_gmt are required' );
+ public function test_create_requires_a_start(): void {
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'starts_at_gmt' => null ) );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'starts_at_gmt'
+ );
+ }
- $this->make_pending( array( 'ends_at_gmt' => null ) );
+ public function test_create_requires_an_end(): void {
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'ends_at_gmt' => null ) );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'ends_at_gmt'
+ );
}
public function test_create_requires_a_currency(): void {
- $this->expectException( DomainException::class );
- $this->expectExceptionMessage( 'currency is required' );
+ $e = $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'currency' => null ) );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'currency'
+ );
- $this->make_pending( array( 'currency' => null ) );
+ $this->assertStringContainsString( 'currency is required', $e->getMessage() );
}
public function test_create_rejects_a_non_positive_count(): void {
- $this->expectException( DomainException::class );
-
- $this->make_pending( array( 'count' => 0 ) );
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'count' => 0 ) );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'count',
+ array( 'min' => 1 )
+ );
}
public function test_create_allows_a_null_count_for_a_non_counting_cycle(): void {
@@ -155,9 +196,14 @@ class CycleTest extends TestCase {
}
public function test_create_rejects_an_invalid_status(): void {
- $this->expectException( DomainException::class );
-
- $this->make_pending( array( 'status' => 'nonsense' ) );
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'status' => 'nonsense' ) );
+ },
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ array( 'value' => 'nonsense' )
+ );
}
public function test_create_carries_order_id_and_owner(): void {
@@ -213,9 +259,14 @@ class CycleTest extends TestCase {
}
public function test_create_rejects_an_unregistered_status_instance(): void {
- $this->expectException( DomainException::class );
-
- $this->make_pending( array( 'status' => new CycleStatus( 'nope' ) ) );
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_pending( array( 'status' => new CycleStatus( 'nope' ) ) );
+ },
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ array( 'value' => 'nope' )
+ );
}
public function test_period_boundaries_are_frozen_at_construction(): void {
@@ -241,16 +292,22 @@ class CycleTest extends TestCase {
$cycle = $this->make_pending();
$cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) ) );
- $this->expectException( DomainException::class );
- $cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 10 ) ) );
+ $this->assert_internal_invariant(
+ static function () use ( $cycle ): void {
+ $cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 10 ) ) );
+ }
+ );
}
public function test_items_snapshot_vo_cannot_be_replaced(): void {
$cycle = $this->make_pending();
$cycle->set_items_snapshot( ItemsSnapshot::from_items( array() ) );
- $this->expectException( DomainException::class );
- $cycle->set_items_snapshot( ItemsSnapshot::from_items( array( array( 'product_id' => 1 ) ) ) );
+ $this->assert_internal_invariant(
+ static function () use ( $cycle ): void {
+ $cycle->set_items_snapshot( ItemsSnapshot::from_items( array( array( 'product_id' => 1 ) ) ) );
+ }
+ );
}
public function test_from_storage_hydrates_a_persisted_cycle(): void {
@@ -375,6 +432,47 @@ class CycleTest extends TestCase {
);
}
+ public function test_create_reports_every_invalid_attribute(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ Cycle::create(
+ array(
+ 'contract_id' => 0,
+ 'status' => 'Not A Slug',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'contract_id', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::REQUIRED, 'starts_at_gmt', array() ),
+ array( ValidationErrorCode::REQUIRED, 'ends_at_gmt', array() ),
+ array( ValidationErrorCode::REQUIRED, 'currency', array() ),
+ array( ValidationErrorCode::INVALID_VALUE, 'status', array() ),
+ )
+ );
+ }
+
+ public function test_create_reports_an_unregistered_status_with_the_other_errors(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ Cycle::create(
+ array(
+ 'contract_id' => 7,
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'currency' => 'USD',
+ 'count' => 0,
+ 'status' => 'never-registered',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'count', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'never-registered' ) ),
+ )
+ );
+ }
+
public function test_create_without_a_sequence_no_leaves_it_to_the_append(): void {
$this->assertTrue( $this->make_unpositioned()->awaits_sequence_no() );
$this->assertFalse( $this->make_pending()->awaits_sequence_no() );
@@ -389,9 +487,11 @@ class CycleTest extends TestCase {
}
public function test_an_unassigned_sequence_no_cannot_be_read(): void {
- $this->expectException( LogicException::class );
-
- $this->make_unpositioned()->get_sequence_no();
+ $this->assert_internal_invariant(
+ function (): void {
+ $this->make_unpositioned()->get_sequence_no();
+ }
+ );
}
public function test_the_sequence_no_can_be_assigned_once(): void {
@@ -404,15 +504,22 @@ class CycleTest extends TestCase {
}
public function test_assign_sequence_no_rejects_a_non_positive_value(): void {
- $this->expectException( DomainException::class );
-
- $this->make_unpositioned()->assign_sequence_no( 0 );
+ $this->assert_invalid_input(
+ function (): void {
+ $this->make_unpositioned()->assign_sequence_no( 0 );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'sequence_no',
+ array( 'min' => 1 )
+ );
}
public function test_an_assigned_sequence_no_cannot_be_reassigned(): void {
- $this->expectException( DomainException::class );
-
- $this->make_pending()->assign_sequence_no( 2 );
+ $this->assert_internal_invariant(
+ function (): void {
+ $this->make_pending()->assign_sequence_no( 2 );
+ }
+ );
}
public function test_id_can_be_stamped_after_persistence(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
index a66398c4ef4..8927f847213 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
@@ -9,17 +9,20 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan
*/
class PlanTest extends TestCase {
+ use AssertsInvalidInput;
+
protected function tearDown(): void {
StatusRegistry::reset();
parent::tearDown();
@@ -147,16 +150,22 @@ class PlanTest extends TestCase {
* @param mixed $value Bad value.
*/
public function test_create_rejects_a_non_object_policy( string $field, $value ): void {
- $this->expectException( DomainException::class );
- $this->expectExceptionMessage( $field );
-
- Plan::create(
- array(
- 'extension_slug' => 'my-ext',
- 'name' => 'Bad',
- $field => $value,
- )
+ $e = $this->assert_invalid_input(
+ static function () use ( $field, $value ): void {
+ Plan::create(
+ array(
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Bad',
+ $field => $value,
+ )
+ );
+ },
+ ValidationErrorCode::INVALID_TYPE,
+ $field,
+ array( 'type' => 'object' )
);
+
+ $this->assertStringContainsString( $field, $e->getMessage() );
}
/**
@@ -206,29 +215,78 @@ class PlanTest extends TestCase {
*
* @param array<string, mixed> $args Create args.
* @param string $message Expected message.
+ * @param string $field Expected field.
*/
- public function test_create_requires_a_name_and_an_extension_slug( array $args, string $message ): void {
- $this->expectException( DomainException::class );
- $this->expectExceptionMessage( $message );
+ public function test_create_requires_a_name_and_an_extension_slug( array $args, string $message, string $field ): void {
+ $e = $this->assert_invalid_input(
+ static function () use ( $args ): void {
+ Plan::create( $args );
+ },
+ ValidationErrorCode::REQUIRED,
+ $field
+ );
- Plan::create( $args );
+ $this->assertSame( $message, $e->getMessage() );
}
/**
- * @return array<string, array{0: array<string, mixed>, 1: string}>
+ * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
*/
public function provide_missing_required_args(): array {
return array(
- 'no name' => array( array( 'extension_slug' => 'my-ext' ), 'Plan: name is required and must be a non-empty string.' ),
+ 'no name' => array( array( 'extension_slug' => 'my-ext' ), 'Plan: name is required and must be a non-empty string.', 'name' ),
'blank name' => array(
array(
'extension_slug' => 'my-ext',
'name' => ' ',
),
'Plan: name is required and must be a non-empty string.',
+ 'name',
),
- 'no extension slug' => array( array( 'name' => 'Box' ), 'Plan: extension_slug is required and must be a non-empty string.' ),
+ 'no extension slug' => array( array( 'name' => 'Box' ), 'Plan: extension_slug is required and must be a non-empty string.', 'extension_slug' ),
+ );
+ }
+
+ /**
+ * @testdox create reports every invalid attribute in one exception.
+ */
+ public function test_create_reports_every_invalid_attribute(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ Plan::create(
+ array(
+ 'name' => '',
+ 'status' => 'never-registered',
+ 'pricing_policy' => array( 'a', 'b' ),
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'name', array() ),
+ array( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'never-registered' ) ),
+ array( ValidationErrorCode::REQUIRED, 'extension_slug', array() ),
+ array( ValidationErrorCode::INVALID_TYPE, 'pricing_policy', array( 'type' => 'object' ) ),
+ )
+ );
+ }
+
+ /**
+ * @testdox draft keeps refused values without checking them.
+ */
+ public function test_draft_keeps_refused_values(): void {
+ $plan = Plan::draft(
+ array(
+ 'id' => 4,
+ 'name' => '',
+ 'status' => 'never-registered',
+ 'extension_slug' => 'my-ext',
+ )
);
+
+ $this->assertSame( 4, $plan->get_id() );
+ $this->assertSame( '', $plan->get_name() );
+ $this->assertSame( 'never-registered', $plan->get_status() );
+ $this->assertSame( 'my-ext', $plan->get_extension_slug() );
}
/**
@@ -237,9 +295,13 @@ class PlanTest extends TestCase {
public function test_set_name_rejects_an_empty_name(): void {
$plan = self::create_plan( 'Named' );
- $this->expectException( DomainException::class );
-
- $plan->set_name( '' );
+ $this->assert_invalid_input(
+ static function () use ( $plan ): void {
+ $plan->set_name( '' );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'name'
+ );
}
/**
@@ -252,9 +314,14 @@ class PlanTest extends TestCase {
$plan = self::create_plan( 'Bad' );
$setter = 'set_' . $field;
- $this->expectException( DomainException::class );
-
- $plan->{$setter}( array( 'a', 'b' ) );
+ $this->assert_invalid_input(
+ static function () use ( $plan, $setter ): void {
+ $plan->{$setter}( array( 'a', 'b' ) );
+ },
+ ValidationErrorCode::INVALID_TYPE,
+ $field,
+ array( 'type' => 'object' )
+ );
}
/**
@@ -287,14 +354,19 @@ class PlanTest extends TestCase {
* @testdox create rejects an unregistered status.
*/
public function test_create_rejects_an_unregistered_status(): void {
- $this->expectException( DomainException::class );
-
- Plan::create(
- array(
- 'extension_slug' => 'my-ext',
- 'name' => 'Unknown',
- 'status' => 'seasonal',
- )
+ $this->assert_invalid_input(
+ static function (): void {
+ Plan::create(
+ array(
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Unknown',
+ 'status' => 'seasonal',
+ )
+ );
+ },
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ array( 'value' => 'seasonal' )
);
}
@@ -304,9 +376,14 @@ class PlanTest extends TestCase {
public function test_set_status_rejects_an_unregistered_status(): void {
$plan = self::create_plan( 'Unknown' );
- $this->expectException( DomainException::class );
-
- $plan->set_status( 'seasonal' );
+ $this->assert_invalid_input(
+ static function () use ( $plan ): void {
+ $plan->set_status( 'seasonal' );
+ },
+ ValidationErrorCode::NOT_REGISTERED,
+ 'status',
+ array( 'value' => 'seasonal' )
+ );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/ArgumentValidatorTest.php
new file mode 100644
index 00000000000..370480302a0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/ArgumentValidatorTest.php
@@ -0,0 +1,269 @@
+<?php
+/**
+ * Unit tests for the Core argument checks. Running under the WordPress-free unit
+ * bootstrap proves they stay in the Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator
+ */
+class ArgumentValidatorTest extends TestCase {
+
+ use AssertsInvalidInput;
+
+ /**
+ * @testdox A valid value is returned normalized.
+ * @dataProvider provide_valid_values
+ *
+ * @param string $method Validator method.
+ * @param array<int, mixed> $args Validator arguments.
+ * @param mixed $expected Normalized value.
+ */
+ public function test_a_valid_value_is_returned_normalized( string $method, array $args, $expected ): void {
+ $this->assertSame( $expected, ArgumentValidator::$method( ...$args ) );
+ }
+
+ /**
+ * @return array<string, array{0: string, 1: array<int, mixed>, 2: mixed}>
+ */
+ public function provide_valid_values(): array {
+ return array(
+ 'currency' => array( 'validate_currency', array( 'EUR' ), 'EUR' ),
+ 'null currency' => array( 'validate_currency', array( null ), null ),
+ 'string' => array( 'validate_string', array( 'status', 'active' ), 'active' ),
+ 'non-empty string' => array( 'validate_non_empty_string', array( 'extension_slug', 'acme' ), 'acme' ),
+ 'nullable string' => array( 'validate_nullable_string', array( 'title', null ), null ),
+ 'nullable id as digits' => array( 'validate_nullable_id', array( 'order_id', '42' ), 42 ),
+ 'nullable id null' => array( 'validate_nullable_id', array( 'order_id', null ), null ),
+ 'nullable date object' => array(
+ 'validate_nullable_date',
+ array( 'start_gmt', new DateTimeImmutable( '2026-01-01 03:00:00', new DateTimeZone( 'Europe/Berlin' ) ) ),
+ '2026-01-01 02:00:00',
+ ),
+ 'nullable date string' => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-28 00:00:00' ), '2026-02-28 00:00:00' ),
+ 'money' => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
+ 'money null is zero' => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
+ 'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
+ 'positive int' => array( 'validate_positive_int', array( 'quantity', 900 ), 900 ),
+ 'positive int digits' => array( 'validate_positive_int', array( 'quantity', '60' ), 60 ),
+ 'non-negative int zero' => array( 'validate_non_negative_int', array( 'offset', 0 ), 0 ),
+ 'non-negative digits' => array( 'validate_non_negative_int', array( 'offset', '7' ), 7 ),
+ 'id list' => array( 'validate_id_list', array( 'ids', array( 3, '4' ) ), array( 3, 4 ) ),
+ 'empty id list' => array( 'validate_id_list', array( 'ids', array() ), array() ),
+ 'string as list' => array( 'validate_string_list', array( 'status', 'active' ), array( 'active' ) ),
+ 'string list' => array( 'validate_string_list', array( 'status', array( 'active', 'archived' ) ), array( 'active', 'archived' ) ),
+ 'nullable array' => array( 'validate_nullable_array', array( 'billing_policy', array( 'period' => 'month' ) ), array( 'period' => 'month' ) ),
+ 'nullable array null' => array( 'validate_nullable_array', array( 'billing_policy', null ), null ),
+ 'addresses' => array(
+ 'validate_contract_addresses',
+ array( array( 'billing' => array( 'first_name' => 'Ada' ) ) ),
+ array( 'billing' => array( 'first_name' => 'Ada' ) ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox An invalid value is refused with its code, field and params.
+ * @dataProvider provide_invalid_values
+ *
+ * @param string $method Validator method.
+ * @param array<int, mixed> $args Validator arguments.
+ * @param string $code Expected error code.
+ * @param string $field Expected field.
+ * @param array<string, mixed> $params Expected params.
+ */
+ public function test_an_invalid_value_is_refused_with_its_code( string $method, array $args, string $code, string $field, array $params ): void {
+ $this->assert_invalid_input(
+ static function () use ( $method, $args ): void {
+ ArgumentValidator::$method( ...$args );
+ },
+ $code,
+ $field,
+ $params
+ );
+ }
+
+ /**
+ * @testdox The English message quotes the field and says what the value must be.
+ */
+ public function test_the_english_message_quotes_the_field(): void {
+ $e = $this->assert_invalid_input(
+ static function (): void {
+ ArgumentValidator::validate_positive_int( 'quantity', 0 );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'quantity',
+ array( 'min' => 1 )
+ );
+ $this->assertSame( '"quantity" must be a positive integer.', $e->getMessage() );
+
+ $e = $this->assert_invalid_input(
+ static function (): void {
+ ArgumentValidator::validate_id_list( 'ids', array( 3, 0 ) );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'ids.1',
+ array( 'min' => 1 )
+ );
+ $this->assertSame( '"ids.1" must be a positive integer.', $e->getMessage() );
+ }
+
+ /**
+ * @return array<string, array{0: string, 1: array<int, mixed>, 2: string, 3: string, 4: array<string, mixed>}>
+ */
+ public function provide_invalid_values(): array {
+ $type_string = array( 'type' => 'string' );
+ $type_integer = array( 'type' => 'integer' );
+ $type_array = array( 'type' => 'array' );
+ $type_object = array( 'type' => 'object' );
+ $min_one = array( 'min' => 1 );
+
+ return array(
+ 'currency' => array( 'validate_currency', array( 'eur' ), ValidationErrorCode::INVALID_VALUE, 'currency', array() ),
+ 'currency newline' => array( 'validate_currency', array( "EUR\n" ), ValidationErrorCode::INVALID_VALUE, 'currency', array() ),
+ 'string' => array( 'validate_string', array( 'status', 5 ), ValidationErrorCode::INVALID_TYPE, 'status', $type_string ),
+ 'nullable string' => array( 'validate_nullable_string', array( 'title', 5 ), ValidationErrorCode::INVALID_TYPE, 'title', $type_string ),
+ 'non-empty not string' => array( 'validate_non_empty_string', array( 'extension_slug', 5 ), ValidationErrorCode::INVALID_TYPE, 'extension_slug', $type_string ),
+ 'non-empty null' => array( 'validate_non_empty_string', array( 'extension_slug', null ), ValidationErrorCode::REQUIRED, 'extension_slug', array() ),
+ 'non-empty empty' => array( 'validate_non_empty_string', array( 'extension_slug', '' ), ValidationErrorCode::REQUIRED, 'extension_slug', array() ),
+ 'positive int type' => array( 'validate_positive_int', array( 'quantity', null ), ValidationErrorCode::INVALID_TYPE, 'quantity', $type_integer ),
+ 'positive int zero' => array( 'validate_positive_int', array( 'quantity', 0 ), ValidationErrorCode::OUT_OF_BOUNDS, 'quantity', $min_one ),
+ 'nullable id type' => array( 'validate_nullable_id', array( 'order_id', "5\n" ), ValidationErrorCode::INVALID_TYPE, 'order_id', $type_integer ),
+ 'nullable id zero' => array( 'validate_nullable_id', array( 'order_id', 0 ), ValidationErrorCode::OUT_OF_BOUNDS, 'order_id', $min_one ),
+ 'non-negative type' => array( 'validate_non_negative_int', array( 'offset', 1.5 ), ValidationErrorCode::INVALID_TYPE, 'offset', $type_integer ),
+ 'non-negative negative' => array( 'validate_non_negative_int', array( 'offset', -1 ), ValidationErrorCode::OUT_OF_BOUNDS, 'offset', array( 'min' => 0 ) ),
+ 'id list map' => array( 'validate_id_list', array( 'ids', array( 'a' => 3 ) ), ValidationErrorCode::INVALID_TYPE, 'ids', $type_array ),
+ 'id list entry zero' => array( 'validate_id_list', array( 'ids', array( 3, 0 ) ), ValidationErrorCode::OUT_OF_BOUNDS, 'ids.1', $min_one ),
+ 'id list entry null' => array( 'validate_id_list', array( 'ids', array( null ) ), ValidationErrorCode::INVALID_TYPE, 'ids.0', $type_integer ),
+ 'string list not list' => array( 'validate_string_list', array( 'status', 5 ), ValidationErrorCode::INVALID_TYPE, 'status', $type_array ),
+ 'string list empty' => array( 'validate_string_list', array( 'status', '' ), ValidationErrorCode::REQUIRED, 'status', array() ),
+ 'string list entry' => array( 'validate_string_list', array( 'status', array( 'active', '' ) ), ValidationErrorCode::REQUIRED, 'status.1', array() ),
+ 'string list entry int' => array( 'validate_string_list', array( 'status', array( 5 ) ), ValidationErrorCode::INVALID_TYPE, 'status.0', $type_string ),
+ 'nullable array' => array( 'validate_nullable_array', array( 'billing_policy', 'monthly' ), ValidationErrorCode::INVALID_TYPE, 'billing_policy', $type_object ),
+ 'list of arrays map' => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), ValidationErrorCode::INVALID_TYPE, 'items', $type_array ),
+ 'list of arrays entry' => array( 'validate_list_of_arrays', array( 'items', array( 'a' ) ), ValidationErrorCode::INVALID_TYPE, 'items.0', $type_object ),
+ 'money' => array( 'validate_money', array( 'tax_total', 'ten' ), ValidationErrorCode::INVALID_TYPE, 'tax_total', array( 'type' => 'number' ) ),
+ 'nullable date' => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), ValidationErrorCode::INVALID_VALUE, 'start_gmt', array() ),
+ 'addresses not array' => array( 'validate_contract_addresses', array( 'home' ), ValidationErrorCode::INVALID_TYPE, 'addresses', $type_object ),
+ 'address unknown type' => array(
+ 'validate_contract_addresses',
+ array( array( 'home' => array( 'first_name' => 'Ada' ) ) ),
+ ValidationErrorCode::NOT_IN_ENUM,
+ 'addresses.home',
+ array( 'allowed' => array( 'billing', 'shipping' ) ),
+ ),
+ 'address not array' => array( 'validate_contract_addresses', array( array( 'billing' => 'Ada' ) ), ValidationErrorCode::INVALID_TYPE, 'addresses.billing', $type_object ),
+ );
+ }
+
+ /**
+ * @testdox A list check reports every failed item.
+ */
+ public function test_a_list_check_reports_every_failed_item(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ ArgumentValidator::validate_id_list( 'ids', array( 1, 0, 'x' ) );
+ },
+ array(
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'ids.1', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::INVALID_TYPE, 'ids.2', array( 'type' => 'integer' ) ),
+ )
+ );
+ $this->assert_invalid_inputs(
+ static function (): void {
+ ArgumentValidator::validate_string_list( 'status', array( '', 'active', 5 ) );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'status.0' ),
+ array( ValidationErrorCode::INVALID_TYPE, 'status.2' ),
+ )
+ );
+ $this->assert_invalid_inputs(
+ static function (): void {
+ ArgumentValidator::validate_list_of_arrays( 'items', array( 'a', array(), 'b' ) );
+ },
+ array(
+ array( ValidationErrorCode::INVALID_TYPE, 'items.0' ),
+ array( ValidationErrorCode::INVALID_TYPE, 'items.2' ),
+ )
+ );
+ $this->assert_invalid_inputs(
+ static function (): void {
+ ArgumentValidator::validate_contract_addresses(
+ array(
+ 'home' => array(),
+ 'billing' => 'x',
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::NOT_IN_ENUM, 'addresses.home', array( 'allowed' => array( 'billing', 'shipping' ) ) ),
+ array( ValidationErrorCode::INVALID_TYPE, 'addresses.billing', array( 'type' => 'object' ) ),
+ )
+ );
+ }
+
+ /**
+ * @testdox A value that is not a list reports only the key itself.
+ */
+ public function test_a_non_list_reports_only_the_key(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ ArgumentValidator::validate_id_list( 'ids', array( 'a' => 0 ) );
+ },
+ array( array( ValidationErrorCode::INVALID_TYPE, 'ids', array( 'type' => 'array' ) ) )
+ );
+ }
+
+ /**
+ * @testdox The collector returns normalized values and throws nothing when nothing failed.
+ */
+ public function test_the_collector_returns_normalized_values(): void {
+ $validator = new ArgumentValidator();
+
+ $this->assertSame( 42, $validator->get_nullable_id( 'order_id', '42' ) );
+ $this->assertSame( array( 3, 4 ), $validator->get_id_list( 'ids', array( 3, '4' ) ) );
+ $this->assertSame( '1.50000000', $validator->get_money( 'tax_total', '1.5' ) );
+ $this->assertSame( 'EUR', $validator->get_currency( 'EUR' ) );
+
+ $validator->throw_errors();
+ }
+
+ /**
+ * @testdox The collector returns null for a refused value and throws every error in call order.
+ */
+ public function test_the_collector_throws_every_error_in_call_order(): void {
+ $validator = new ArgumentValidator();
+
+ $this->assertNull( $validator->get_string( 'name', 5 ) );
+ $this->assertNull( $validator->get_positive_int( 'limit', 0 ) );
+ $validator->add( new InvalidInputException( ValidationErrorCode::INVALID_VALUE, 'extension_slug', 'Not "any".' ) );
+ $this->assertNull( $validator->get_nullable_array( 'billing_policy', 'monthly' ) );
+
+ $this->assert_invalid_inputs(
+ static function () use ( $validator ): void {
+ $validator->throw_errors();
+ },
+ array(
+ array( ValidationErrorCode::INVALID_TYPE, 'name', array( 'type' => 'string' ) ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::INVALID_VALUE, 'extension_slug', array() ),
+ array( ValidationErrorCode::INVALID_TYPE, 'billing_policy', array( 'type' => 'object' ) ),
+ )
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php
new file mode 100644
index 00000000000..7b8c8d86b35
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Unit tests for the Core validation exception.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use DomainException;
+use LogicException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException
+ */
+class InvalidInputExceptionTest extends TestCase {
+
+ /**
+ * @testdox The getters return the constructor values and the message stays English.
+ *
+ * @throws InvalidInputException Thrown and caught as a DomainException.
+ */
+ public function test_getters_return_the_constructor_values(): void {
+ try {
+ throw new InvalidInputException(
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'trial_duration.length',
+ 'BillingPolicy: trial length must be positive.',
+ array( 'min' => 1 )
+ );
+ } catch ( DomainException $caught ) {
+ $e = $caught;
+ }
+
+ $this->assertInstanceOf( InvalidInputException::class, $e, 'Callers catching DomainException keep working.' );
+ $this->assertSame( 'out_of_bounds', $e->get_error_code() );
+ $this->assertSame( 'trial_duration.length', $e->get_field() );
+ $this->assertSame( array( 'min' => 1 ), $e->get_params() );
+ $this->assertSame( 'BillingPolicy: trial length must be positive.', $e->getMessage() );
+ }
+
+ /**
+ * @testdox Params default to an empty array.
+ */
+ public function test_params_default_to_empty(): void {
+ $e = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+
+ $this->assertSame( array(), $e->get_params() );
+ }
+
+ /**
+ * @testdox A single exception lists itself as its only error.
+ */
+ public function test_a_single_exception_lists_itself(): void {
+ $e = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+
+ $this->assertSame( array( $e ), $e->get_errors() );
+ }
+
+ /**
+ * @testdox from_errors returns a single error as is.
+ */
+ public function test_from_errors_returns_a_single_error_as_is(): void {
+ $e = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+
+ $this->assertSame( $e, InvalidInputException::from_errors( array( $e ) ) );
+ }
+
+ /**
+ * @testdox from_errors keeps the first error's getters, joins the messages and lists every error in order.
+ */
+ public function test_from_errors_combines_several_errors(): void {
+ $name = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+ $interval = new InvalidInputException( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', 'Interval too low.', array( 'min' => 1 ) );
+
+ $e = InvalidInputException::from_errors( array( $name, $interval ) );
+
+ $this->assertSame( ValidationErrorCode::REQUIRED, $e->get_error_code() );
+ $this->assertSame( 'name', $e->get_field() );
+ $this->assertSame( array(), $e->get_params() );
+ $this->assertSame( 'Plan: name is required. Interval too low.', $e->getMessage() );
+ $this->assertSame( array( $name, $interval ), $e->get_errors() );
+ }
+
+ /**
+ * @testdox from_errors flattens a combined error.
+ */
+ public function test_from_errors_flattens_a_combined_error(): void {
+ $a = new InvalidInputException( ValidationErrorCode::REQUIRED, 'a', 'A.' );
+ $b = new InvalidInputException( ValidationErrorCode::REQUIRED, 'b', 'B.' );
+ $c = new InvalidInputException( ValidationErrorCode::REQUIRED, 'c', 'C.' );
+
+ $e = InvalidInputException::from_errors( array( InvalidInputException::from_errors( array( $a, $b ) ), $c ) );
+
+ $this->assertSame( array( $a, $b, $c ), $e->get_errors() );
+ $this->assertSame( 'A. B. C.', $e->getMessage() );
+ }
+
+ /**
+ * @testdox from_errors refuses an empty list as an engine bug.
+ */
+ public function test_from_errors_refuses_an_empty_list(): void {
+ try {
+ InvalidInputException::from_errors( array() );
+ $this->fail( 'Expected a LogicException.' );
+ } catch ( LogicException $e ) {
+ $this->assertSame( LogicException::class, get_class( $e ) );
+ }
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/NoPlainDomainExceptionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/NoPlainDomainExceptionTest.php
new file mode 100644
index 00000000000..d85d11a2670
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/NoPlainDomainExceptionTest.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Guards that every validation throw carries a code.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use PHPUnit\Framework\TestCase;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use SplFileInfo;
+
+/**
+ * Scans the Core and Integration zones for plain DomainException throws.
+ *
+ * @coversNothing
+ */
+class NoPlainDomainExceptionTest extends TestCase {
+
+ /**
+ * @testdox Core and Integration throw no plain DomainException: validation errors carry a code.
+ */
+ public function test_core_and_integration_throw_no_plain_domain_exception(): void {
+ $offenders = array();
+ $src = dirname( __DIR__, 4 ) . '/src';
+
+ foreach ( array( $src . '/Core', $src . '/Integration' ) as $dir ) {
+ $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ) );
+ foreach ( $files as $file ) {
+ if ( ! $file instanceof SplFileInfo || 'php' !== $file->getExtension() ) {
+ continue;
+ }
+
+ $contents = (string) file_get_contents( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local source file.
+ if ( 1 === preg_match( '/new\s+\\\\?DomainException\s*\(/', $contents ) ) {
+ $offenders[] = substr( $file->getPathname(), strlen( $src ) + 1 );
+ }
+ }
+ }
+
+ $this->assertSame( array(), $offenders, 'Throw Core\Support\InvalidInputException (validation) or LogicException (internal invariant) instead.' );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
index 2ca3627a253..c727ec96aca 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
@@ -11,15 +11,234 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject
use DateTimeImmutable;
use DateTimeZone;
-use DomainException;
use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy
*/
class BillingPolicyTest extends TestCase {
+ use AssertsInvalidInput;
+
+ /**
+ * @testdox Each refusal reports its code, field and params.
+ * @dataProvider provide_refusals
+ *
+ * @param callable $operation Operation expected to be refused.
+ * @param string $code Expected error code.
+ * @param string $field Expected field.
+ * @param array<string, mixed> $params Expected params.
+ */
+ public function test_each_refusal_reports_its_code_field_and_params( callable $operation, string $code, string $field, array $params ): void {
+ $this->assert_invalid_input( $operation, $code, $field, $params );
+ }
+
+ /**
+ * @return array<string, array{0: callable, 1: string, 2: string, 3: array<string, mixed>}>
+ */
+ public function provide_refusals(): array {
+ $units = array( 'allowed' => array( 'day', 'week', 'month', 'year' ) );
+
+ return array(
+ 'interval not positive' => array(
+ static function (): void {
+ new BillingPolicy( 'month', 0, null, null, null );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'interval',
+ array( 'min' => 1 ),
+ ),
+ 'unknown period' => array(
+ static function (): void {
+ new BillingPolicy( 'fortnight', 1, null, null, null );
+ },
+ ValidationErrorCode::NOT_IN_ENUM,
+ 'period',
+ $units,
+ ),
+ 'period missing' => array(
+ static function (): void {
+ BillingPolicy::from_array( array( 'interval' => 1 ) );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'period',
+ array(),
+ ),
+ 'interval missing' => array(
+ static function (): void {
+ BillingPolicy::from_array( array( 'period' => 'month' ) );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'interval',
+ array(),
+ ),
+ 'period not a string' => array(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 1,
+ 'interval' => 1,
+ )
+ );
+ },
+ ValidationErrorCode::INVALID_TYPE,
+ 'period',
+ array( 'type' => 'string' ),
+ ),
+ 'interval not an integer' => array(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => '2',
+ )
+ );
+ },
+ ValidationErrorCode::INVALID_TYPE,
+ 'interval',
+ array( 'type' => 'integer' ),
+ ),
+ 'trial not an object' => array(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'trial_duration' => 'P7D',
+ )
+ );
+ },
+ ValidationErrorCode::INVALID_TYPE,
+ 'trial_duration',
+ array( 'type' => 'object' ),
+ ),
+ 'min_cycles negative' => array(
+ static function (): void {
+ new BillingPolicy( 'month', 1, -1, null, null );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'min_cycles',
+ array( 'min' => 0 ),
+ ),
+ 'max_cycles negative' => array(
+ static function (): void {
+ new BillingPolicy( 'month', 1, null, -1, null );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'max_cycles',
+ array( 'min' => 0 ),
+ ),
+ 'min_cycles above max' => array(
+ static function (): void {
+ new BillingPolicy( 'month', 1, 5, 2, null );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'min_cycles',
+ array( 'max' => 2 ),
+ ),
+ 'trial length missing' => array(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'trial_duration' => array( 'unit' => 'day' ),
+ )
+ );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'trial_duration.length',
+ array(),
+ ),
+ 'trial unit missing' => array(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'trial_duration' => array( 'length' => 7 ),
+ )
+ );
+ },
+ ValidationErrorCode::REQUIRED,
+ 'trial_duration.unit',
+ array(),
+ ),
+ 'trial length not integer' => array(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'trial_duration' => array(
+ 'length' => '7',
+ 'unit' => 'day',
+ ),
+ )
+ );
+ },
+ ValidationErrorCode::INVALID_TYPE,
+ 'trial_duration.length',
+ array( 'type' => 'integer' ),
+ ),
+ 'trial unit not a string' => array(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'trial_duration' => array(
+ 'length' => 7,
+ 'unit' => 1,
+ ),
+ )
+ );
+ },
+ ValidationErrorCode::INVALID_TYPE,
+ 'trial_duration.unit',
+ array( 'type' => 'string' ),
+ ),
+ 'trial length not positive' => array(
+ static function (): void {
+ new BillingPolicy(
+ 'month',
+ 1,
+ null,
+ null,
+ array(
+ 'length' => 0,
+ 'unit' => 'day',
+ )
+ );
+ },
+ ValidationErrorCode::OUT_OF_BOUNDS,
+ 'trial_duration.length',
+ array( 'min' => 1 ),
+ ),
+ 'unknown trial unit' => array(
+ static function (): void {
+ new BillingPolicy(
+ 'month',
+ 1,
+ null,
+ null,
+ array(
+ 'length' => 7,
+ 'unit' => 'fortnight',
+ )
+ );
+ },
+ ValidationErrorCode::NOT_IN_ENUM,
+ 'trial_duration.unit',
+ $units,
+ ),
+ );
+ }
+
/**
* @testdox from_array refuses an unusable cadence.
* @testWith ["decade", 1, "BillingPolicy: invalid period \"decade\"."]
@@ -36,7 +255,7 @@ class BillingPolicyTest extends TestCase {
'interval' => $interval,
);
- $this->expectException( DomainException::class );
+ $this->expectException( InvalidInputException::class );
$this->expectExceptionMessage( $message );
BillingPolicy::from_array( $data );
@@ -142,25 +361,90 @@ class BillingPolicyTest extends TestCase {
}
/**
- * @testdox construction refuses an unknown period.
+ * @testdox from_array reports every invalid value in one exception.
*/
- public function test_construction_refuses_an_unknown_period(): void {
- $this->expectException( DomainException::class );
+ public function test_from_array_reports_every_invalid_value(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'interval' => 0,
+ 'min_cycles' => -1,
+ 'trial_duration' => array( 'length' => 'x' ),
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::REQUIRED, 'period', array() ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'min' => 0 ) ),
+ array( ValidationErrorCode::INVALID_TYPE, 'trial_duration.length', array( 'type' => 'integer' ) ),
+ array( ValidationErrorCode::REQUIRED, 'trial_duration.unit', array() ),
+ )
+ );
+ }
- new BillingPolicy( 'fortnight', 1, null, null, null );
+ /**
+ * @testdox from_array reports a bad period, trial length and trial unit together.
+ */
+ public function test_from_array_reports_the_trial_values_with_the_other_fields(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ BillingPolicy::from_array(
+ array(
+ 'period' => 'fortnight',
+ 'interval' => 1,
+ 'trial_duration' => array(
+ 'length' => 0,
+ 'unit' => 'fortnight',
+ ),
+ )
+ );
+ },
+ array(
+ array( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'trial_duration.length', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::NOT_IN_ENUM, 'trial_duration.unit', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
+ )
+ );
}
/**
- * @testdox construction refuses a non-positive interval.
+ * @testdox The construction reports every invalid value in one exception.
*/
- public function test_construction_refuses_a_non_positive_interval(): void {
- $this->expectException( DomainException::class );
+ public function test_construction_reports_every_invalid_value(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ new BillingPolicy( 'fortnight', 0, null, -2, null );
+ },
+ array(
+ array( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', array( 'min' => 1 ) ),
+ array( ValidationErrorCode::OUT_OF_BOUNDS, 'max_cycles', array( 'min' => 0 ) ),
+ )
+ );
+ }
- new BillingPolicy( 'month', 0, null, null, null );
+ /**
+ * @testdox min_cycles above max_cycles is checked only when both bounds pass their own checks.
+ */
+ public function test_the_cycle_bounds_check_runs_only_on_valid_bounds(): void {
+ $this->assert_invalid_inputs(
+ static function (): void {
+ new BillingPolicy( 'month', 1, 5, 2, null );
+ },
+ array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'max' => 2 ) ) )
+ );
+ $this->assert_invalid_inputs(
+ static function (): void {
+ new BillingPolicy( 'month', 1, -1, 2, null );
+ },
+ array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'min' => 0 ) ) )
+ );
}
public function test_non_array_trial_duration_throws(): void {
- $this->expectException( DomainException::class );
+ $this->expectException( InvalidInputException::class );
$this->expectExceptionMessage( 'BillingPolicy: trial_duration must be null or an array, got string.' );
BillingPolicy::from_array(
@@ -180,7 +464,7 @@ class BillingPolicyTest extends TestCase {
*/
public function test_min_and_max_cycles_validation( ?string $expected_exception_message, ?int $min_cycles, ?int $max_cycles ): void {
if ( null !== $expected_exception_message ) {
- $this->expectException( DomainException::class );
+ $this->expectException( InvalidInputException::class );
$this->expectExceptionMessage( $expected_exception_message );
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
index fc62bb0a0cf..057eeaf815f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
@@ -9,15 +9,17 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
-use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInternalInvariant;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot
*/
class ItemsSnapshotTest extends TestCase {
+ use AssertsInternalInvariant;
+
/**
* A representative two-item set.
*
@@ -85,8 +87,10 @@ class ItemsSnapshotTest extends TestCase {
}
public function test_negative_schema_version_throws(): void {
- $this->expectException( DomainException::class );
-
- ItemsSnapshot::from_items( $this->sample_items(), -1 );
+ $this->assert_internal_invariant(
+ function (): void {
+ ItemsSnapshot::from_items( $this->sample_items(), -1 );
+ }
+ );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
index 3f727a58230..384c456d33d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -9,16 +9,19 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
-use DomainException;
use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInternalInvariant;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot
*/
class PlanSnapshotTest extends TestCase {
+ use AssertsInternalInvariant;
+
public function test_schema_version_defaults_to_one(): void {
$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
@@ -91,9 +94,11 @@ class PlanSnapshotTest extends TestCase {
}
public function test_negative_schema_version_throws(): void {
- $this->expectException( DomainException::class );
-
- PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 0 );
+ $this->assert_internal_invariant(
+ static function (): void {
+ PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 0 );
+ }
+ );
}
/**
@@ -133,7 +138,7 @@ class PlanSnapshotTest extends TestCase {
)
);
- $this->expectException( DomainException::class );
+ $this->expectException( InvalidInputException::class );
$snapshot->read_billing_policy();
}
@@ -159,7 +164,7 @@ class PlanSnapshotTest extends TestCase {
)
);
- $this->expectException( DomainException::class );
+ $this->expectException( InvalidInputException::class );
$snapshot->read_billing_policy();
}