terms. $GLOBALS['aisthetix_test_products'] = array(); // variation id => fake variation. // ---------------------------------------------------------------- WP stubs. function apply_filters( $hook, $value ) { // phpcs:ignore return array_key_exists( $hook, $GLOBALS['aisthetix_test_filters'] ) ? $GLOBALS['aisthetix_test_filters'][ $hook ] : $value; } function untrailingslashit( $string ) { // phpcs:ignore return rtrim( $string, '/\\' ); } function esc_url_raw( $url ) { // phpcs:ignore return $url; } function get_option( $name, $default = false ) { // phpcs:ignore if ( 'aisthetix_tryon_settings' === $name ) { return $GLOBALS['aisthetix_test_settings']; } return array_key_exists( $name, $GLOBALS['aisthetix_test_options'] ) ? $GLOBALS['aisthetix_test_options'][ $name ] : $default; } function update_option( $name, $value ) { // phpcs:ignore $GLOBALS['aisthetix_test_options'][ $name ] = $value; return true; } function delete_option( $name ) { // phpcs:ignore unset( $GLOBALS['aisthetix_test_options'][ $name ] ); return true; } function get_transient( $name ) { // phpcs:ignore return array_key_exists( $name, $GLOBALS['aisthetix_test_transients'] ) ? $GLOBALS['aisthetix_test_transients'][ $name ] : false; } function set_transient( $name, $value, $ttl = 0 ) { // phpcs:ignore $GLOBALS['aisthetix_test_transients'][ $name ] = $value; return true; } function delete_transient( $name ) { // phpcs:ignore unset( $GLOBALS['aisthetix_test_transients'][ $name ] ); return true; } function wp_generate_password( $length, $special, $extra = false ) { // phpcs:ignore return str_repeat( 'a1B2', (int) ceil( $length / 4 ) ); } function home_url() { // phpcs:ignore return 'https://merchant.example'; } function rest_url( $path = '' ) { // phpcs:ignore return 'https://merchant.example/wp-json/' . ltrim( $path, '/' ); } function wp_json_encode( $value ) { // phpcs:ignore return json_encode( $value ); // phpcs:ignore } function wp_remote_post( $url, $args = array() ) { // phpcs:ignore $GLOBALS['aisthetix_test_posts'][] = array( 'url' => $url, 'args' => $args ); return array( 'response' => array( 'code' => $GLOBALS['aisthetix_test_post_code'] ) ); } function wp_remote_retrieve_response_code( $response ) { // phpcs:ignore return isset( $response['response']['code'] ) ? $response['response']['code'] : 0; } function is_wp_error( $thing ) { // phpcs:ignore return false; } function get_the_terms( $product_id, $taxonomy ) { // phpcs:ignore return isset( $GLOBALS['aisthetix_test_terms'][ $taxonomy ] ) ? $GLOBALS['aisthetix_test_terms'][ $taxonomy ] : false; } function wp_get_attachment_image_url( $attachment_id, $size ) { // phpcs:ignore return 0 === $attachment_id ? false : "https://merchant.example/wp-content/uploads/{$attachment_id}-{$size}.jpg"; } function get_permalink( $product_id ) { // phpcs:ignore return "https://merchant.example/product/p-{$product_id}"; } function get_woocommerce_currency() { // phpcs:ignore return 'EUR'; } function sanitize_title( $value ) { // phpcs:ignore return strtolower( trim( preg_replace( '/[^a-zA-Z0-9_-]+/', '-', $value ), '-' ) ); } function sanitize_text_field( $value ) { // phpcs:ignore return trim( strip_tags( (string) $value ) ); } function wc_get_product( $id ) { // phpcs:ignore return isset( $GLOBALS['aisthetix_test_products'][ $id ] ) ? $GLOBALS['aisthetix_test_products'][ $id ] : null; } function rest_ensure_response( $value ) { // phpcs:ignore return $value; } function add_action() {} // phpcs:ignore function add_filter() {} // phpcs:ignore function register_rest_route() {} // phpcs:ignore function add_shortcode() {} // phpcs:ignore $GLOBALS['aisthetix_test_is_admin'] = false; $GLOBALS['aisthetix_test_is_preview'] = false; function is_admin() { // phpcs:ignore return $GLOBALS['aisthetix_test_is_admin']; } function is_preview() { // phpcs:ignore return $GLOBALS['aisthetix_test_is_preview']; } require_once __DIR__ . '/../includes/class-plugin.php'; require_once __DIR__ . '/../includes/class-status.php'; require_once __DIR__ . '/../includes/class-connector.php'; require_once __DIR__ . '/../includes/class-storefront.php'; use Aisthetix\TryOn\Connector; use Aisthetix\TryOn\Status; use Aisthetix\TryOn\Storefront; $passed = 0; $failed = 0; /** * Assert two values match. * * @param string $name What is being asserted. * @param mixed $expected Expected value. * @param mixed $actual Actual value. */ function check( $name, $expected, $actual ) { global $passed, $failed; if ( $expected === $actual ) { echo " ✓ {$name}\n"; $passed++; return; } echo " ✗ {$name}\n"; echo ' expected: ' . var_export( $expected, true ) . "\n"; echo ' actual: ' . var_export( $actual, true ) . "\n"; $failed++; } /** * Reset the world; optionally connect the store. * * @param bool $connected Whether keys exist. */ function reset_world( $connected = true ) { $GLOBALS['aisthetix_test_settings'] = array(); $GLOBALS['aisthetix_test_options'] = array(); $GLOBALS['aisthetix_test_transients'] = array(); $GLOBALS['aisthetix_test_posts'] = array(); $GLOBALS['aisthetix_test_post_code'] = 200; $GLOBALS['aisthetix_test_terms'] = array(); $GLOBALS['aisthetix_test_products'] = array(); if ( $connected ) { $GLOBALS['aisthetix_test_options']['aisthetix_tryon_publishable_key'] = 'pk_test'; $GLOBALS['aisthetix_test_options']['aisthetix_tryon_secret_key'] = 'sk_test_secret'; $GLOBALS['aisthetix_test_options']['aisthetix_tryon_store_id'] = 'store_1'; } } /** Minimal duck-typed product for serialize_product. */ class Fake_Product { // phpcs:ignore public $props; public function __construct( $props = array() ) { $this->props = array_merge( array( 'id' => 11, 'name' => 'Felpa Logo', 'slug' => 'felpa-logo', 'type' => 'simple', 'status' => 'publish', 'price' => '39.90', 'image_id' => 501, 'var_min' => '', 'children' => array(), ), $props ); } public function get_id() { return $this->props['id']; } // phpcs:ignore public function get_name() { return $this->props['name']; } // phpcs:ignore public function get_slug() { return $this->props['slug']; } // phpcs:ignore public function get_type() { return $this->props['type']; } // phpcs:ignore public function get_status() { return $this->props['status']; } // phpcs:ignore public function get_price() { return $this->props['price']; } // phpcs:ignore public function get_image_id() { return $this->props['image_id']; } // phpcs:ignore public function is_type( $type ) { return $type === $this->props['type']; } // phpcs:ignore public function get_variation_price( $which ) { return $this->props['var_min']; } // phpcs:ignore public function get_children() { return $this->props['children']; } // phpcs:ignore } class Fake_Variation { // phpcs:ignore private $attributes; private $available; public function __construct( $attributes, $available = true ) { $this->attributes = $attributes; $this->available = $available; } public function get_attributes() { return $this->attributes; } // phpcs:ignore public function is_in_stock() { return $this->available; } // phpcs:ignore public function is_purchasable() { return $this->available; } // phpcs:ignore } /** A term object like get_the_terms returns. */ function term( $name, $slug, $id = 10 ) { // phpcs:ignore $t = new stdClass(); $t->name = $name; $t->slug = $slug; $t->term_id = $id; return $t; } /** A fake WP_REST_Request exposing get_header. */ class Fake_Request { // phpcs:ignore private $headers; public function __construct( $headers = array() ) { $this->headers = $headers; } public function get_header( $name ) { // phpcs:ignore return isset( $this->headers[ $name ] ) ? $this->headers[ $name ] : ''; } } echo "Connector — catalog token auth\n"; reset_world(); $connector = new Connector(); check( 'no stored token refuses everything', false, $connector->verify_catalog_request( new Fake_Request( array( 'X-Aisthetix-Catalog-Token' => 'ct_anything' ) ) ) ); reset_world(); $GLOBALS['aisthetix_test_options'][ Connector::OPTION_CATALOG_TOKEN ] = 'ct_right_token'; check( 'wrong token refused', false, $connector->verify_catalog_request( new Fake_Request( array( 'X-Aisthetix-Catalog-Token' => 'ct_wrong_token' ) ) ) ); check( 'missing header refused', false, $connector->verify_catalog_request( new Fake_Request() ) ); check( 'right token accepted', true, $connector->verify_catalog_request( new Fake_Request( array( 'X-Aisthetix-Catalog-Token' => 'ct_right_token' ) ) ) ); reset_world( false ); $GLOBALS['aisthetix_test_options'][ Connector::OPTION_CATALOG_TOKEN ] = 'ct_right_token'; check( 'a DISCONNECTED site refuses even the right token', false, $connector->verify_catalog_request( new Fake_Request( array( 'X-Aisthetix-Catalog-Token' => 'ct_right_token' ) ) ) ); echo "\nConnector — token minting + registration\n"; reset_world(); $token = $connector->ensure_token(); check( 'minted token carries the ct_ prefix', 'ct_', substr( $token, 0, 3 ) ); check( 'minted token length within the brain contract (24..128 after prefix)', true, strlen( substr( $token, 3 ) ) >= 24 && strlen( substr( $token, 3 ) ) <= 128 ); check( 'a second call returns the SAME token', $token, $connector->ensure_token() ); check( 'registration due when never registered', true, $connector->registration_due( $token ) ); $GLOBALS['aisthetix_test_options'][ Connector::OPTION_REGISTERED ] = $connector->registration_signature( $token ); check( 'registration NOT due once the signature matches', false, $connector->registration_due( $token ) ); $GLOBALS['aisthetix_test_options'][ Connector::OPTION_REGISTERED ] = 'stale-signature'; $GLOBALS['aisthetix_test_transients'][ Connector::TRANSIENT_REGISTER_RETRY ] = 1; check( 'registration NOT due while the retry throttle is set', false, $connector->registration_due( $token ) ); reset_world( false ); check( 'registration never due for a disconnected site', false, $connector->registration_due( 'ct_whatever_token_value_x' ) ); echo "\nConnector — maybe_register_token\n"; reset_world(); $connector->maybe_register_token(); check( 'one registration POST sent', 1, count( $GLOBALS['aisthetix_test_posts'] ) ); $post = $GLOBALS['aisthetix_test_posts'][0]; check( 'sent to the brain endpoint', 'https://woo.aisthetix.fyi/api/connect/catalog-token', $post['url'] ); check( 'authenticated with the secret key', 'sk_test_secret', $post['args']['headers']['X-Aisthetix-Secret'] ); $body = json_decode( $post['args']['body'], true ); check( 'body carries the catalog URL on this site', 'https://merchant.example/wp-json/aisthetix/v1/catalog', $body['catalogUrl'] ); check( 'body carries the site origin', 'https://merchant.example', $body['site'] ); check( 'body carries the plugin version', '0.7.0-test', $body['pluginVersion'] ); check( 'success stores the signature', $connector->registration_signature( $body['catalogToken'] ), get_option( Connector::OPTION_REGISTERED ) ); $connector->maybe_register_token(); check( 'an up-to-date registration sends nothing', 1, count( $GLOBALS['aisthetix_test_posts'] ) ); reset_world(); $GLOBALS['aisthetix_test_post_code'] = 404; // older brain without the endpoint. $connector->maybe_register_token(); check( 'a failed push stores NO signature', false, get_option( Connector::OPTION_REGISTERED ) ); check( 'a failed push arms the retry throttle', 1, get_transient( Connector::TRANSIENT_REGISTER_RETRY ) ); $connector->maybe_register_token(); check( 'no retry storm while throttled', 1, count( $GLOBALS['aisthetix_test_posts'] ) ); reset_world( false ); $connector->maybe_register_token(); check( 'a disconnected site never pushes', 0, count( $GLOBALS['aisthetix_test_posts'] ) ); echo "\nConnector — product serialization\n"; reset_world(); $GLOBALS['aisthetix_test_terms'] = array( 'product_cat' => array( term( 'Hoodies', 'hoodies' ) ), 'product_tag' => array( term( 'cotone', 'cotone' ) ), ); $row = $connector->serialize_product( new Fake_Product() ); check( 'simple product price', '39.90', $row['price'] ); check( 'currency is the shop currency', 'EUR', $row['currency'] ); check( 'image resolves through the attachment', array( 'url' => 'https://merchant.example/wp-content/uploads/501-large.jpg' ), $row['image'] ); check( 'permalink included', 'https://merchant.example/product/p-11', $row['permalink'] ); check( 'categories carry stable id, name and slug', array( array( 'id' => 10, 'name' => 'Hoodies', 'slug' => 'hoodies' ) ), $row['categories'] ); check( 'tags carry names', array( 'cotone' ), $row['tags'] ); $row = $connector->serialize_product( new Fake_Product( array( 'type' => 'variable', 'var_min' => '19.50', 'price' => '' ) ) ); check( 'variable product reports the MIN variation price', '19.50', $row['price'] ); $GLOBALS['aisthetix_test_products'][21] = new Fake_Variation( array( 'pa_taglia' => 'M' ) ); $GLOBALS['aisthetix_test_products'][22] = new Fake_Variation( array( 'pa_colore' => 'nero' ) ); $row = $connector->serialize_product( new Fake_Product( array( 'type' => 'variable', 'children' => array( 21, 22 ) ) ) ); check( 'catalog exports only variations with a recognised size attribute', array( array( 'variant_id' => '21', 'size_label' => 'M', 'available' => true ) ), $row['variants'] ); $row = $connector->serialize_product( new Fake_Product( array( 'image_id' => 0 ) ) ); check( 'missing image is null, never a placeholder URL', null, $row['image'] ); $row = $connector->serialize_product( new Fake_Product( array( 'price' => '' ) ) ); check( 'empty price is null, never zero', null, $row['price'] ); check( 'garbage input serializes to null instead of fataling', null, $connector->serialize_product( 'not-a-product' ) ); echo "\nStatus — product targeting\n"; check( 'missing targeting allows the product', true, Status::feature_enabled( null, array() ) ); check( 'include targeting matches a Woo category slug', true, Status::feature_enabled( array( 'mode' => 'include', 'categories' => array( 'hoodies' ), 'productIds' => array() ), array( 'categories' => array( 'hoodies' ), 'productId' => '11' ) ) ); check( 'exclude targeting removes an explicitly listed product id', false, Status::feature_enabled( array( 'mode' => 'exclude', 'categories' => array(), 'productIds' => array( '11' ) ), array( 'categories' => array(), 'productId' => 11 ) ) ); echo "\nStatus — category fit overrides\n"; $normalise_size = new ReflectionMethod( Status::class, 'normalise_size' ); $normalised_size = $normalise_size->invoke( null, array( 'defaultFitPreference' => 'loose', 'fitOverrides' => array( array( 'categoryId' => 'wc:product_cat:10', 'categoryLabel' => 'Shirts', 'preference' => 'tight', 'priority' => 12 ), array( 'categoryId' => 'wc:product_cat:10', 'categoryLabel' => 'Duplicate', 'preference' => 'loose', 'priority' => 1 ), array( 'categoryId' => 'shirts', 'categoryLabel' => 'Legacy slug', 'preference' => 'tight', 'priority' => 0 ), ) ) ); check( 'global fit survives normalisation', 'loose', $normalised_size['defaultFitPreference'] ); check( 'only a unique stable Woo category id survives', 1, count( $normalised_size['fitOverrides'] ) ); check( 'override preference survives normalisation', 'tight', $normalised_size['fitOverrides'][0]['preference'] ); $renamed_term = term( 'New display name', 'new-display-name', 10 ); check( 'category rename does not change the stable identifier', array( 'wc:product_cat:10' ), Storefront::stable_category_ids( array( $renamed_term, $renamed_term ) ) ); echo "\nConnector — catalog envelope\n"; reset_world(); $GLOBALS['aisthetix_test_settings'] = array( 'placement' => 'manual' ); $payload = $connector->catalog_payload( array( new Fake_Product(), 'garbage-row' ) ); check( 'envelope site', 'https://merchant.example', $payload['site'] ); check( 'envelope currency', 'EUR', $payload['currency'] ); check( 'envelope placement mode reflects the real setting', 'manual', $payload['placementMode'] ); check( 'envelope plugin version', '0.7.0-test', $payload['pluginVersion'] ); check( 'garbage rows dropped, good rows kept', 1, count( $payload['products'] ) ); echo "\nConnector — placement receipts\n"; reset_world(); $connector->report_placement( 'auto', 42 ); check( 'one receipt sent', 1, count( $GLOBALS['aisthetix_test_posts'] ) ); $post = $GLOBALS['aisthetix_test_posts'][0]; check( 'receipt endpoint', 'https://woo.aisthetix.fyi/api/storefront/placement', $post['url'] ); check( 'receipt is non-blocking (a shopper never waits on it)', false, $post['args']['blocking'] ); check( 'receipt authenticated with the secret key', 'sk_test_secret', $post['args']['headers']['X-Aisthetix-Secret'] ); $body = json_decode( $post['args']['body'], true ); check( 'receipt payload matches the brain contract', array( 'site' => 'https://merchant.example', 'surface' => 'auto', 'productId' => 42, 'pluginVersion' => '0.7.0-test' ), $body ); $connector->report_placement( 'auto', 42 ); check( 'second receipt inside the throttle window is suppressed', 1, count( $GLOBALS['aisthetix_test_posts'] ) ); reset_world( false ); $connector->report_placement( 'auto', 42 ); check( 'a disconnected site never sends receipts', 0, count( $GLOBALS['aisthetix_test_posts'] ) ); echo "\nConnector — clear()\n"; reset_world(); $connector->ensure_token(); $GLOBALS['aisthetix_test_options'][ Connector::OPTION_REGISTERED ] = 'sig'; $GLOBALS['aisthetix_test_transients'][ Connector::TRANSIENT_PLACEMENT ] = 1; $GLOBALS['aisthetix_test_transients'][ Connector::TRANSIENT_REGISTER_RETRY ] = 1; Connector::clear(); check( 'token removed', false, get_option( Connector::OPTION_CATALOG_TOKEN ) ); check( 'registration signature removed', false, get_option( Connector::OPTION_REGISTERED ) ); check( 'placement throttle removed', false, get_transient( Connector::TRANSIENT_PLACEMENT ) ); check( 'retry throttle removed', false, get_transient( Connector::TRANSIENT_REGISTER_RETRY ) ); echo "\nStorefront — placement context gate (the receipt false-positive guard)\n"; $frontend = array( 'admin' => false, 'rest' => false, 'preview' => false, 'elementor_preview' => false ); check( 'a genuine frontend render reports', true, Storefront::placement_context_allows_reporting( $frontend ) ); check( 'wp-admin / Elementor editor ajax never reports', false, Storefront::placement_context_allows_reporting( array_merge( $frontend, array( 'admin' => true ) ) ) ); check( 'block editor ServerSideRender (REST) never reports', false, Storefront::placement_context_allows_reporting( array_merge( $frontend, array( 'rest' => true ) ) ) ); check( 'post preview never reports', false, Storefront::placement_context_allows_reporting( array_merge( $frontend, array( 'preview' => true ) ) ) ); check( 'Elementor editor preview iframe never reports', false, Storefront::placement_context_allows_reporting( array_merge( $frontend, array( 'elementor_preview' => true ) ) ) ); $GLOBALS['aisthetix_test_is_admin'] = false; $GLOBALS['aisthetix_test_is_preview'] = false; unset( $_GET['elementor-preview'] ); $ctx = Storefront::current_placement_context(); check( 'context builder reads a clean frontend request', $frontend, $ctx ); $GLOBALS['aisthetix_test_is_admin'] = true; check( 'context builder sees wp-admin', true, Storefront::current_placement_context()['admin'] ); $GLOBALS['aisthetix_test_is_admin'] = false; $GLOBALS['aisthetix_test_is_preview'] = true; check( 'context builder sees a preview', true, Storefront::current_placement_context()['preview'] ); $GLOBALS['aisthetix_test_is_preview'] = false; $_GET['elementor-preview'] = '123'; check( 'context builder sees the Elementor preview param', true, Storefront::current_placement_context()['elementor_preview'] ); unset( $_GET['elementor-preview'] ); check( 'surface clamp keeps contract values', 'elementor', Storefront::normalize_placement_surface( 'elementor' ) ); check( 'surface clamp coerces junk to shortcode', 'shortcode', Storefront::normalize_placement_surface( 'sneaky_iframe' ) ); class WP_Query { public $posts = array(); public $max_num_pages = 6; public function __construct( $args ) { $GLOBALS['catalog_query_args'] = $args; } } class WP_Error { public function __construct( $code, $message, $data ) {} } class CatalogQueryRequest { private $params; public function __construct( $params ) { $this->params = $params; } public function get_param( $key ) { return isset( $this->params[$key] ) ? $this->params[$key] : null; } } $search_connector = new Connector(); $page_result = $search_connector->handle_catalog( new CatalogQueryRequest( array( 'page' => '5', 'search' => 'Same title' ) ) ); check( 'search spans paginated catalogs', 5, $GLOBALS['catalog_query_args']['paged'] ); check( 'search excludes variations', 'product', $GLOBALS['catalog_query_args']['post_type'] ); check( 'search term is a WP query parameter', 'Same title', $GLOBALS['catalog_query_args']['s'] ); check( 'search page size is bounded', 25, $GLOBALS['catalog_query_args']['posts_per_page'] ); check( 'next page is explicit', true, $page_result['pagination']['hasNextPage'] ); $search_connector->handle_catalog( new CatalogQueryRequest( array( 'page' => 1, 'include' => '42,43' ) ) ); check( 'ID hydration is scoped to exact products', array( 42, 43 ), $GLOBALS['catalog_query_args']['post__in'] ); foreach ( array( array( 'page' => 0 ), array( 'page' => 10001 ), array( 'page' => 1, 'include' => '-1' ), array( 'page' => 1, 'search' => str_repeat( 'x', 201 ) ) ) as $invalid ) { check( 'invalid catalog query fails', true, $search_connector->handle_catalog( new CatalogQueryRequest( $invalid ) ) instanceof WP_Error ); } echo "\n{$passed} passed, {$failed} failed\n"; exit( 0 === $failed ? 0 : 1 );