diff --git a/modules/ppcp-agentic-commerce/docs/testing.md b/modules/ppcp-agentic-commerce/docs/testing.md new file mode 100644 index 000000000..3febadeca --- /dev/null +++ b/modules/ppcp-agentic-commerce/docs/testing.md @@ -0,0 +1,9 @@ +# Testing + +Unit tests for this module are located in the `tests/PHPUnit/AgenticCommerce` directory. + +To run them, use the command: + +```sh +ddev exec phpunit --filter AgenticCommerce +``` diff --git a/modules/ppcp-agentic-commerce/services.php b/modules/ppcp-agentic-commerce/services.php index 1e70859cd..8a8ba1b13 100644 --- a/modules/ppcp-agentic-commerce/services.php +++ b/modules/ppcp-agentic-commerce/services.php @@ -9,4 +9,11 @@ namespace WooCommerce\PayPalCommerce\AgenticCommerce; -return array(); +use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +use WooCommerce\PayPalCommerce\AgenticCommerce\Response\ResponseFactory; + +return array( + 'agentic.response.factory' => static function ( ContainerInterface $container ): ResponseFactory { + return new ResponseFactory(); + }, +); diff --git a/modules/ppcp-agentic-commerce/src/Errors/AgenticError.php b/modules/ppcp-agentic-commerce/src/Errors/AgenticError.php new file mode 100644 index 000000000..f7d26379a --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Errors/AgenticError.php @@ -0,0 +1,68 @@ +message = $message; + $this->details = $details; + } + + public function get_status_code(): int { + return static::STATUS_CODE; + } + + public function to_array(): array { + $data = array( + 'name' => static::ERROR_NAME, + 'message' => $this->message, + ); + + if ( $this->details ) { + $data['details'] = $this->details; + } + + return $data; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Errors/CartNotFoundError.php b/modules/ppcp-agentic-commerce/src/Errors/CartNotFoundError.php new file mode 100644 index 000000000..62bb8a1da --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Errors/CartNotFoundError.php @@ -0,0 +1,31 @@ +cart = $cart; + // todo - set the other props of this class, once the flow becomes more clear. + } + + public function to_array(): array { + // Always set via the constructor, but the IDE does not believe it. + assert( $this->cart instanceof PayPalCart ); + + $data = array( + 'id' => $this->cart_id, + 'status' => $this->status, + 'validation_status' => $this->validation_status, + 'validation_issues' => $this->cart->validate(), + ); + + return array_merge( $data, $this->cart->to_array() ); + } +} diff --git a/modules/ppcp-agentic-commerce/src/Response/NewCartResponse.php b/modules/ppcp-agentic-commerce/src/Response/NewCartResponse.php new file mode 100644 index 000000000..afca90c56 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Response/NewCartResponse.php @@ -0,0 +1,31 @@ +token = $token; + } + + public function to_array(): array { + $data = parent::to_array(); + + $data['payment_method'] = array( + 'type' => 'paypal', + 'token' => $this->token, + 'approval_url' => 'not-implemented', + ); + + return $data; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Response/PaidCartResponse.php b/modules/ppcp-agentic-commerce/src/Response/PaidCartResponse.php new file mode 100644 index 000000000..c3ba8b277 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Response/PaidCartResponse.php @@ -0,0 +1,38 @@ +wc_order = $wc_order; + } + + public function to_array(): array { + $data = parent::to_array(); + + if ( $this->wc_order ) { + $data['payment_confirmation'] = array( + 'merchant_order_number' => $this->wc_order->get_id(), + 'order_review_page' => $this->wc_order->get_checkout_order_received_url(), + ); + } + + return $data; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Response/ResponseFactory.php b/modules/ppcp-agentic-commerce/src/Response/ResponseFactory.php new file mode 100644 index 000000000..6a41919e6 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Response/ResponseFactory.php @@ -0,0 +1,27 @@ +country_code = null; + $this->address_line_1 = null; + $this->address_line_2 = null; + $this->admin_area_2 = null; + $this->admin_area_1 = null; + $this->postal_code = null; + + // Parse mandatory fields. + if ( isset( $input['country_code'] ) && is_string( $input['country_code'] ) ) { + $country_code = strtoupper( trim( $input['country_code'] ) ); + + if ( preg_match( '/^[A-Z]{2}$/', $country_code ) ) { + $this->country_code = $country_code; + } else { + $add_issue( new InvalidData( 'Unexpected country_code', 'Please provide a valid 2-letter country code.', 'country_code' ) ); + } + } else { + $add_issue( new InvalidData( 'Missing required field', 'Please provide a country code.', 'country_code' ) ); + } + + // Parse optional fields. + if ( isset( $input['address_line_1'] ) && is_string( $input['address_line_1'] ) ) { + $address_line_1 = trim( $input['address_line_1'] ); + + if ( strlen( $address_line_1 ) <= 300 ) { + $this->address_line_1 = $address_line_1; + } else { + $add_issue( new InvalidData( 'Field address_line_1 is too long', 'Please provide a valid address line 1.', 'address_line_1' ) ); + } + } + + if ( isset( $input['address_line_2'] ) && is_string( $input['address_line_2'] ) ) { + $address_line_2 = trim( $input['address_line_2'] ); + + if ( strlen( $address_line_2 ) <= 300 ) { + $this->address_line_2 = $address_line_2; + } else { + $add_issue( new InvalidData( 'Field address_line_2 is too long', 'Please provide a valid address line 2.', 'address_line_2' ) ); + } + } + + if ( isset( $input['admin_area_2'] ) && is_string( $input['admin_area_2'] ) ) { + $admin_area_2 = trim( $input['admin_area_2'] ); + + if ( strlen( $admin_area_2 ) <= 120 ) { + $this->admin_area_2 = $admin_area_2; + } else { + $add_issue( new InvalidData( 'Field admin_area_2 is too long', 'Please provide a valid city.', 'admin_area_2' ) ); + } + } + + if ( isset( $input['admin_area_1'] ) && is_string( $input['admin_area_1'] ) ) { + $admin_area_1 = trim( $input['admin_area_1'] ); + + if ( strlen( $admin_area_1 ) <= 300 ) { + $this->admin_area_1 = $admin_area_1; + } else { + $add_issue( new InvalidData( 'Field admin_area_1 is too long', 'Please provide a valid region or state.', 'admin_area_1' ) ); + } + } + + if ( isset( $input['postal_code'] ) && is_string( $input['postal_code'] ) ) { + $postal_code = trim( $input['postal_code'] ); + + if ( strlen( $postal_code ) <= 60 ) { + $this->postal_code = $postal_code; + } else { + $add_issue( new InvalidData( 'Field postal_code is too long', 'Please provide a valid postal code.', 'postal_code' ) ); + } + } + } + + public function country_code(): ?string { + return $this->country_code; + } + + public function address_line_1(): ?string { + return $this->address_line_1; + } + + public function address_line_2(): ?string { + return $this->address_line_2; + } + + /** + * The city. + */ + public function admin_area_2(): ?string { + return $this->admin_area_2; + } + + /** + * The region or state. + */ + public function admin_area_1(): ?string { + return $this->admin_area_1; + } + + public function postal_code(): ?string { + return $this->postal_code; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/AgenticSchema.php b/modules/ppcp-agentic-commerce/src/Schema/AgenticSchema.php new file mode 100644 index 000000000..f812ba2fc --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/AgenticSchema.php @@ -0,0 +1,94 @@ +raw_data = $raw_data; + } + + /** + * Performs the data validation during the object construction. + * + * @param array $input The raw input data. + * @param callable $add_issue The callback to add a new ValidationIssue. + */ + abstract protected function parse_fields( array $input, callable $add_issue ): void; + + /** + * Returns a list of validation errors or an empty array when the object is valid. + */ + final public function validate(): array { + return $this->validation_issues; + } + + /** + * Returns a key-value array that represents the object's internal state. + */ + final public function to_array(): array { + return $this->raw_data; + } + + /** + * Factory method to create a new object from the key-value array. + * + * @param array $data Key-value array. + * @param callable|null $add_issue The callback to add a new ValidationIssue; allows + * propagation of issues to the parent instance. + * @return static New instance, or error details. + */ + final public static function from_array( array $data, ?callable $add_issue = null ): self { + $instance = new static( $data ); + + if ( null === $add_issue ) { + $add_issue = static function ( ValidationIssue $issue ) use ( $instance ): void { + $instance->validation_issues[] = $issue; + }; + } + + $instance->parse_fields( $data, $add_issue ); + + return $instance; + } + + /** + * Helper that creates a new instance with a specific change based on the current object. + * + * @param array $changes Key-value set of changes to apply. + * @return static New instance, or error details. + */ + final public function with( array $changes ): self { + $current = $this->to_array(); + $merged = array_merge( $current, $changes ); + + return static::from_array( $merged ); + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/AppliedCoupon.php b/modules/ppcp-agentic-commerce/src/Schema/AppliedCoupon.php new file mode 100644 index 000000000..3a90dcbe1 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/AppliedCoupon.php @@ -0,0 +1,60 @@ +code = null; + $this->description = null; + $this->discount_amount = null; + + // Optional fields. + if ( isset( $input['code'] ) && is_string( $input['code'] ) ) { + $this->code = trim( $input['code'] ); + } + if ( isset( $input['description'] ) && is_string( $input['description'] ) ) { + $this->description = trim( $input['description'] ); + } + if ( isset( $input['discount_amount'] ) && is_array( $input['discount_amount'] ) ) { + $money = Money::from_array( $input['discount_amount'], $add_issue ); + + $issues = $money->validate(); + if ( empty( $issues ) ) { + $this->discount_amount = $money; + } else { + foreach ( $issues as $issue ) { + $add_issue( $issue ); + } + } + } + } + + public function code(): ?string { + return $this->code; + } + + public function description(): ?string { + return $this->description; + } + + public function discount_amount(): ?Money { + return $this->discount_amount; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/CartItem.php b/modules/ppcp-agentic-commerce/src/Schema/CartItem.php new file mode 100644 index 000000000..2f34a9035 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/CartItem.php @@ -0,0 +1,184 @@ +id = null; + $this->variant_id = null; + $this->parent_id = null; + $this->quantity = 0; + $this->name = null; + $this->description = null; + $this->price = null; + $this->selected_attributes = null; + $this->gift_options = null; + + // Parse mandatory fields. + if ( isset( $input['quantity'] ) && is_numeric( $input['quantity'] ) ) { + $quantity = (int) $input['quantity']; + + if ( $quantity < 1 || $quantity > 999 ) { + $add_issue( new InvalidData( 'Quantity is invalid', 'Item quantity must be between 1 and 999', 'quantity' ) ); + } else { + $this->quantity = $quantity; + } + } else { + $add_issue( new MissingField( 'Quantity missing', 'The quantity field is required.', 'quantity' ) ); + } + + // Parse optional fields. + if ( isset( $input['item_id'] ) && is_string( $input['item_id'] ) ) { + $id = trim( $input['item_id'] ); + + if ( strlen( $id ) > 127 ) { + $add_issue( new InvalidData( 'Item id too long', 'The item ID can be at most 127 characters long', 'item_id' ) ); + } else { + $this->id = $id; + } + } + + if ( isset( $input['variant_id'] ) && is_string( $input['variant_id'] ) ) { + $variant_id = trim( $input['variant_id'] ); + + if ( strlen( $variant_id ) > 127 ) { + $add_issue( new InvalidData( 'Variant id too long', 'The variant ID can be at most 127 characters long', 'variant_id' ) ); + } else { + $this->variant_id = $variant_id; + } + } + + if ( isset( $input['parent_id'] ) && is_string( $input['parent_id'] ) ) { + $parent_id = trim( $input['parent_id'] ); + + if ( strlen( $parent_id ) > 127 ) { + $add_issue( new InvalidData( 'Parent id too long', 'The parent ID can be at most 127 characters long', 'parent_id' ) ); + } else { + $this->parent_id = $parent_id; + } + } + + if ( isset( $input['name'] ) && is_string( $input['name'] ) ) { + $name = trim( $input['name'] ); + + if ( strlen( $name ) > 127 ) { + $add_issue( new InvalidData( 'Item name too long', 'The item name can be at most 127 characters long', 'name' ) ); + } else { + $this->name = $name; + } + } + + if ( isset( $input['description'] ) && is_string( $input['description'] ) ) { + $description = trim( $input['description'] ); + + if ( strlen( $description ) > 255 ) { + $add_issue( new InvalidData( 'Item description too long', 'The item description can be at most 127 characters long', 'description' ) ); + } else { + $this->description = $description; + } + } + + if ( isset( $input['price'] ) && is_array( $input['price'] ) ) { + $price = Money::from_array( $input['price'], $add_issue ); + + if ( $price->value() <= 0. ) { + $add_issue( new InvalidData( 'Item price is invalid', 'The item price is invalid', 'price' ) ); + } else { + $this->price = $price; + } + } + + if ( isset( $input['gift_options'] ) && is_array( $input['gift_options'] ) ) { + $this->gift_options = GiftOptions::from_array( $input['gift_options'], $add_issue ); + } + + if ( isset( $input['selected_attributes'] ) && is_array( $input['selected_attributes'] ) ) { + $attributes = $input['selected_attributes']; + + if ( count( $attributes ) > 10 ) { + $add_issue( new InvalidData( 'Too many attributes', 'The item can have at most 10 attributes', 'selected_attributes' ) ); + } else { + $attributes = array_filter( + $attributes, + static fn( $attribute ) => is_array( $attribute ) && ! empty( $attribute['name'] ) + ); + + $this->selected_attributes = array(); + foreach ( $attributes as $attribute ) { + $this->selected_attributes[] = array( + 'name' => $attribute['name'], + 'value' => $attribute['value'] ?? '', + ); + } + } + } + } + + public function item_id(): ?string { + return $this->id; + } + + public function variant_id(): ?string { + return $this->variant_id; + } + + public function parent_id(): ?string { + return $this->parent_id; + } + + public function quantity(): int { + return $this->quantity; + } + + public function name(): ?string { + return $this->name; + } + + public function description(): ?string { + return $this->description; + } + + public function price(): ?Money { + return $this->price; + } + + public function selected_attributes(): ?array { + return $this->selected_attributes; + } + + public function gift_options(): ?GiftOptions { + return $this->gift_options; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/CartTotals.php b/modules/ppcp-agentic-commerce/src/Schema/CartTotals.php new file mode 100644 index 000000000..201f68fd0 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/CartTotals.php @@ -0,0 +1,125 @@ +total = null; + $this->subtotal = null; + $this->discount = null; + $this->shipping = null; + $this->tax = null; + $this->handling = null; + $this->insurance = null; + $this->shipping_discount = null; + $this->custom_charges = null; + + // Required field: total. + if ( ! isset( $input['total'] ) || ! is_array( $input['total'] ) ) { + $add_issue( new MissingField( 'Total is required', 'Please provide a total amount', 'total' ) ); + } else { + $money = Money::from_array( $input['total'], $add_issue ); + $issues = $money->validate(); + + if ( empty( $issues ) ) { + $this->total = $money; + } else { + foreach ( $issues as $issue ) { + $add_issue( $issue ); + } + } + } + + // Optional Money fields. + $this->parse_optional_money_field( $input, 'subtotal', $add_issue ); + $this->parse_optional_money_field( $input, 'discount', $add_issue ); + $this->parse_optional_money_field( $input, 'shipping', $add_issue ); + $this->parse_optional_money_field( $input, 'tax', $add_issue ); + $this->parse_optional_money_field( $input, 'handling', $add_issue ); + $this->parse_optional_money_field( $input, 'insurance', $add_issue ); + $this->parse_optional_money_field( $input, 'shipping_discount', $add_issue ); + $this->parse_optional_money_field( $input, 'custom_charges', $add_issue ); + } + + private function parse_optional_money_field( array $input, string $field_name, callable $add_issue ): void { + if ( isset( $input[ $field_name ] ) && is_array( $input[ $field_name ] ) ) { + $money = Money::from_array( $input[ $field_name ], $add_issue ); + $issues = $money->validate(); + + if ( empty( $issues ) ) { + $this->$field_name = $money; + } else { + foreach ( $issues as $issue ) { + $add_issue( $issue ); + } + } + } + } + + public function total(): ?Money { + return $this->total; + } + + public function subtotal(): ?Money { + return $this->subtotal; + } + + public function discount(): ?Money { + return $this->discount; + } + + public function shipping(): ?Money { + return $this->shipping; + } + + public function tax(): ?Money { + return $this->tax; + } + + public function handling(): ?Money { + return $this->handling; + } + + public function insurance(): ?Money { + return $this->insurance; + } + + public function shipping_discount(): ?Money { + return $this->shipping_discount; + } + + public function custom_charges(): ?Money { + return $this->custom_charges; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/CheckoutField.php b/modules/ppcp-agentic-commerce/src/Schema/CheckoutField.php new file mode 100644 index 000000000..6510e93b0 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/CheckoutField.php @@ -0,0 +1,86 @@ +type = null; + $this->status = 'ERROR'; + $this->value = null; + $this->context = null; + + // Parse mandatory fields. + if ( ! empty( $input['type'] ) && is_string( $input['type'] ) ) { + $this->type = strtoupper( trim( $input['type'] ) ); + } else { + $add_issue( new MissingField( 'Type is required', 'The field type is mandatory', 'type' ) ); + } + + if ( ! empty( $input['status'] ) && is_string( $input['status'] ) ) { + $status = strtoupper( trim( $input['status'] ) ); + + if ( in_array( $status, self::VALID_STATUS, true ) ) { + $this->status = $status; + } else { + $add_issue( new InvalidData( 'Status is invalid', 'The status value is not supported', 'status' ) ); + } + } else { + $add_issue( new MissingField( 'Status is required', 'The field status is mandatory', 'status' ) ); + } + + // Parse optional fields. + if ( isset( $input['value'] ) && is_array( $input['value'] ) ) { + $this->value = $input['value']; + } + + if ( isset( $input['context'] ) && is_array( $input['context'] ) ) { + $this->context = $input['context']; + } + } + + public function type(): ?string { + return $this->type; + } + + public function status(): string { + return $this->status; + } + + public function value(): ?array { + return $this->value; + } + + public function context(): ?array { + return $this->context; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/Coupon.php b/modules/ppcp-agentic-commerce/src/Schema/Coupon.php new file mode 100644 index 000000000..35edc84e8 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/Coupon.php @@ -0,0 +1,54 @@ +code = null; + $this->action = null; + + if ( isset( $input['code'] ) && is_string( $input['code'] ) ) { + $this->code = trim( $input['code'] ); + } else { + $add_issue( new InvalidData( 'Missing required field', 'Please provide a coupon code.', 'code' ) ); + } + + if ( isset( $input['action'] ) && is_string( $input['action'] ) ) { + $action = strtoupper( trim( $input['action'] ) ); + $valid_actions = array( 'APPLY', 'REMOVE' ); + + if ( in_array( $action, $valid_actions, true ) ) { + $this->action = $action; + } else { + $add_issue( new InvalidData( 'Action must be APPLY or REMOVE', 'Please provide a valid action.', 'action' ) ); + } + } else { + $add_issue( new InvalidData( 'Missing required field', 'Please provide an action.', 'action' ) ); + } + } + + public function code(): ?string { + return $this->code; + } + + public function action(): ?string { + return $this->action; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/Customer.php b/modules/ppcp-agentic-commerce/src/Schema/Customer.php new file mode 100644 index 000000000..dcda7a3cd --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/Customer.php @@ -0,0 +1,115 @@ +email_address = null; + $this->name = null; + $this->phone = null; + + // Optional fields. + if ( isset( $input['email_address'] ) && is_string( $input['email_address'] ) ) { + $email_address = trim( $input['email_address'] ); + + if ( filter_var( $email_address, FILTER_VALIDATE_EMAIL ) ) { + $this->email_address = $email_address; + } else { + $add_issue( new InvalidData( 'Invalid email', 'The customers email address is not valid', 'email_address' ) ); + } + } + if ( isset( $input['name'] ) && is_array( $input['name'] ) ) { + $this->name = array( + 'given_name' => null, + 'surname' => null, + ); + + $given_name = $input['name']['given_name'] ?? null; + $surname = $input['name']['surname'] ?? null; + + if ( is_string( $given_name ) ) { + if ( strlen( $given_name ) > 140 ) { + $add_issue( new InvalidData( 'Given name too long', 'The customers given name cannot be longer than 140 characters', 'name.given_name' ) ); + } else { + $this->name['given_name'] = trim( $given_name ); + } + } + if ( is_string( $surname ) ) { + if ( strlen( $surname ) > 140 ) { + $add_issue( new InvalidData( 'Surname too long', 'The customers surname cannot be longer than 140 characters', 'name.surname' ) ); + } else { + $this->name['surname'] = trim( $surname ); + } + } + } + if ( isset( $input['phone'] ) && is_array( $input['phone'] ) ) { + $this->phone = array( + 'country_code' => null, + 'national_number' => null, + ); + + $country_code = $input['phone']['country_code'] ?? null; + $national_number = $input['phone']['national_number'] ?? null; + + if ( is_string( $country_code ) ) { + $country_code = trim( $country_code ); + + if ( ! is_numeric( $country_code ) || '0' === $country_code ) { + $add_issue( new InvalidData( 'Invalid country code format', 'The customers phone country-code must be numeric', 'phone.country_code' ) ); + } elseif ( strlen( $country_code ) > 3 ) { + $add_issue( new InvalidData( 'Invalid country code length', 'The customers phone country-code must have between 1 and 3 digits', 'phone.country_code' ) ); + } else { + $this->phone['country_code'] = trim( $country_code ); + } + } + if ( is_string( $national_number ) ) { + $national_number = trim( $national_number ); + + if ( ! is_numeric( $national_number ) ) { + $add_issue( new InvalidData( 'Invalid national number format', 'The customers phone number must be numeric', 'phone.national_number' ) ); + } elseif ( strlen( $national_number ) > 14 ) { + $add_issue( new InvalidData( 'Invalid national number length', 'The customers phone number must have between 1 and 3 digits', 'phone.national_number' ) ); + } else { + $this->phone['national_number'] = trim( $national_number ); + } + } + } + } + + public function email_address(): ?string { + return $this->email_address; + } + + /** + * @return null|array Customer name as array, no own schema. + */ + public function name(): ?array { + return $this->name; + } + + /** + * @return null|array Phone number as array, no own schema. + */ + public function phone(): ?array { + return $this->phone; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/GeoCoordinates.php b/modules/ppcp-agentic-commerce/src/Schema/GeoCoordinates.php new file mode 100644 index 000000000..fbc74c08c --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/GeoCoordinates.php @@ -0,0 +1,115 @@ +latitude = null; + $this->longitude = null; + $this->subdivision = null; + $this->country_code = null; + + // Parse optional fields. + if ( isset( $input['latitude'] ) ) { + $latitude = $input['latitude']; + + if ( is_int( $latitude ) ) { + $latitude = (float) $latitude; + } elseif ( is_string( $latitude ) ) { + $latitude = trim( $latitude ); + + if ( is_numeric( $latitude ) ) { + $latitude = (float) $latitude; + } + } + if ( is_float( $latitude ) ) { + if ( $latitude < - 90.0 || $latitude > 90.0 ) { + $add_issue( new InvalidData( 'Invalid latitude', 'Latitude must be a decimal value between -90.0 and 90.0', 'latitude' ) ); + } else { + $this->latitude = $latitude; + } + } else { + $add_issue( new InvalidData( 'Invalid latitude', 'Latitude must be a decimal value between -90.0 and 90.0', 'latitude' ) ); + } + } + if ( isset( $input['longitude'] ) ) { + $longitude = $input['longitude']; + + if ( is_int( $longitude ) ) { + $longitude = (float) $longitude; + } elseif ( is_string( $longitude ) ) { + $longitude = trim( $longitude ); + + if ( is_numeric( $longitude ) ) { + $longitude = (float) $longitude; + } + } + if ( is_float( $longitude ) ) { + if ( $longitude < - 180.0 || $longitude > 180.0 ) { + $add_issue( new InvalidData( 'Invalid longitude', 'Longitude must be a decimal value between -180.0 and 180.0', 'longitude' ) ); + } else { + $this->longitude = $longitude; + } + } else { + $add_issue( new InvalidData( 'Invalid longitude', 'Longitude must be a decimal value between -180.0 and 180.0', 'longitude' ) ); + } + } + if ( isset( $input['subdivision'] ) && is_string( $input['subdivision'] ) ) { + $subdivision = strtoupper( trim( $input['subdivision'] ) ); + + if ( strlen( $subdivision ) > 10 ) { + $add_issue( new InvalidData( 'Subdivision too long', 'The subdivision code must be in ISO 3166-2 format (no country code).', 'subdivision' ) ); + } elseif ( ! preg_match( '/^[A-Z0-9-]+$/', $subdivision ) ) { + $add_issue( new InvalidData( 'Subdivision invalid', 'The subdivision code must be in ISO 3166-2 format.', 'subdivision' ) ); + } else { + $this->subdivision = $subdivision; + } + } + if ( isset( $input['country_code'] ) && is_string( $input['country_code'] ) ) { + $country_code = strtoupper( trim( $input['country_code'] ) ); + + if ( ! preg_match( '/^[A-Z]{2}$/', $country_code ) ) { + $add_issue( new InvalidData( 'Country code invalid', 'The country code must be a 2-letter value.', 'country_code' ) ); + } else { + $this->country_code = $country_code; + } + } + } + + public function latitude(): ?float { + return $this->latitude; + } + + public function longitude(): ?float { + return $this->longitude; + } + + public function subdivision(): ?string { + return $this->subdivision; + } + + public function country_code(): ?string { + return $this->country_code; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/GiftOptions.php b/modules/ppcp-agentic-commerce/src/Schema/GiftOptions.php new file mode 100644 index 000000000..b701f3def --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/GiftOptions.php @@ -0,0 +1,123 @@ +is_gift = false; + $this->gift_wrap = false; + $this->sender_name = null; + $this->gift_message = null; + $this->delivery_date = null; + $this->recipient = null; + + // Optional fields. + if ( isset( $input['is_gift'] ) && is_bool( $input['is_gift'] ) ) { + $this->is_gift = $input['is_gift']; + } + if ( isset( $input['gift_wrap'] ) && is_bool( $input['gift_wrap'] ) ) { + $this->gift_wrap = $input['gift_wrap']; + } + if ( isset( $input['sender_name'] ) && is_string( $input['sender_name'] ) ) { + $this->sender_name = trim( $input['sender_name'] ); + } + if ( isset( $input['gift_message'] ) && is_string( $input['gift_message'] ) ) { + $gift_message = trim( $input['gift_message'] ); + + if ( strlen( $gift_message ) > 500 ) { + $add_issue( new InvalidData( 'Gift message too long', 'The gift message must be no longer than 500 characters', 'gift_message' ) ); + } else { + $this->gift_message = $gift_message; + } + } + if ( isset( $input['delivery_date'] ) && is_string( $input['delivery_date'] ) ) { + $delivery_date = trim( $input['delivery_date'] ); + + $rfc_date = DateTime::createFromFormat( DateTimeInterface::RFC3339, $delivery_date ); + + if ( $rfc_date ) { + $this->delivery_date = $delivery_date; + } else { + $add_issue( new InvalidData( 'Invalid delivery date format', 'The delivery date must be in RFC3339 format (e.g., 2024-12-25T09:00:00Z)', 'delivery_date' ) ); + } + } + if ( ! empty( $input['recipient'] ) && is_array( $input['recipient'] ) ) { + $recipient_email = $input['recipient']['email'] ?? null; + $recipient_name = $input['recipient']['name'] ?? null; + + if ( $recipient_email && is_string( $recipient_email ) ) { + $recipient_email = trim( $recipient_email ); + + if ( ! filter_var( $recipient_email, FILTER_VALIDATE_EMAIL ) ) { + $recipient_email = null; + $add_issue( new InvalidData( 'Invalid recipient email', 'The recipient email is not valid', 'recipient.email' ) ); + } + } + if ( $recipient_name && is_string( $recipient_name ) ) { + $recipient_name = trim( $recipient_name ); + } + + $this->recipient = array( + 'email' => $recipient_email, + 'name' => $recipient_name, + ); + } + } + + public function is_gift(): bool { + return $this->is_gift; + } + + public function gift_wrap(): bool { + return $this->gift_wrap; + } + + public function sender_name(): ?string { + return $this->sender_name; + } + + public function gift_message(): ?string { + return $this->gift_message; + } + + /** + * @return string|null The scheduled delivery date, in RFC3339 format, or null. + */ + public function delivery_date(): ?string { + return $this->delivery_date; + } + + /** + * @return null|array Recipient as simple array, no own schema. + */ + public function recipient(): ?array { + return $this->recipient; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/Money.php b/modules/ppcp-agentic-commerce/src/Schema/Money.php new file mode 100644 index 000000000..3378f633a --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/Money.php @@ -0,0 +1,63 @@ +currency = null; + $this->value = null; + + // Parse mandatory fields. + if ( isset( $input['currency_code'] ) && is_string( $input['currency_code'] ) ) { + $currency = strtoupper( trim( $input['currency_code'] ) ); + + if ( preg_match( '/^[A-Z]{3}$/', $currency ) ) { + $this->currency = $currency; + } else { + $add_issue( new InvalidData( 'Unexpected currency_code', 'Please provide a valid 3-letter currency code.', 'currency_code' ) ); + } + } else { + $add_issue( new MissingField( 'Required field missing', 'Please provide a currency code.', 'currency_code' ) ); + } + + if ( isset( $input['value'] ) ) { + $value = $input['value']; + + if ( is_int( $value ) || is_float( $value ) ) { + $this->value = (float) $value; + } elseif ( is_string( $value ) && preg_match( '/^-?\d+(\.\d{2,3})?$/', $value ) ) { + $this->value = (float) $value; + } else { + $add_issue( new InvalidData( 'Unexpected money value', 'Please provide a valid numerical value.', 'value' ) ); + } + } else { + $add_issue( new MissingField( 'Required field missing', 'Please provide a value.', 'value' ) ); + } + } + + public function currency_code(): ?string { + return $this->currency; + } + + public function value(): ?float { + return $this->value; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/PayPalCart.php b/modules/ppcp-agentic-commerce/src/Schema/PayPalCart.php new file mode 100644 index 000000000..e79bdecf1 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/PayPalCart.php @@ -0,0 +1,150 @@ +items = array(); + $this->payment_method = null; + $this->customer = null; + $this->shipping_address = null; + $this->billing_address = null; + $this->geo_coordinates = null; + $this->checkout_fields = null; + $this->coupons = null; + + // Parse mandatory fields. + if ( ! empty( $input['items'] ) && is_array( $input['items'] ) ) { + $items = $input['items']; + + if ( count( $items ) > 100 ) { + $add_issue( new InvalidData( 'Too many items', 'The cart cannot hold more than 100 items', 'items' ) ); + } else { + foreach ( $items as $item ) { + if ( is_object( $item ) ) { + $item = (array) $item; + } + if ( ! is_array( $item ) ) { + continue; + } + + $this->items[] = CartItem::from_array( $item, $add_issue ); + } + } + } else { + $add_issue( new MissingField( 'Required field missing', 'Please provide a list of cart items.', 'items' ) ); + } + + if ( ! empty( $input['payment_method'] ) && is_array( $input['payment_method'] ) ) { + $this->payment_method = PaymentMethod::from_array( $input['payment_method'], $add_issue ); + } else { + $add_issue( new MissingField( 'Required field missing', 'No payment_method defined.', 'payment_method' ) ); + } + + // Parse optional fields. + if ( ! empty( $input['customer'] ) && is_array( $input['customer'] ) ) { + $this->customer = Customer::from_array( $input['customer'], $add_issue ); + } + + if ( ! empty( $input['shipping_address'] ) && is_array( $input['shipping_address'] ) ) { + $this->shipping_address = Address::from_array( $input['shipping_address'], $add_issue ); + } + + if ( ! empty( $input['billing_address'] ) && is_array( $input['billing_address'] ) ) { + $this->billing_address = Address::from_array( $input['billing_address'], $add_issue ); + } + + if ( ! empty( $input['geo_coordinates'] ) && is_array( $input['geo_coordinates'] ) ) { + $this->geo_coordinates = GeoCoordinates::from_array( $input['geo_coordinates'], $add_issue ); + } + + if ( isset( $input['checkout_fields'] ) && is_array( $input['checkout_fields'] ) ) { + $checkout_fields = $input['checkout_fields']; + $this->checkout_fields = array(); + + if ( count( $checkout_fields ) > 20 ) { + $add_issue( new InvalidData( 'Too many checkout fields', 'The cart cannot hold more than 20 checkout fields', 'checkout_fields' ) ); + } else { + foreach ( $checkout_fields as $field ) { + $this->checkout_fields[] = CheckoutField::from_array( $field, $add_issue ); + } + } + } + + if ( isset( $input['coupons'] ) && is_array( $input['coupons'] ) ) { + $this->coupons = array(); + + foreach ( $input['coupons'] as $coupon ) { + $this->coupons[] = Coupon::from_array( $coupon, $add_issue ); + } + } + } + + public function items(): array { + return $this->items; + } + + public function payment_method(): ?PaymentMethod { + return $this->payment_method; + } + + public function customer(): ?Customer { + return $this->customer; + } + + public function shipping_address(): ?Address { + return $this->shipping_address; + } + + public function billing_address(): ?Address { + return $this->billing_address; + } + + public function geo_coordinates(): ?GeoCoordinates { + return $this->geo_coordinates; + } + + public function checkout_fields(): ?array { + return $this->checkout_fields; + } + + public function coupons(): ?array { + return $this->coupons; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/PaymentMethod.php b/modules/ppcp-agentic-commerce/src/Schema/PaymentMethod.php new file mode 100644 index 000000000..102f7999a --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/PaymentMethod.php @@ -0,0 +1,61 @@ +token = null; + $this->payer_id = null; + + // Mandatory fields. + if ( ! isset( $input['type'] ) || ! is_string( $input['type'] ) ) { + $add_issue( new MissingField( 'Payment method is required', 'No value for the payment method type found', 'type' ) ); + } else { + $type = trim( $input['type'] ); + + if ( empty( $type ) ) { + $add_issue( new MissingField( 'Payment method is required', 'No value for the payment method type found', 'type' ) ); + } elseif ( 'paypal' !== $type ) { + $add_issue( new InvalidData( 'Unexpected payment method type', 'Only PayPal is supported', 'type' ) ); + } + } + + // Optional fields. + if ( isset( $input['token'] ) && is_string( $input['token'] ) ) { + $this->token = trim( $input['token'] ); + } + if ( isset( $input['payer_id'] ) && is_string( $input['payer_id'] ) ) { + $this->payer_id = trim( $input['payer_id'] ); + } + } + + public function type(): string { + return 'paypal'; + } + + public function token(): ?string { + return $this->token; + } + + public function payer_id(): ?string { + return $this->payer_id; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Schema/ShippingOption.php b/modules/ppcp-agentic-commerce/src/Schema/ShippingOption.php new file mode 100644 index 000000000..edadbb5e8 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Schema/ShippingOption.php @@ -0,0 +1,137 @@ +id = ''; + $this->name = ''; + $this->price = null; + $this->is_selected = false; + $this->description = null; + $this->estimated_delivery = null; + + // Required field: id. + if ( ! isset( $input['id'] ) || ! is_string( $input['id'] ) ) { + $add_issue( new MissingField( 'Shipping option ID is required', 'Please provide a shipping option ID', 'id' ) ); + } else { + $id = trim( $input['id'] ); + + if ( empty( $id ) ) { + $add_issue( new MissingField( 'Shipping option ID is required', 'Please provide a shipping option ID', 'id' ) ); + } else { + $this->id = $id; + } + } + + // Required field: name. + if ( ! isset( $input['name'] ) || ! is_string( $input['name'] ) ) { + $add_issue( new MissingField( 'Shipping option name is required', 'Please provide a shipping option name', 'name' ) ); + } else { + $name = trim( $input['name'] ); + + if ( empty( $name ) ) { + $add_issue( new MissingField( 'Shipping option name is required', 'Please provide a shipping option name', 'name' ) ); + } else { + $this->name = $name; + } + } + + // Required field: price. + if ( ! isset( $input['price'] ) || ! is_array( $input['price'] ) ) { + $add_issue( new MissingField( 'Shipping price is required', 'Please provide a shipping price', 'price' ) ); + } else { + $money = Money::from_array( $input['price'], $add_issue ); + $issues = $money->validate(); + + if ( empty( $issues ) ) { + $this->price = $money; + } else { + foreach ( $issues as $issue ) { + $add_issue( $issue ); + } + } + } + + // Required field: isSelected. + if ( ! isset( $input['isSelected'] ) ) { + $add_issue( new MissingField( 'Selection status is required', 'Please specify if this shipping option is selected', 'isSelected' ) ); + } elseif ( is_bool( $input['isSelected'] ) ) { + $this->is_selected = $input['isSelected']; + } + + // Optional field: description. + if ( isset( $input['description'] ) && is_string( $input['description'] ) ) { + $this->description = trim( $input['description'] ); + } + + // Optional field: estimated_delivery. + if ( isset( $input['estimated_delivery'] ) && is_string( $input['estimated_delivery'] ) ) { + $estimated_delivery = trim( $input['estimated_delivery'] ); + + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $estimated_delivery ) ) { + $add_issue( new InvalidData( 'Invalid delivery date format', 'The estimated delivery date must be in YYYY-MM-DD format', 'estimated_delivery' ) ); + } else { + $parsed_date = DateTime::createFromFormat( 'Y-m-d', $estimated_delivery ); + $real_date = $parsed_date->format( 'Y-m-d' ); + + if ( $real_date !== $estimated_delivery ) { + $add_issue( new InvalidData( 'Invalid date', 'The date provided does not exist (e.g., Feb 31 or month 13)', 'estimated_delivery' ) ); + } else { + $this->estimated_delivery = $estimated_delivery; + } + } + } + } + + public function id(): string { + return $this->id; + } + + public function name(): string { + return $this->name; + } + + public function price(): ?Money { + return $this->price; + } + + public function isSelected(): bool { + return $this->is_selected; + } + + public function description(): ?string { + return $this->description; + } + + public function estimated_delivery(): ?string { + return $this->estimated_delivery; + } +} diff --git a/modules/ppcp-agentic-commerce/src/Validation/CouponInvalid.php b/modules/ppcp-agentic-commerce/src/Validation/CouponInvalid.php new file mode 100644 index 000000000..87540e534 --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Validation/CouponInvalid.php @@ -0,0 +1,16 @@ +message = $message ?: 'Validation error occurred'; + $this->user_message = $user_message; + $this->field = $field; + } + + public function to_array(): array { + $data = array( + 'code' => static::ISSUE_CODE, + 'type' => static::ISSUE_TYPE, + 'message' => (string) substr( $this->message, 0, 255 ), + ); + + if ( $this->user_message ) { + $data['user_message'] = (string) substr( $this->user_message, 0, 500 ); + } + if ( $this->field ) { + $data['field'] = $this->field; + } + + return $data; + } +} diff --git a/psalm.xml.dist b/psalm.xml.dist index 470d5ceb9..1e0737a0f 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -31,6 +31,7 @@ + diff --git a/tests/PHPUnit/AgenticCommerce/Schema/AddressTest.php b/tests/PHPUnit/AgenticCommerce/Schema/AddressTest.php new file mode 100644 index 000000000..21916602c --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/AddressTest.php @@ -0,0 +1,100 @@ + '123 Main Street', + 'address_line_2' => 'Apt 4B', + 'admin_area_2' => 'San Jose', + 'admin_area_1' => 'CA', + 'postal_code' => '95131', + 'country_code' => 'us', + ); + } + + protected function get_expected_data(): array { + return array( + 'address_line_1' => '123 Main Street', + 'address_line_2' => 'Apt 4B', + 'admin_area_2' => 'San Jose', + 'admin_area_1' => 'CA', + 'postal_code' => '95131', + 'country_code' => 'US', + ); + } + + protected function get_data_types(): array { + return array( + 'address_line_1' => 'string', + 'address_line_2' => 'string', + 'admin_area_2' => 'string', + 'admin_area_1' => 'string', + 'postal_code' => 'string', + 'country_code' => 'country', + ); + } + + protected function mandatory_data(): array { + return array( 'country_code' => 'US' ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'country_code' ); + + $this->assertOptionalField( 'address_line_1' ); + $this->assertOptionalField( 'address_line_2' ); + $this->assertOptionalField( 'admin_area_2' ); + $this->assertOptionalField( 'admin_area_1' ); + $this->assertOptionalField( 'postal_code' ); + } + + public function test_string_fields(): void { + $this->assertEmptyStringPreserved( 'address_line_1' ); + $this->assertEmptyStringPreserved( 'address_line_2' ); + $this->assertEmptyStringPreserved( 'admin_area_2' ); + $this->assertEmptyStringPreserved( 'admin_area_1' ); + $this->assertEmptyStringPreserved( 'postal_code' ); + + $this->assertWhitespaceTrimming( 'country_code', 'US' ); + $this->assertWhitespaceTrimming( 'address_line_1', 'ABC' ); + $this->assertWhitespaceTrimming( 'address_line_2', 'ABC' ); + $this->assertWhitespaceTrimming( 'admin_area_2', 'ABC' ); + $this->assertWhitespaceTrimming( 'admin_area_1', 'ABC' ); + $this->assertWhitespaceTrimming( 'postal_code', 'ABC' ); + + $this->assertStringFieldExactLength( 'country_code', 2 ); + $this->assertStringFieldMaxLength( 'address_line_1', 300 ); + $this->assertStringFieldMaxLength( 'address_line_2', 300 ); + $this->assertStringFieldMaxLength( 'admin_area_2', 120 ); + $this->assertStringFieldMaxLength( 'admin_area_1', 300 ); + $this->assertStringFieldMaxLength( 'postal_code', 60 ); + + $this->assertFieldNormalizesToUppercase( 'country_code', 'us', 'US' ); + $this->assertFieldIsCaseSensitive( 'address_line_1', 'sample' ); + $this->assertFieldIsCaseSensitive( 'address_line_2', 'sample' ); + $this->assertFieldIsCaseSensitive( 'admin_area_2', 'sample' ); + $this->assertFieldIsCaseSensitive( 'admin_area_1', 'sample' ); + $this->assertFieldIsCaseSensitive( 'postal_code', 'sample' ); + + $this->assertFieldAcceptsSpecialCharacters( 'address_line_1' ); + $this->assertFieldAcceptsSpecialCharacters( 'address_line_2' ); + $this->assertFieldAcceptsSpecialCharacters( 'admin_area_2' ); + $this->assertFieldAcceptsSpecialCharacters( 'admin_area_1' ); + $this->assertFieldAcceptsSpecialCharacters( 'postal_code' ); + } + + public function test_field_format_validation(): void { + $this->assertFieldFormat( 'country_code', $this->get_country_code_format_cases() ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/AppliedCouponTest.php b/tests/PHPUnit/AgenticCommerce/Schema/AppliedCouponTest.php new file mode 100644 index 000000000..95be07288 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/AppliedCouponTest.php @@ -0,0 +1,62 @@ + 'SAVE10', + 'description' => '10% off entire order', + 'discount_amount' => array( + 'currency_code' => 'usd', + 'value' => '4.00', + ), + ); + } + + protected function get_expected_data(): array { + return array( + 'code' => 'SAVE10', + 'description' => '10% off entire order', + 'discount_amount.currency_code' => 'USD', + 'discount_amount.value' => 4.0, + ); + } + + protected function get_data_types(): array { + return array( + 'code' => 'string', + 'description' => 'string', + ); + } + + public function test_required_fields(): void { + // AppliedCoupon has no required fields - all fields are optional. + + $this->assertOptionalField( 'code' ); + $this->assertOptionalField( 'description' ); + $this->assertOptionalField( 'discount_amount' ); + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'code', 'SAVE10' ); + $this->assertWhitespaceTrimming( 'description', 'Discount' ); + + $this->assertEmptyStringPreserved( 'code' ); + $this->assertEmptyStringPreserved( 'description' ); + + $this->assertFieldIsCaseSensitive( 'code', 'sample' ); + $this->assertFieldIsCaseSensitive( 'description', 'sample' ); + $this->assertFieldAcceptsSpecialCharacters( 'code' ); + $this->assertFieldAcceptsSpecialCharacters( 'description' ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/CartItemTest.php b/tests/PHPUnit/AgenticCommerce/Schema/CartItemTest.php new file mode 100644 index 000000000..7950afab7 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/CartItemTest.php @@ -0,0 +1,139 @@ + 'SHIRT-BLUE-M', + 'variant_id' => 'SHIRT-BLUE-M-COTTON', + 'parent_id' => 'SHIRT-COLLECTION-001', + 'quantity' => 2, + 'name' => 'Blue Cotton T-Shirt (Medium)', + 'description' => 'Comfortable cotton t-shirt in medium size', + 'price' => array( + 'currency_code' => 'usd', + 'value' => '25.00', + ), + 'selected_attributes' => array( + array( + 'name' => 'Color', + 'value' => 'Blue', + ), + array( + 'name' => 'Size', + 'value' => 'Medium', + ), + ), + 'gift_options' => array( + 'is_gift' => true, + 'sender_name' => 'John Smith', + 'gift_message' => 'Happy Birthday!', + ), + ); + } + + protected function get_expected_data(): array { + return array( + 'item_id' => 'SHIRT-BLUE-M', + 'variant_id' => 'SHIRT-BLUE-M-COTTON', + 'parent_id' => 'SHIRT-COLLECTION-001', + 'quantity' => 2, + 'name' => 'Blue Cotton T-Shirt (Medium)', + 'description' => 'Comfortable cotton t-shirt in medium size', + 'price.currency_code' => 'USD', + 'price.value' => 25.0, + 'selected_attributes.0.name' => 'Color', + 'selected_attributes.0.value' => 'Blue', + 'selected_attributes.1.name' => 'Size', + 'selected_attributes.1.value' => 'Medium', + 'gift_options.is_gift' => true, + 'gift_options.sender_name' => 'John Smith', + 'gift_options.gift_message' => 'Happy Birthday!', + ); + } + + protected function get_data_types(): array { + return array( + 'item_id' => 'string', + 'variant_id' => 'string', + 'parent_id' => 'string', + 'quantity' => array( 'type' => 'number', 'default' => 0 ), + 'name' => 'string', + 'description' => 'string', + ); + } + + protected function mandatory_data(): array { + return array( 'quantity' => 1 ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'quantity' ); + + $this->assertOptionalField( 'item_id' ); + $this->assertOptionalField( 'variant_id' ); + $this->assertOptionalField( 'parent_id' ); + $this->assertOptionalField( 'name' ); + $this->assertOptionalField( 'description' ); + $this->assertOptionalField( 'price' ); + $this->assertOptionalField( 'selected_attributes' ); + $this->assertOptionalField( 'gift_options' ); + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'item_id', 'SHIRT-001' ); + $this->assertWhitespaceTrimming( 'variant_id', 'VARIANT-001' ); + $this->assertWhitespaceTrimming( 'parent_id', 'PARENT-001' ); + $this->assertWhitespaceTrimming( 'name', 'Blue T-Shirt' ); + $this->assertWhitespaceTrimming( 'description', 'Cotton shirt' ); + + $this->assertEmptyStringPreserved( 'item_id' ); + $this->assertEmptyStringPreserved( 'variant_id' ); + $this->assertEmptyStringPreserved( 'parent_id' ); + $this->assertEmptyStringPreserved( 'name' ); + $this->assertEmptyStringPreserved( 'description' ); + + $this->assertStringFieldMaxLength( 'item_id', 127 ); + $this->assertStringFieldMaxLength( 'variant_id', 127 ); + $this->assertStringFieldMaxLength( 'parent_id', 127 ); + $this->assertStringFieldMaxLength( 'name', 127 ); + $this->assertStringFieldMaxLength( 'description', 255 ); + + $this->assertFieldIsCaseSensitive( 'item_id', 'sample' ); + $this->assertFieldIsCaseSensitive( 'variant_id', 'sample' ); + $this->assertFieldIsCaseSensitive( 'parent_id', 'sample' ); + $this->assertFieldIsCaseSensitive( 'name', 'sample' ); + $this->assertFieldIsCaseSensitive( 'description', 'sample' ); + + $this->assertFieldAcceptsSpecialCharacters( 'item_id' ); + $this->assertFieldAcceptsSpecialCharacters( 'variant_id' ); + $this->assertFieldAcceptsSpecialCharacters( 'parent_id' ); + $this->assertFieldAcceptsSpecialCharacters( 'name' ); + $this->assertFieldAcceptsSpecialCharacters( 'description' ); + } + + public function test_quantity_range(): void { + $this->assertIntegerFieldRange( 'quantity', 1, 999 ); + } + + public function test_selected_attributes_max_count(): void { + $this->assertArrayFieldMaxCount( + 'selected_attributes', + 10, + array( + 'name' => 'Attribute {index}', + 'value' => 'Value {index}', + ) + ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/CartTotalsTest.php b/tests/PHPUnit/AgenticCommerce/Schema/CartTotalsTest.php new file mode 100644 index 000000000..17466554d --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/CartTotalsTest.php @@ -0,0 +1,84 @@ + array( 'currency_code' => 'usd', 'value' => '25.00' ), + 'discount' => array( 'currency_code' => 'usd', 'value' => '2.50' ), + 'shipping' => array( 'currency_code' => 'usd', 'value' => '5.99' ), + 'tax' => array( 'currency_code' => 'usd', 'value' => '2.70' ), + 'handling' => array( 'currency_code' => 'usd', 'value' => '1.50' ), + 'insurance' => array( 'currency_code' => 'usd', 'value' => '0.50' ), + 'shipping_discount' => array( 'currency_code' => 'usd', 'value' => '1.00' ), + 'custom_charges' => array( 'currency_code' => 'usd', 'value' => '3.00' ), + 'total' => array( 'currency_code' => 'usd', 'value' => '36.69' ), + ); + } + + protected function get_data_types(): array { + return array( + 'subtotal' => array( 'type' => 'array', 'valid' => array() ), + 'discount' => array( 'type' => 'array', 'valid' => array() ), + 'shipping' => array( 'type' => 'array', 'valid' => array() ), + 'tax' => array( 'type' => 'array', 'valid' => array() ), + 'handling' => array( 'type' => 'array', 'valid' => array() ), + 'insurance' => array( 'type' => 'array', 'valid' => array() ), + 'shipping_discount' => array( 'type' => 'array', 'valid' => array() ), + 'custom_charges' => array( 'type' => 'array', 'valid' => array() ), + 'total' => array( 'type' => 'array', 'valid' => array() ), + ); + } + + protected function get_expected_data(): array { + return array( + 'subtotal.currency_code' => 'USD', + 'subtotal.value' => 25.00, + 'discount.currency_code' => 'USD', + 'discount.value' => 2.50, + 'shipping.currency_code' => 'USD', + 'shipping.value' => 5.99, + 'tax.currency_code' => 'USD', + 'tax.value' => 2.70, + 'handling.currency_code' => 'USD', + 'handling.value' => 1.50, + 'insurance.currency_code' => 'USD', + 'insurance.value' => 0.50, + 'shipping_discount.currency_code' => 'USD', + 'shipping_discount.value' => 1.00, + 'custom_charges.currency_code' => 'USD', + 'custom_charges.value' => 3.00, + 'total.currency_code' => 'USD', + 'total.value' => 36.69, + ); + } + + protected function mandatory_data(): array { + return array( + 'total' => array( 'currency_code' => 'USD', 'value' => '2.50' ), + ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'total' ); + + $this->assertOptionalField( 'subtotal' ); + $this->assertOptionalField( 'discount' ); + $this->assertOptionalField( 'shipping' ); + $this->assertOptionalField( 'tax' ); + $this->assertOptionalField( 'handling' ); + $this->assertOptionalField( 'insurance' ); + $this->assertOptionalField( 'shipping_discount' ); + $this->assertOptionalField( 'custom_charges' ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/CheckoutFieldTest.php b/tests/PHPUnit/AgenticCommerce/Schema/CheckoutFieldTest.php new file mode 100644 index 000000000..862fdb54f --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/CheckoutFieldTest.php @@ -0,0 +1,76 @@ + 'age_verification_21_plus', + 'status' => 'completed', + 'value' => array( + 'confirmed' => true, + 'verification_method' => 'self_declaration', + 'verification_date' => '2024-06-24T14:30:00Z', + ), + 'context' => array( + 'display_name' => 'Age Verification (21+)', + 'min_age' => 21, + 'compliance_note' => 'Required by state law', + ), + ); + } + + protected function get_expected_data(): array { + return array( + 'type' => 'AGE_VERIFICATION_21_PLUS', + 'status' => 'COMPLETED', + 'value.confirmed' => true, + 'value.verification_method' => 'self_declaration', + 'value.verification_date' => '2024-06-24T14:30:00Z', + 'context.display_name' => 'Age Verification (21+)', + 'context.min_age' => 21, + 'context.compliance_note' => 'Required by state law', + ); + } + + protected function get_data_types(): array { + return array( + 'type' => 'string', + 'status' => array( 'type' => 'string', 'valid' => 'completed', 'default' => 'ERROR' ), + 'value' => array( 'type' => 'array', 'valid' => array() ), + 'context' => array( 'type' => 'array', 'valid' => array() ), + ); + } + + protected function mandatory_data(): array { + return array( + 'type' => 'AGE_VERIFICATION_21_PLUS', + 'status' => 'PENDING', + ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'type' ); + $this->assertRequiredField( 'status' ); + + $this->assertOptionalField( 'value' ); + $this->assertOptionalField( 'context' ); + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'type', 'GIFT_MESSAGE' ); + $this->assertWhitespaceTrimming( 'status', 'PENDING' ); + + $this->assertFieldNormalizesToUppercase( 'type', 'sample', 'SAMPLE' ); + $this->assertFieldNormalizesToUppercase( 'status', 'pending', 'PENDING' ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/CouponTest.php b/tests/PHPUnit/AgenticCommerce/Schema/CouponTest.php new file mode 100644 index 000000000..fcc9a331e --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/CouponTest.php @@ -0,0 +1,62 @@ + 'SAVE10', + 'action' => 'apply', + ); + } + + protected function get_expected_data(): array { + return array( + 'code' => 'SAVE10', + 'action' => 'APPLY', + ); + } + + protected function get_data_types(): array { + return array( + 'code' => 'string', + 'action' => array( 'type' => 'string', 'valid' => 'apply' ), + ); + } + + protected function mandatory_data(): array { + return array( + 'code' => 'SAVE10', + 'action' => 'APPLY', + ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'code' ); + $this->assertRequiredField( 'action' ); + + // Coupon has no optional fields - both fields are required. + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'code', 'SAVE10' ); + $this->assertWhitespaceTrimming( 'action', 'APPLY' ); + + $this->assertFieldIsCaseSensitive( 'code', 'Save10' ); + $this->assertFieldNormalizesToUppercase( 'action', 'apply', 'APPLY' ); + } + + public function test_field_format_validation(): void { + $this->assertFieldFormat( 'action', $this->get_coupon_action_test_cases() ); + } +} + diff --git a/tests/PHPUnit/AgenticCommerce/Schema/CustomerTest.php b/tests/PHPUnit/AgenticCommerce/Schema/CustomerTest.php new file mode 100644 index 000000000..d05fc3b02 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/CustomerTest.php @@ -0,0 +1,88 @@ + array( + 'given_name' => 'John', + 'surname' => 'Smith', + ), + 'email_address' => 'john.smith@example.com', + 'phone' => array( + 'country_code' => '1', + 'national_number' => '5551234567', + ), + ); + } + + protected function get_expected_data(): array { + return array( + 'name.given_name' => 'John', + 'name.surname' => 'Smith', + 'email_address' => 'john.smith@example.com', + 'phone.country_code' => '1', + 'phone.national_number' => '5551234567', + ); + } + + protected function get_data_types(): array { + return array( + 'email_address' => 'email', + 'name' => array( 'type' => 'array', 'valid' => array() ), + 'phone' => array( 'type' => 'array', 'valid' => array() ), + ); + } + + public function test_required_fields(): void { + // Customer has no required fields - all fields are optional. + + $this->assertOptionalField( 'email_address' ); + + // Test optional nested fields. + $customer = Customer::from_array( array() ); + + $this->assertNull( $customer->name() ); + $this->assertNull( $customer->phone() ); + } + + public function test_string_fields(): void { + // Top-level optional fields + $this->assertWhitespaceTrimming( 'email_address', 'test@example.com' ); + + // Nested name fields + $this->assertWhitespaceTrimming( 'name.given_name', 'John' ); + $this->assertWhitespaceTrimming( 'name.surname', 'Smith' ); + $this->assertEmptyStringPreserved( 'name.given_name' ); + $this->assertEmptyStringPreserved( 'name.surname' ); + $this->assertStringFieldMaxLength( 'name.given_name', 140 ); + $this->assertStringFieldMaxLength( 'name.surname', 140 ); + + // Nested phone fields (digits only, no max length test for pattern-validated fields) + $this->assertWhitespaceTrimming( 'phone.country_code', '1' ); + $this->assertWhitespaceTrimming( 'phone.national_number', '5551234567' ); + + $this->assertFieldIsCaseSensitive( 'name.given_name', 'john' ); + $this->assertFieldIsCaseSensitive( 'name.surname', 'smith' ); + $this->assertFieldIsCaseSensitive( 'email_address', 'john.smith@example.com' ); + + $this->assertFieldAcceptsSpecialCharacters( 'name.given_name' ); + $this->assertFieldAcceptsSpecialCharacters( 'name.surname' ); + } + + public function test_field_format_validation(): void { + $this->assertFieldFormat( 'email_address', $this->get_email_address_format_cases() ); + $this->assertFieldFormat( 'phone.country_code', $this->get_phone_country_code_format_cases() ); + $this->assertFieldFormat( 'phone.national_number', $this->get_phone_national_number_format_cases() ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/GeoCoordinatesTest.php b/tests/PHPUnit/AgenticCommerce/Schema/GeoCoordinatesTest.php new file mode 100644 index 000000000..03ef56960 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/GeoCoordinatesTest.php @@ -0,0 +1,68 @@ + '37.7749', + 'longitude' => '-122.4194', + 'subdivision' => 'CA', + 'country_code' => 'us', + ); + } + + protected function get_expected_data(): array { + return array( + 'latitude' => 37.7749, + 'longitude' => - 122.4194, + 'subdivision' => 'CA', + 'country_code' => 'US', + ); + } + + protected function get_data_types(): array { + return array( + 'latitude' => 'number', + 'longitude' => 'number', + 'subdivision' => 'string', + 'country_code' => 'country', + ); + } + + public function test_required_fields(): void { + // GeoCoordinates has no required fields - all fields are optional when schema is used. + + $this->assertOptionalField( 'latitude' ); + $this->assertOptionalField( 'longitude' ); + $this->assertOptionalField( 'subdivision' ); + $this->assertOptionalField( 'country_code' ); + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'country_code', 'US' ); + $this->assertWhitespaceTrimming( 'subdivision', 'CA' ); + + $this->assertStringFieldExactLength( 'country_code', 2 ); + $this->assertStringFieldMaxLength( 'subdivision', 10 ); + + $this->assertFieldNormalizesToUppercase( 'country_code', 'us', 'US' ); + $this->assertFieldNormalizesToUppercase( 'subdivision', 'sample', 'SAMPLE' ); + } + + public function test_field_format_validation(): void { + $this->assertFieldFormat( 'country_code', $this->get_country_code_format_cases() ); + $this->assertFieldFormat( 'subdivision', $this->get_geo_subdivision_test_cases() ); + $this->assertFieldFormat( 'latitude', $this->get_geo_latitude_test_cases() ); + $this->assertFieldFormat( 'longitude', $this->get_geo_longitude_test_cases() ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/GiftOptionsTest.php b/tests/PHPUnit/AgenticCommerce/Schema/GiftOptionsTest.php new file mode 100644 index 000000000..c8ec98881 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/GiftOptionsTest.php @@ -0,0 +1,87 @@ + true, + 'recipient' => array( + 'name' => 'Mary Johnson', + 'email' => 'mary@example.com', + ), + 'delivery_date' => '2024-12-25T09:00:00Z', + 'sender_name' => 'John Smith', + 'gift_message' => 'Happy Birthday! Hope you enjoy this gift.', + 'gift_wrap' => true, + ); + } + + protected function get_expected_data(): array { + return array( + 'is_gift' => true, + 'recipient.name' => 'Mary Johnson', + 'recipient.email' => 'mary@example.com', + 'delivery_date' => '2024-12-25T09:00:00Z', + 'sender_name' => 'John Smith', + 'gift_message' => 'Happy Birthday! Hope you enjoy this gift.', + 'gift_wrap' => true, + ); + } + + protected function get_data_types(): array { + return array( + 'is_gift' => array( 'type' => 'bool', 'default' => false ), + 'gift_wrap' => array( 'type' => 'bool', 'default' => false ), + 'recipient' => array( 'type' => 'array', 'valid' => array() ), + 'delivery_date' => 'timestamp', + 'sender_name' => 'string', + 'gift_message' => 'string', + ); + } + + public function test_required_fields(): void { + // GiftOptions has no required fields - all fields are optional. + + // Boolean fields have default behavior, so test separately. + $this->assertBooleanFieldDefaultState( 'is_gift' ); + $this->assertBooleanFieldDefaultState( 'gift_wrap' ); + + // Other optional fields return null. + $this->assertOptionalField( 'recipient' ); + $this->assertOptionalField( 'delivery_date' ); + $this->assertOptionalField( 'sender_name' ); + $this->assertOptionalField( 'gift_message' ); + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'sender_name', 'John Smith' ); + $this->assertWhitespaceTrimming( 'gift_message', 'Happy Birthday' ); + $this->assertWhitespaceTrimming( 'delivery_date', '2024-12-25T09:00:00Z' ); + + $this->assertEmptyStringPreserved( 'sender_name' ); + $this->assertEmptyStringPreserved( 'gift_message' ); + + $this->assertStringFieldMaxLength( 'gift_message', 500 ); + + // Nested fields + $this->assertWhitespaceTrimming( 'recipient.name', 'John' ); + $this->assertWhitespaceTrimming( 'recipient.email', 'john@example.com' ); + $this->assertEmptyStringPreserved( 'recipient.name' ); + $this->assertEmptyStringPreserved( 'recipient.email' ); + } + + public function test_field_format_validation(): void { + $this->assertFieldFormat( 'delivery_date', $this->get_iso_date_format_cases() ); + $this->assertFieldFormat( 'recipient.email', $this->get_email_address_format_cases( true ) ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/MoneyTest.php b/tests/PHPUnit/AgenticCommerce/Schema/MoneyTest.php new file mode 100644 index 000000000..59b56be9b --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/MoneyTest.php @@ -0,0 +1,61 @@ + 'usd', + 'value' => '25.00', + ); + } + + protected function get_expected_data(): array { + return array( + 'currency_code' => 'USD', + 'value' => 25., + ); + } + + protected function get_data_types(): array { + return array( + 'currency_code' => array( 'type' => 'currency', 'getter' => 'currency' ), + 'value' => 'number', + ); + } + + protected function mandatory_data(): array { + return array( + 'currency_code' => 'USD', + 'value' => '25.00', + ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'currency_code' ); + $this->assertRequiredField( 'value' ); + + // Money has no optional fields - all fields are required. + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'currency_code', 'USD' ); + + $this->assertStringFieldExactLength( 'currency_code', 3 ); + $this->assertFieldNormalizesToUppercase( 'currency_code', 'usd', 'USD' ); + } + + public function test_field_format_validation(): void { + $this->assertFieldFormat( 'currency_code', $this->get_currency_code_format_cases() ); + $this->assertFieldFormat( 'value', $this->get_money_value_cases() ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/PayPalCartTest.php b/tests/PHPUnit/AgenticCommerce/Schema/PayPalCartTest.php new file mode 100644 index 000000000..27fcf44ac --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/PayPalCartTest.php @@ -0,0 +1,167 @@ + array( + array( + 'item_id' => 'SHIRT-001', + 'quantity' => 1, + 'name' => 'Blue T-Shirt', + 'price' => array( + 'currency_code' => 'usd', + 'value' => '25.00', + ), + ), + ), + 'customer' => array( + 'email_address' => 'customer@example.com', + 'name' => array( + 'given_name' => 'John', + 'surname' => 'Smith', + ), + ), + 'shipping_address' => array( + 'address_line_1' => '123 Main Street', + 'admin_area_2' => 'San Jose', + 'admin_area_1' => 'CA', + 'postal_code' => '95131', + 'country_code' => 'us', + ), + 'billing_address' => array( + 'address_line_1' => '456 Payment Blvd', + 'admin_area_2' => 'New York', + 'admin_area_1' => 'NY', + 'postal_code' => '10001', + 'country_code' => 'us', + ), + 'payment_method' => array( + 'type' => 'paypal', + ), + 'checkout_fields' => array( + array( + 'type' => 'AGE_VERIFICATION_21_PLUS', + 'status' => 'pending', + ), + ), + 'coupons' => array( + array( + 'code' => 'SAVE10', + 'action' => 'apply', + ), + ), + 'geo_coordinates' => array( + 'latitude' => '37.7749', + 'longitude' => '-122.4194', + 'subdivision' => 'CA', + 'country_code' => 'us', + ), + ); + } + + protected function get_expected_data(): array { + return array( + 'items.0.item_id' => 'SHIRT-001', + 'items.0.quantity' => 1, + 'items.0.name' => 'Blue T-Shirt', + 'items.0.price.currency_code' => 'USD', + 'items.0.price.value' => 25.00, + 'customer.email_address' => 'customer@example.com', + 'customer.name.given_name' => 'John', + 'customer.name.surname' => 'Smith', + 'shipping_address.address_line_1' => '123 Main Street', + 'shipping_address.admin_area_2' => 'San Jose', + 'shipping_address.admin_area_1' => 'CA', + 'shipping_address.postal_code' => '95131', + 'shipping_address.country_code' => 'US', + 'billing_address.address_line_1' => '456 Payment Blvd', + 'billing_address.admin_area_2' => 'New York', + 'billing_address.admin_area_1' => 'NY', + 'billing_address.postal_code' => '10001', + 'billing_address.country_code' => 'US', + 'payment_method.type' => 'paypal', + 'checkout_fields.0.type' => 'AGE_VERIFICATION_21_PLUS', + 'checkout_fields.0.status' => 'PENDING', + 'coupons.0.code' => 'SAVE10', + 'coupons.0.action' => 'APPLY', + 'geo_coordinates.latitude' => 37.7749, + 'geo_coordinates.longitude' => - 122.4194, + 'geo_coordinates.subdivision' => 'CA', + 'geo_coordinates.country_code' => 'US', + ); + } + + protected function get_data_types(): array { + return array( + 'customer' => array( 'type' => 'array', 'valid' => array() ), + 'shipping_address' => array( 'type' => 'array', 'valid' => array() ), + 'billing_address' => array( 'type' => 'array', 'valid' => array() ), + 'payment_method' => array( 'type' => 'array', 'valid' => array() ), + 'checkout_fields' => array( 'type' => 'array', 'valid' => array() ), + 'coupons' => array( 'type' => 'array', 'valid' => array() ), + 'geo_coordinates' => array( 'type' => 'array', 'valid' => array() ), + ); + } + + protected function mandatory_data(): array { + return array( + 'payment_method' => array( 'type' => 'paypal' ), + 'items' => array( + array( 'quantity' => 1 ), + ), + ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'items' ); + $this->assertRequiredField( 'payment_method' ); + + $this->assertOptionalField( 'customer' ); + $this->assertOptionalField( 'shipping_address' ); + $this->assertOptionalField( 'billing_address' ); + $this->assertOptionalField( 'checkout_fields' ); + $this->assertOptionalField( 'coupons' ); + $this->assertOptionalField( 'geo_coordinates' ); + } + + public function test_array_fields(): void { + $car_item = [ 'quantity' => 1 ]; + $checkout_field = [ 'type' => 'GIFT_MESSAGE', 'status' => 'PENDING' ]; + + $this->assertArrayFieldMinCount( 'items', 1, $car_item ); + $this->assertArrayFieldMaxCount( 'items', 100, $car_item ); + $this->assertArrayFieldMaxCount( 'checkout_fields', 20, $checkout_field ); + } + + public function test_validation_issue_propagation(): void { + /** + * The PayPalCart instance is expected to collect ALL issues from child classes. + * + * This input should generate 3 validation issues: + * 1. 'items' - at least one item must be provided (MissingField) + * 2. 'payment_method' - this field is expected (MissingField) + * 3. 'customer.email_address' - when provided, must be a valid email (InvalidData)) + */ + $multiple_problems = array( + 'items' => array(), + 'customer' => array( 'email_address' => 'not-provided' ), + ); + + $testee = PayPalCart::from_array( $multiple_problems ); + + $issues = $testee->validate(); + $this->assertCount( 3, $issues ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/PaymentMethodTest.php b/tests/PHPUnit/AgenticCommerce/Schema/PaymentMethodTest.php new file mode 100644 index 000000000..489873b23 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/PaymentMethodTest.php @@ -0,0 +1,65 @@ + 'paypal', + 'token' => 'EC123456789', + 'payer_id' => 'merchant@example.com#123', + ); + } + + protected function get_expected_data(): array { + return array( + 'type' => 'paypal', + 'token' => 'EC123456789', + 'payer_id' => 'merchant@example.com#123', + ); + } + + protected function get_data_types(): array { + return array( + 'type' => array( 'type' => 'string', 'valid' => 'paypal', 'default' => 'paypal' ), + 'token' => 'string', + 'payer_id' => 'string', + ); + } + + protected function mandatory_data(): array { + return array( + 'type' => 'paypal', + ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'type' ); + + $this->assertOptionalField( 'token' ); + $this->assertOptionalField( 'payer_id' ); + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'type', 'paypal' ); + $this->assertWhitespaceTrimming( 'token', 'EC-123' ); + $this->assertWhitespaceTrimming( 'payer_id', 'PAYER123' ); + + $this->assertEmptyStringPreserved( 'token' ); + $this->assertEmptyStringPreserved( 'payer_id' ); + + $this->assertFieldIsCaseSensitive( 'token', 'sample' ); + $this->assertFieldIsCaseSensitive( 'payer_id', 'sample' ); + $this->assertFieldAcceptsSpecialCharacters( 'token' ); + $this->assertFieldAcceptsSpecialCharacters( 'payer_id' ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/SchemaTestCase.php b/tests/PHPUnit/AgenticCommerce/Schema/SchemaTestCase.php new file mode 100644 index 000000000..fff5ccfe9 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/SchemaTestCase.php @@ -0,0 +1,1043 @@ + 'US']). + */ + abstract protected function get_expected_data(): array; + + abstract protected function get_data_types(): array; + + /** + * @return array Minimal input to pass schema validation + */ + protected function mandatory_data(): array { + return array(); + } + + // === Shared tests that run for all schema classes === + + /** + * Tests that from_array creates a valid instance without validation issues. + */ + public function test_from_array_creates_valid_instance(): void { + $class = $this->get_schema_class(); + $instance = $class::from_array( $this->get_valid_data() ); + + $this->assertInstanceOf( $class, $instance ); + $this->assertEmpty( $instance->validate(), 'Valid data should not produce validation issues' ); + } + + /** + * Tests that to_array returns the original input data unchanged. + */ + public function test_to_array_returns_original_data(): void { + $data = $this->get_valid_data(); + $class = $this->get_schema_class(); + $instance = $class::from_array( $data ); + + $this->assertSame( $data, $instance->to_array() ); + } + + /** + * Tests that with() creates a new instance (immutability). + */ + public function test_with_creates_new_instance(): void { + $class = $this->get_schema_class(); + $instance1 = $class::from_array( $this->get_valid_data() ); + $instance2 = $instance1->with( array() ); + + $this->assertNotSame( $instance1, $instance2, 'with() must return a new instance' ); + $this->assertInstanceOf( $class, $instance2 ); + } + + /** + * Tests that valid data is correctly parsed and accessible via getters. + * Subclasses should override get_expected_values() to define field->getter mappings. + */ + public function test_valid_data_accessible_via_getters(): void { + $expectations = $this->get_expected_data(); + + if ( empty( $expectations ) ) { + $this->markTestSkipped( 'No getter mappings defined - override get_expected_values() to test' ); + } + + $class = $this->get_schema_class(); + $instance = $class::from_array( $this->get_valid_data() ); + + $this->assertEmpty( $instance->validate(), 'Valid data should pass validation' ); + + foreach ( $expectations as $field_name => $expected ) { + $actual = $this->get_nested_value( $instance, $field_name ); + $this->assertSame( $expected, $actual, "Getter '$field_name()' should return '$expected' value" ); + } + } + + /** + * Tests that all fields accept only their declared types. + * + * @see get_data_type + * @see assertFieldAcceptsOnlyType + */ + public function test_fields_accept_only_declared_types(): void { + $data_types = $this->get_data_types(); + + if ( empty( $data_types ) ) { + // Skip, no plain data-types to verify. + $this->addToAssertionCount( 1 ); + + return; + } + + foreach ( $data_types as $field_name => $type_config ) { + $type = is_array( $type_config ) ? $type_config['type'] : $type_config; + $default = is_array( $type_config ) ? ( $type_config['default'] ?? null ) : null; + $valid = is_array( $type_config ) ? ( $type_config['valid'] ?? null ) : null; + + if ( ! is_null( $valid ) && ! is_array( $valid ) ) { + $valid = array( $valid ); + } + + // Positive tests: field accepts valid values + $this->assertFieldAcceptsValidTypes( $field_name, $type, $valid ); + + // Negative tests: field rejects wrong types + $this->assertFieldRejectsInvalidTypes( $field_name, $type, $default ); + } + } + + /** + * ---------------------------------------------------------------------- + * CUSTOM ASSERTIONS + * + * Assertion methods use camelCase to align with PHPUnit's built-in + * "assertSomething" convention. + * ---------------------------------------------------------------------- + */ + + /** + * Tests that a required field produces validation error when missing. + * + * @param string $field_name Expected validation error field key. + */ + protected function assertRequiredField( string $field_name ): void { + $class = $this->get_schema_class(); + $data = array(); + $instance = $class::from_array( $data ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Missing value for field '$field_name' should raise a validation issue" ); + + $issue_fields = array_map( + static fn( $issue ) => $issue->to_array()['field'], + $issues + ); + + $this->assertContains( $field_name, $issue_fields, "Expected validation error for required field: $field_name" ); + } + + /** + * Tests that an optional field returns null when missing. + * + * @param string $field_name Getter method name. + */ + protected function assertOptionalField( string $field_name ): void { + $class = $this->get_schema_class(); + $mandatory_data = $this->mandatory_data(); + $instance = $class::from_array( $mandatory_data ); + + $this->assertNull( $instance->$field_name() ); + $this->assertEmpty( $instance->validate() ); + } + + /** + * Tests that a boolean field returns the expected default state when missing. + * + * @param string $field_name Getter method name. + */ + protected function assertBooleanFieldDefaultState( string $field_name ): void { + $class = $this->get_schema_class(); + $data = array(); + $instance = $class::from_array( $data ); + + $this->assertSame( false, $instance->$field_name() ); + } + + /** + * Tests string field exact length validation. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param int $exact_length Required exact length. + */ + protected function assertStringFieldExactLength( string $field_name, int $exact_length ): void { + $class = $this->get_schema_class(); + $mandatory_data = $this->mandatory_data(); + + // Test below exact length produces validation issue + $too_short = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, str_repeat( 'a', $exact_length - 1 ) ) ); + $instance = $class::from_array( $too_short ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Field '$field_name' should fail validation when below $exact_length characters" ); + + $issue_fields = array_map( + static fn( $issue ) => $issue->to_array()['field'], + $issues + ); + $this->assertContains( $field_name, $issue_fields, "Expected validation error for invalid length of '$field_name'" ); + + // Test at exact length is valid + $at_exact = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, str_repeat( 'a', $exact_length ) ) ); + $instance = $class::from_array( $at_exact ); + $issues = $instance->validate(); + + $this->assertEmpty( $issues, "Field '$field_name' should be valid at exactly $exact_length characters" ); + + // Test above exact length produces validation issue + $too_long = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, str_repeat( 'a', $exact_length + 1 ) ) ); + $instance = $class::from_array( $too_long ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Field '$field_name' should fail validation when above $exact_length characters" ); + $this->assertContains( $field_name, $issue_fields, "Expected validation error for invalid length of '$field_name'" ); + } + + /** + * Tests string field max length validation. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param int $max_length Maximum allowed length. + */ + protected function assertStringFieldMaxLength( string $field_name, int $max_length ): void { + $class = $this->get_schema_class(); + $mandatory_data = $this->mandatory_data(); + + // Test exceeding max length produces validation issue + $too_long = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, str_repeat( 'a', $max_length + 1 ) ) ); + $instance = $class::from_array( $too_long ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Field '$field_name' should fail validation when exceeding $max_length characters" ); + + $issue_fields = array_map( + static fn( $issue ) => $issue->to_array()['field'], + $issues + ); + $this->assertContains( $field_name, $issue_fields, "Expected validation error for invalid length of '$field_name'" ); + + // Test at max length is valid + $at_max = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, str_repeat( 'a', $max_length ) ) ); + $instance = $class::from_array( $at_max ); + $issues = $instance->validate(); + + $this->assertEmpty( $issues, "Field '$field_name' should be valid at exactly $max_length characters" ); + } + + /** + * Tests integer field min/max range validation. + * + * @param string $field_name Field name in the data array. + * @param int $min Minimum allowed value. + * @param int $max Maximum allowed value. + */ + protected function assertIntegerFieldRange( string $field_name, int $min, int $max ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + // Test below minimum + $below_min = array_merge( $mandatory_data, array( $field_name => $min - 1 ) ); + $instance = $class::from_array( $below_min ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Field '$field_name' should fail validation when below $min" ); + $this->assertSame( $field_name, $issues[0]->to_array()['field'] ); + + // Test at minimum (valid) + $at_min = array_merge( $mandatory_data, array( $field_name => $min ) ); + $instance = $class::from_array( $at_min ); + $issues = $instance->validate(); + + $this->assertEmpty( $issues, "Field '$field_name' should be valid at minimum value $min" ); + + // Test above maximum + $above_max = array_merge( $mandatory_data, array( $field_name => $max + 1 ) ); + $instance = $class::from_array( $above_max ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Field '$field_name' should fail validation when above $max" ); + $this->assertSame( $field_name, $issues[0]->to_array()['field'] ); + + // Test at maximum (valid) + $at_max = array_merge( $mandatory_data, array( $field_name => $max ) ); + $instance = $class::from_array( $at_max ); + $issues = $instance->validate(); + + $this->assertEmpty( $issues, "Field '$field_name' should be valid at maximum value $max" ); + } + + /** + * Tests array field min count validation. + * + * @param string $field_name Field name in the data array. + * @param int $min_count Minimum allowed number of items. + * @param array $item_template Template for generating array items. + */ + protected function assertArrayFieldMinCount( string $field_name, int $min_count, array $item_template ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + // Test below min count produces validation issue + if ( $min_count > 0 ) { + $too_few = array(); + $data = array_merge( $mandatory_data, array( $field_name => $too_few ) ); + $instance = $class::from_array( $data ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Field '$field_name' should fail validation with less than $min_count items" ); + + $issue_fields = array_map( + static fn( $issue ) => $issue->to_array()['field'], + $issues + ); + $this->assertContains( $field_name, $issue_fields ); + } + + // Test at min count is valid + $at_min = array(); + for ( $i = 0; $i < $min_count; $i ++ ) { + $at_min[] = $item_template; + } + + $data = array_merge( $mandatory_data, array( $field_name => $at_min ) ); + $instance = $class::from_array( $data ); + $issues = $instance->validate(); + + $this->assertEmpty( $issues, "Field '$field_name' should be valid with exactly $min_count items" ); + } + + /** + * Tests array field max count validation. + * + * @param string $field_name Field name in the data array. + * @param int $max_count Maximum allowed number of items. + * @param array $item_template Template for generating array items. + */ + protected function assertArrayFieldMaxCount( string $field_name, int $max_count, array $item_template ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + // Test exceeding max count + $too_many = array(); + for ( $i = 0; $i < $max_count + 1; $i ++ ) { + $item = $item_template; + // Replace any {index} placeholders in the template + array_walk_recursive( + $item, + function ( &$value ) use ( $i ) { + if ( is_string( $value ) ) { + $value = str_replace( '{index}', (string) $i, $value ); + } + } + ); + $too_many[] = $item; + } + + $data = array_merge( $mandatory_data, array( $field_name => $too_many ) ); + $instance = $class::from_array( $data ); + $issues = $instance->validate(); + + $this->assertGreaterThan( 0, count( $issues ), "Field '$field_name' should fail validation when exceeding $max_count items" ); + $this->assertSame( $field_name, $issues[0]->to_array()['field'] ); + + // Test at max count (valid) + $at_max = array_slice( $too_many, 0, $max_count ); + $data = array_merge( $mandatory_data, array( $field_name => $at_max ) ); + $instance = $class::from_array( $data ); + $issues = $instance->validate(); + + $this->assertEmpty( $issues, "Field '$field_name' should be valid with exactly $max_count items" ); + } + + /** + * Tests that a field accepts all valid values for its declared type. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param string $expected_type Expected type (e.g., 'string', 'int', 'country', 'email'). + * @param array|null $valid_values Optional override for valid test values. + */ + protected function assertFieldAcceptsValidTypes( string $field_name, string $expected_type, array $valid_values = null ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + // Valid values that should be accepted per type + $known_types = array( + 'country' => array( 'US' ), + 'currency' => array( 'USD' ), + 'string' => array( 'valid' ), + 'int' => array( 42 ), + 'float' => array( 3.14 ), + 'number' => array( '25.00', 25, 25.0 ), + 'date' => array( '2024-12-25' ), + 'timestamp' => array( '2024-12-25T09:00:00Z' ), + 'email' => array( 'test@example.com' ), + 'bool' => array( true, false ), + 'array' => array( array( 'key' => 'value' ), array() ), + ); + + if ( ! isset( $known_types[ $expected_type ] ) ) { + $this->fail( "Unknown type '$expected_type'. Valid types: " . implode( ', ', array_keys( $known_types ) ) ); + } + + $test_values = $valid_values ?? $known_types[ $expected_type ]; + + foreach ( $test_values as $input_value ) { + $data = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, $input_value ) ); + $instance = $class::from_array( $data ); + $actual = $this->get_nested_value( $instance, $field_name ); + + // Handle case normalization for string types + if ( 'string' === $expected_type ) { + $actual = strtolower( $actual ); + } + + $this->assertEquals( + $input_value, + $actual, + "Field '$field_name' should accept valid $expected_type value: " . var_export( $input_value, true ) + ); + } + } + + /** + * Tests that a field rejects incompatible types and returns the specified default. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param string $expected_type Expected type that should be accepted. + * @param mixed $default_value Expected value when type is incompatible. + */ + protected function assertFieldRejectsInvalidTypes( string $field_name, string $expected_type, $default_value ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + // Primitive types to test for rejection + $primitive_values = array( + 'string' => 'a', + 'int' => 42, + 'float' => 3.14, + 'true' => true, + 'false' => false, + 'array' => array(), + 'null' => null, + ); + + // Which primitive types are compatible with each semantic type + $compatible_primitives = array( + 'number' => array( 'int', 'float', 'string' ), + 'timestamp' => array( 'string' ), + 'date' => array( 'string' ), + 'email' => array( 'string' ), + 'country' => array( 'string' ), + 'currency' => array( 'string' ), + 'string' => array( 'string' ), + 'int' => array( 'int' ), + 'float' => array( 'float' ), + 'bool' => array( 'true', 'false' ), + ); + + $allowed_primitives = $compatible_primitives[ $expected_type ] ?? array( $expected_type ); + + foreach ( $primitive_values as $primitive_type => $input_value ) { + // Skip compatible types + if ( in_array( $primitive_type, $allowed_primitives, true ) ) { + continue; + } + + $data = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, $input_value ) ); + $instance = $class::from_array( $data ); + $actual = $this->get_nested_value( $instance, $field_name ); + + $this->assertSame( + $default_value, + $actual, + "Field '$field_name' (type: $expected_type) should reject primitive type '$primitive_type' and return default" + ); + } + } + + /** + * Tests that empty strings are preserved (not converted to null). + * + * @param string $field_name Field name in the data array (supports dot notation). + */ + protected function assertEmptyStringPreserved( string $field_name ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + $data = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, '' ) ); + $instance = $class::from_array( $data ); + + $actual = $this->get_nested_value( $instance, $field_name ); + $this->assertSame( '', $actual, "Field '{$field_name}' should be empty string'" ); + } + + /** + * Tests that whitespace is trimmed from string values. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param mixed $clean_value The expected clean value (without whitespace). + */ + protected function assertWhitespaceTrimming( string $field_name, $clean_value ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + $test_cases = $this->get_whitespace_trim_test_cases( $clean_value ); + + foreach ( $test_cases as $description => list( $input_value, $expected_value ) ) { + $data = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, $input_value ) ); + $instance = $class::from_array( $data ); + $actual = $this->get_nested_value( $instance, $field_name ); + + $this->assertEquals( $expected_value, $actual, "Failed whitespace trimming for case: $description" ); + } + } + + /** + * Tests field format validation with multiple test cases. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param array $test_cases Test cases [description => [input, is_valid, + * expected_output]]. expected_output is optional - + * defaults to input if not provided. + * @param mixed $default_value Expected value when validation fails (e.g., '', null). + */ + protected function assertFieldFormat( string $field_name, array $test_cases, $default_value = null ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + foreach ( $test_cases as $description => $case ) { + $input = $case[0]; + $is_valid = $case[1]; + $expected_output = $case[2] ?? $input; + + $data = array_merge( array(), $mandatory_data ); + $data = $this->set_nested_value( $data, $field_name, $input ); + $instance = $class::from_array( $data ); + $issues = $instance->validate(); + $actual = $this->get_nested_value( $instance, $field_name ); + + if ( $is_valid ) { + $this->assertEmpty( $issues, "Case '$description': Expected no validation issues" ); + $this->assertSame( $expected_output, $actual, "Case '$description': Unexpected value from getter" ); + } else { + $this->assertNotEmpty( $issues, "Case '$description': Expected validation issues" ); + $issue_fields = array_map( + static fn( $issue ) => $issue->to_array()['field'], + $issues + ); + $this->assertContains( $field_name, $issue_fields, "Case '$description': Expected validation error for field '$field_name'" ); + $this->assertSame( $default_value, $actual, "Case '$description': Unexpected default value for invalid input" ); + } + } + } + + /** + * Tests that a field normalizes input to uppercase. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param string $test_value Base value to test (e.g., 'us' for country codes). + * @param string $expected_value Expected normalized value (e.g., 'US'). + */ + protected function assertFieldNormalizesToUppercase( string $field_name, string $test_value, string $expected_value ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + $test_cases = array( + 'lowercase' => strtolower( $test_value ), + 'uppercase' => strtoupper( $test_value ), + 'mixed' => ucfirst( strtolower( $test_value ) ), + ); + + foreach ( $test_cases as $case_type => $input ) { + $data = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, $input ) ); + $instance = $class::from_array( $data ); + $actual = $this->get_nested_value( $instance, $field_name ); + + $this->assertSame( + $expected_value, + $actual, + "Field '$field_name' should normalize $case_type input to uppercase" + ); + } + } + + /** + * Tests that a field preserves the exact case of input (case-sensitive). + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param string $test_value Value with mixed case to test (e.g., 'JohnSmith'). + */ + protected function assertFieldIsCaseSensitive( string $field_name, string $test_value ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + $test_cases = array( + 'lowercase' => strtolower( $test_value ), + 'uppercase' => strtoupper( $test_value ), + 'mixed' => $test_value, + ); + + foreach ( $test_cases as $case_type => $input ) { + $data = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, $input ) ); + $instance = $class::from_array( $data ); + $actual = $this->get_nested_value( $instance, $field_name ); + + $this->assertSame( + $input, + $actual, + "Field '$field_name' should preserve exact case for $case_type input" + ); + } + } + + /** + * Tests that a field accepts special characters without modification. + * + * @param string $field_name Field name in the data array (supports dot notation). + * @param array|null $special_chars Characters to test (null = common set). + */ + protected function assertFieldAcceptsSpecialCharacters( string $field_name, array $special_chars = null ): void { + $mandatory_data = $this->mandatory_data(); + $class = $this->get_schema_class(); + + $default_chars = array( + 'hyphen' => 'Test-Value', + 'underscore' => 'Test_Value', + 'period' => 'Test.Value', + 'comma' => 'Test, Value', + 'apostrophe' => "Test's Value", + 'parentheses' => 'Test (Value)', + 'ampersand' => 'Test & Value', + 'exclamation' => 'Test! Value', + 'question' => 'Test? Value', + 'colon' => 'Test: Value', + 'slash' => 'Test/Value', + 'plus' => 'Test+Value', + 'equals' => 'Test=Value', + 'at' => 'Test@Value', + 'hash' => 'Test#Value', + 'dollar' => 'Test$Value', + 'percent' => 'Test%Value', + 'unicode' => 'Tëst Vãlüe', + 'emoji' => 'Test 🎉 Value', + ); + + $test_cases = $special_chars ?? $default_chars; + + foreach ( $test_cases as $char_type => $input ) { + $data = array_merge( $mandatory_data, $this->set_nested_value( array(), $field_name, $input ) ); + $instance = $class::from_array( $data ); + $actual = $this->get_nested_value( $instance, $field_name ); + + $this->assertSame( + $input, + $actual, + "Field '$field_name' should accept special character: $char_type" + ); + } + } + + /** + * ---------------------------------------------------------------------- + * HELPERS - DATA ACCESS AND VALUE GENERATORS + * ---------------------------------------------------------------------- + */ + + /** + * Sets a nested value in an array using dot notation. + * + * @param array $data Array to modify. + * @param string $path Dot-separated path (e.g., 'phone.country_code'). + * @param mixed $value Value to set. + * @return array Modified array. + */ + protected function set_nested_value( array $data, string $path, $value ): array { + $keys = explode( '.', $path ); + $temp = &$data; + + foreach ( $keys as $key ) { + if ( ! isset( $temp[ $key ] ) || ! is_array( $temp[ $key ] ) ) { + $temp[ $key ] = array(); + } + $temp = &$temp[ $key ]; + } + + $temp = $value; + + return $data; + } + + /** + * Gets a nested value from an object using dot notation. + * + * @param object $instance Schema instance. + * @param string $path Dot-separated path (e.g., 'phone.country_code'). + * @return mixed Retrieved value. + */ + protected function get_nested_value( $instance, string $path ) { + $keys = explode( '.', $path ); + $value = $instance; + + foreach ( $keys as $key ) { + if ( is_array( $value ) ) { + $value = $value[ $key ] ?? null; + } else { + $value = $value->$key(); + } + } + + return $value; + } + + // === Common data providers === + + /** + * Provides whitespace trimming test cases for a given clean value. + * + * @param mixed $clean_value Clean value without whitespace. + * @return array Test cases [description => [input_value, expected_value]]. + */ + protected function get_whitespace_trim_test_cases( $clean_value ): array { + $string_value = (string) $clean_value; + + return array( + 'leading space' => array( " $string_value", $clean_value ), + 'trailing space' => array( "$string_value ", $clean_value ), + 'both spaces' => array( " $string_value ", $clean_value ), + ); + } + + /** + * Provides test cases for 2-letter country codes (ISO 3166-1 alpha-2). + * + * @see assertFieldFormat + * @return array Test cases [description => [input, is_valid, expected_output]]. + */ + protected function get_country_code_format_cases(): array { + return array( + 'United States' => array( 'US', true ), + 'Canada' => array( 'CA', true ), + 'United Kingdom' => array( 'GB', true ), + 'Germany' => array( 'DE', true ), + 'lowercase us' => array( 'us', true, 'US' ), + 'lowercase de' => array( 'de', true, 'DE' ), + 'mixed case' => array( 'Us', true, 'US' ), + 'single char' => array( 'U', false ), + 'three chars' => array( 'USA', false ), + 'with numbers' => array( 'U1', false ), + 'with special' => array( 'U-', false ), + 'empty' => array( '', false ), + 'spaces' => array( ' ', false ), + ); + } + + /** + * Provides test cases for 3-letter currency codes (ISO 4217). + * + * @see assertFieldFormat + * @return array Test cases [description => [input, is_valid, expected_output]]. + */ + protected function get_currency_code_format_cases(): array { + return array( + 'US Dollar' => array( 'USD', true ), + 'Euro' => array( 'EUR', true ), + 'British Pound' => array( 'GBP', true ), + 'lowercase usd' => array( 'usd', true, 'USD' ), + 'mixed case' => array( 'Usd', true, 'USD' ), + 'two chars' => array( 'US', false ), + 'four chars' => array( 'USDD', false ), + 'with numbers' => array( 'US1', false ), + 'with special' => array( 'US-', false ), + 'empty' => array( '', false ), + ); + } + + /** + * Provides test cases for phone country codes (1-3 digits). + * + * @see assertFieldFormat + * @return array Test cases [description => [input, is_valid, expected_output]]. + */ + protected function get_phone_country_code_format_cases(): array { + return array( + 'US single' => array( '1', true ), + 'UK double' => array( '44', true ), + 'Germany triple' => array( '49', true ), + 'max length' => array( '123', true ), + 'with leading space' => array( ' 1', true, '1' ), + 'empty' => array( '', false ), + 'too long' => array( '1234', false ), + 'with letters' => array( '1a', false ), + 'with special' => array( '1-', false ), + 'zero' => array( '0', false ), + ); + } + + /** + * Provides test cases for phone national numbers (1-14 digits). + * + * @see assertFieldFormat + * @return array Test cases [description => [input, is_valid, expected_output]]. + */ + protected function get_phone_national_number_format_cases(): array { + return array( + 'short' => array( '123', true ), + 'medium' => array( '5551234', true ), + 'long' => array( '5551234567', true ), + 'max length' => array( '12345678901234', true ), + 'with leading space' => array( ' 555123', true, '555123' ), + 'with trailing space' => array( '555123 ', true, '555123' ), + 'empty' => array( '', false ), + 'too long' => array( '123456789012345', false ), + 'with letters' => array( '555123a', false ), + 'with dashes' => array( '555-1234', false ), + 'with parentheses' => array( '(555)1234', false ), + ); + } + + /** + * Provides test cases for email addresses (RFC 5322). + * + * @see assertFieldFormat + * @return array Test cases [description => [input, is_valid, expected_output]]. + */ + protected function get_email_address_format_cases( bool $allow_empty = false ): array { + return array( + 'simple' => array( 'test@example.com', true ), + 'with plus' => array( 'user+tag@example.com', true ), + 'with subdomain' => array( 'user@mail.example.com', true ), + 'with dots' => array( 'first.last@example.com', true ), + 'with numbers' => array( 'user123@example.com', true ), + 'with hyphens' => array( 'user-name@ex-ample.com', true ), + 'with leading space' => array( ' test@example.com', true, 'test@example.com' ), + 'with trailing space' => array( 'test@example.com ', true, 'test@example.com' ), + 'short domain' => array( 'a@b.co', true ), + 'max length valid' => array( + str_repeat( 'a', 64 ) . '@' . str_repeat( 'b', 63 ) . '.' . str_repeat( 'c', 63 ) . '.' . str_repeat( 'd', 61 ), + true, + ), + 'no @' => array( 'notanemail', false ), + 'no domain' => array( 'user@', false ), + 'no local part' => array( '@example.com', false ), + 'spaces' => array( 'user @example.com', false ), + 'invalid - exceeds max' => array( str_repeat( 'a', 65 ) . '@example.com', false ), + 'invalid - domain too long' => array( 'user@' . str_repeat( 'a', 64 ) . '.com', false ), + 'empty' => array( '', $allow_empty ), + ); + } + + /** + * Provides test cases for ISO 8601 / RFC 3339 datetime strings. + * + * @see assertFieldFormat + * @return array Test cases [description => [input, is_valid, expected_output]]. + */ + protected function get_iso_date_format_cases( bool $allow_empty = false ): array { + return array( + 'UTC' => array( '2024-12-25T09:00:00Z', true ), + 'with offset' => array( '2024-12-25T09:00:00+01:00', true ), + 'negative offset' => array( '2024-12-25T09:00:00-05:00', true ), + 'with leading space' => array( ' 2024-12-25T09:00:00Z', true, '2024-12-25T09:00:00Z' ), + 'with milliseconds' => array( '2024-12-25T09:00:00.123Z', false ), + 'date only' => array( '2024-12-25', false ), + 'missing timezone' => array( '2024-12-25T09:00:00', false ), + 'wrong format' => array( '12/25/2024', false ), + 'missing seconds' => array( '2024-12-25T09:00Z', false ), + 'empty' => array( '', $allow_empty ), + ); + } + + /** + * Provides test cases for YYYY-MM-DD date format. + * + * @see assertFieldFormat + * @return array Test cases [description => [input, is_valid, expected_output]]. + */ + protected function get_ymd_date_format_cases( bool $allow_empty = false ): array { + return array( + 'standard date' => array( '2024-12-25', true ), + 'start of year' => array( '2024-01-01', true ), + 'end of year' => array( '2024-12-31', true ), + 'leap year Feb 29' => array( '2024-02-29', true ), + 'end of month' => array( '2024-11-30', true ), + 'with leading space' => array( ' 2024-12-25', true, '2024-12-25' ), + 'with trailing space' => array( '2024-12-25 ', true, '2024-12-25' ), + 'with both spaces' => array( ' 2024-12-25 ', true, '2024-12-25' ), + 'non-leap Feb 29' => array( '2023-02-29', false ), + 'invalid month 13' => array( '2024-13-01', false ), + 'invalid month 00' => array( '2024-00-01', false ), + 'invalid day 32' => array( '2024-01-32', false ), + 'invalid day 00' => array( '2024-01-00', false ), + 'Feb 31' => array( '2024-02-31', false ), + 'April 31' => array( '2024-04-31', false ), + 'US format' => array( '12/25/2024', false ), + 'EU format' => array( '25-12-2024', false ), + 'short year' => array( '24-12-25', false ), + 'missing day' => array( '2024-12', false ), + 'missing month' => array( '2024--25', false ), + 'with time' => array( '2024-12-25T09:00:00', false ), + 'with timezone' => array( '2024-12-25Z', false ), + 'text date' => array( 'December 25, 2024', false ), + 'only spaces' => array( ' ', $allow_empty ), + 'empty' => array( '', $allow_empty ), + ); + } + + protected function get_geo_latitude_test_cases(): array { + return array( + 'zero' => array( '0', true, 0. ), + 'positive integer' => array( '45', true, 45. ), + 'negative integer' => array( '-45', true, - 45. ), + 'positive decimal' => array( '37.7749', true, 37.7749 ), + 'negative decimal' => array( '-33.8688', true, - 33.8688 ), + 'max positive' => array( '90', true, 90. ), + 'leading space' => array( ' 90', true, 90. ), + 'trailing space' => array( '90 ', true, 90. ), + 'max positive decimal' => array( '90.0', true, 90.0 ), + 'max negative' => array( '-90', true, - 90. ), + 'max negative decimal' => array( '-90.0', true, - 90.0 ), + 'small positive' => array( '0.0001', true, 0.0001 ), + 'small negative' => array( '-0.0001', true, - 0.0001 ), + 'single digit' => array( '5', true, 5. ), + '89.9999' => array( '89.9999', true, 89.9999 ), + 'int' => array( 23, true, 23. ), + 'float' => array( 23.0, true, 23. ), + 'exceeds max' => array( '90.1', false ), + 'exceeds min' => array( '-90.1', false ), + 'way too large' => array( '180', false ), + 'way too small' => array( '-180', false ), + 'non-numeric' => array( 'abc', false ), + 'with units' => array( '45°', false ), + 'with comma' => array( '45,5', false ), + 'multiple decimals' => array( '45.5.5', false ), + ); + } + + protected function get_geo_longitude_test_cases(): array { + return array( + 'zero' => array( '0', true, 0. ), + 'positive integer' => array( '90', true, 90. ), + 'negative integer' => array( '-90', true, - 90. ), + 'positive decimal' => array( '122.4194', true, 122.4194 ), + 'negative decimal' => array( '-122.4194', true, - 122.4194 ), + 'max positive' => array( '180', true, 180. ), + 'leading space' => array( ' 180', true, 180. ), + 'trailing space' => array( '180 ', true, 180. ), + 'max positive decimal' => array( '180.0', true, 180.0 ), + 'max negative' => array( '-180', true, - 180. ), + 'max negative decimal' => array( '-180.0', true, - 180.0 ), + 'small positive' => array( '0.0001', true, 0.0001 ), + 'small negative' => array( '-0.0001', true, - 0.0001 ), + 'single digit' => array( '5', true, 5. ), + '179.9999' => array( '179.9999', true, 179.9999 ), + 'int' => array( 23, true, 23. ), + 'float' => array( 23.0, true, 23. ), + 'exceeds max' => array( '180.1', false ), + 'exceeds min' => array( '-180.1', false ), + 'way too large' => array( '360', false ), + 'way too small' => array( '-360', false ), + 'non-numeric' => array( 'xyz', false ), + 'with units' => array( '-122°', false ), + 'with comma' => array( '122,4', false ), + 'multiple decimals' => array( '122.41.94', false ), + ); + } + + protected function get_geo_subdivision_test_cases(): array { + return array( + 'US state CA' => array( 'CA', true ), + 'lowercase CA' => array( 'ca', true, 'CA' ), + 'US state NY' => array( 'NY', true ), + 'Canada province ON' => array( 'ON', true ), + 'UK region ENG' => array( 'ENG', true ), + 'ENG with spaces' => array( ' ENG ', true, 'ENG' ), + 'Germany Bavaria BY' => array( 'BY', true ), + 'Australia NSW' => array( 'NSW', true ), + 'with hyphen' => array( 'AB-CD', true ), + 'with numbers' => array( 'CA1', true ), + 'with multiple hyphens' => array( 'A-B-C', true ), + 'alphanumeric mix' => array( 'A1B2C3', true ), + 'max length 10 chars' => array( 'ABCDEFGHIJ', true ), + 'exceeds max length' => array( 'ABCDEFGHIJK', false ), + 'with spaces' => array( 'CA NY', false ), + 'with special chars' => array( 'CA_NY', false ), + 'with dots' => array( 'CA.NY', false ), + 'with slash' => array( 'CA/NY', false ), + ); + } + + protected function get_coupon_action_test_cases(): array { + // Allowed values are APPLY and REMOVE. + return array( + 'valid apply' => array( 'APPLY', true ), + 'remove' => array( 'REMOVE', true ), + 'apply lower' => array( 'apply', true, 'APPLY' ), + 'remove mixed' => array( 'ReMoVe', true, 'REMOVE' ), + 'invalid' => array( 'INVALID', false ), + 'empty' => array( '', false ), + ); + } + + protected function get_money_value_cases( bool $allow_zero = true, bool $allow_negative = true ): array { + return array( + // Positive. + 'positive integer' => array( '25', true, 25.0 ), + 'positive decimal' => array( '25.99', true, 25.99 ), + 'three decimal jpy' => array( '25.500', true, 25.5 ), + 'three decimal jpy 2' => array( '25.599', true, 25.599 ), + 'large amount' => array( '999999.99', true, 999999.99 ), + 'int amount' => array( 10, true, 10.0 ), + 'float amount' => array( 10.5, true, 10.5 ), + // Zero. + 'zero string' => array( '0', $allow_zero, 0.0 ), + 'zero int' => array( 0, $allow_zero, 0.0 ), + 'zero float' => array( 0., $allow_zero, 0.0 ), + // Negative. + 'minus one' => array( - 1, $allow_negative, - 1. ), + 'negative value' => array( '-10.50', $allow_negative, - 10.5 ), + 'large negative amount' => array( '-999999.99', $allow_negative, - 999999.99 ), + // Invalid format. + 'non numeric' => array( 'abc', false ), + 'too many decimals' => array( '10.1234', false ), + 'invalid format' => array( '10,50', false ), + 'empty string' => array( '', false ), + ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Schema/ShippingOptionTest.php b/tests/PHPUnit/AgenticCommerce/Schema/ShippingOptionTest.php new file mode 100644 index 000000000..4135f0ea7 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Schema/ShippingOptionTest.php @@ -0,0 +1,95 @@ + 'STANDARD_SHIPPING', + 'name' => 'Standard Shipping (5-7 days)', + 'description' => 'Standard ground shipping via USPS', + 'price' => array( + 'currency_code' => 'usd', + 'value' => '5.99', + ), + 'isSelected' => true, + 'estimated_delivery' => '2024-07-01', + ); + } + + protected function get_expected_data(): array { + return array( + 'id' => 'STANDARD_SHIPPING', + 'name' => 'Standard Shipping (5-7 days)', + 'description' => 'Standard ground shipping via USPS', + 'price.currency_code' => 'USD', + 'price.value' => 5.99, + 'isSelected' => true, + 'estimated_delivery' => '2024-07-01', + ); + } + + protected function get_data_types(): array { + return array( + 'id' => array( 'type' => 'string', 'default' => '' ), + 'name' => array( 'type' => 'string', 'default' => '' ), + 'description' => 'string', + 'isSelected' => array( 'type' => 'bool', 'default' => false ), + 'estimated_delivery' => 'date', + ); + } + + protected function mandatory_data(): array { + return array( + 'id' => 'STANDARD_SHIPPING', + 'name' => 'Standard Shipping', + 'price' => array( + 'currency_code' => 'USD', + 'value' => '5.99', + ), + 'isSelected' => true, + ); + } + + public function test_required_fields(): void { + $this->assertRequiredField( 'id' ); + $this->assertRequiredField( 'name' ); + $this->assertRequiredField( 'price' ); + $this->assertRequiredField( 'isSelected' ); + + $this->assertBooleanFieldDefaultState( 'isSelected' ); + + $this->assertOptionalField( 'description' ); + $this->assertOptionalField( 'estimated_delivery' ); + } + + public function test_string_fields(): void { + $this->assertWhitespaceTrimming( 'id', 'STANDARD' ); + $this->assertWhitespaceTrimming( 'name', 'Standard' ); + $this->assertWhitespaceTrimming( 'description', 'Description' ); + $this->assertWhitespaceTrimming( 'estimated_delivery', '2024-07-01' ); + + $this->assertEmptyStringPreserved( 'description' ); + + $this->assertFieldIsCaseSensitive( 'id', 'STANDARD' ); + $this->assertFieldIsCaseSensitive( 'name', 'Standard' ); + $this->assertFieldIsCaseSensitive( 'description', 'Description' ); + + $this->assertFieldAcceptsSpecialCharacters( 'id' ); + $this->assertFieldAcceptsSpecialCharacters( 'name' ); + $this->assertFieldAcceptsSpecialCharacters( 'description' ); + } + + public function test_field_format_validation(): void { + $this->assertFieldFormat( 'estimated_delivery', $this->get_ymd_date_format_cases() ); + } +} diff --git a/tests/PHPUnit/AgenticCommerce/Validation/ValidationIssueTest.php b/tests/PHPUnit/AgenticCommerce/Validation/ValidationIssueTest.php new file mode 100644 index 000000000..f76119e51 --- /dev/null +++ b/tests/PHPUnit/AgenticCommerce/Validation/ValidationIssueTest.php @@ -0,0 +1,78 @@ +to_array(); + + $this->assertContains( $data['code'], self::VALID_CODES, "$class_name has invalid ISSUE_CODE" ); + $this->assertContains( $data['type'], self::VALID_TYPES, "$class_name has invalid ISSUE_TYPE" ); + $this->assertSame( 'Validation error occurred', $data['message'] ); + + // Test with an actual message. + $issue = new $class_name( 'Test message', 'User message', 'field_name' ); + $data = $issue->to_array(); + + $this->assertSame( 'Test message', $data['message'] ); + $this->assertSame( 'User message', $data['user_message'] ); + $this->assertSame( 'field_name', $data['field'] ); + } + + public function validation_issue_provider(): array { + return array( + 'MissingField' => array( MissingField::class ), + 'InvalidData' => array( InvalidData::class ), + 'InvalidProduct' => array( InvalidProduct::class ), + 'ShippingUnavailable' => array( ShippingUnavailable::class ), + 'PriceMismatch' => array( PriceMismatch::class ), + 'ItemOutOfStock' => array( ItemOutOfStock::class ), + 'InvalidAddress' => array( InvalidAddress::class ), + 'InsufficientQuantity' => array( InsufficientQuantity::class ), + 'CouponInvalid' => array( CouponInvalid::class ), + ); + } +}