'original_price', SettingsConfig::ROUND_PRICE => 'none', SettingsConfig::PRICE_CHANGE_VALUE => '', SettingsConfig::SAFE_STOCK => 0, SettingsConfig::VARIABLE_PRODUCT_STOCK_SOURCE => 'variation', ], $settings)); $property = new ReflectionProperty(SettingsContainer::class, 'settings'); $property->setAccessible(true); $property->setValue(syncBasalamSettings(), null); }; $fixture = static function (array $options, array $variations) use (&$products) { $product = new WC_Product_Variable(); $product->set_name('تست ووسلام تنوع سایز و رنگ ' . wp_generate_password(8, false)); $product->set_status('publish'); $attributes = []; foreach ($options as $name => $values) { $attribute = new WC_Product_Attribute(); $attribute->set_name($name); if (taxonomy_exists($name)) $attribute->set_id(wc_attribute_taxonomy_id_by_name($name)); $attribute->set_options($values); $attribute->set_variation(true); $attributes[] = $attribute; } $product->set_attributes($attributes); $products[] = $product->save(); foreach ($variations as $position => $data) { $variation = new WC_Product_Variation(); $variation->set_parent_id($product->get_id()); $variation->set_status($data['status'] ?? 'publish'); $variation->set_menu_order($position); $selectedAttributes = []; foreach ($data['attributes'] as $name => $value) $selectedAttributes[sanitize_title($name)] = $value; $variation->set_attributes($selectedAttributes); $variation->set_regular_price($data['price'] ?? '100000'); $variation->set_manage_stock(true); $variation->set_stock_quantity($data['stock'] ?? 4); $variation->set_stock_status(($data['stock'] ?? 4) > 0 ? 'instock' : 'outofstock'); $variation->save(); } WC_Product_Variable::sync($product->get_id()); wc_delete_product_transients($product->get_id()); return wc_get_product($product->get_id()); }; $checkVariants = static function (array $variants, int $count) use ($assertSame) { $assertSame($count, count($variants), 'variant count'); foreach ($variants as $variant) { foreach ($variant['properties'] as $property) { $assertSame(true, trim($property['value']) !== '', 'no empty property values'); } } }; try { // Fixture saves must not schedule synchronization of unrelated products. foreach (['save_post', 'woocommerce_new_product', 'woocommerce_update_product', 'woocommerce_new_product_variation', 'woocommerce_update_product_variation'] as $hook) { remove_all_actions($hook); } $configure([]); $service = new VariantService(); $product = $fixture(['سایز' => ['S', 'M', 'L']], [['attributes' => ['سایز' => '']]]); $variants = $service->getVariants($product); $checkVariants($variants, 3); $assertSame(['S', 'M', 'L'], array_map(static function ($v) { return $v['properties'][0]['value']; }, $variants), 'all sizes'); $scenarios[] = 'one Any-size variation expands to three sizes'; $product = $fixture(['سایز' => ['S', 'M', 'L'], 'رنگ' => ['قرمز', 'آبی']], [ ['attributes' => ['سایز' => '', 'رنگ' => 'قرمز'], 'price' => '110000', 'stock' => 3], ['attributes' => ['سایز' => '', 'رنگ' => 'آبی'], 'price' => '120000', 'stock' => 0], ]); $variants = $service->getVariants($product); $checkVariants($variants, 6); $assertSame(1100000, $variants[0]['primary_price'], 'red variation price in rial'); $assertSame(3, $variants[0]['stock'], 'red variation stock'); $assertSame(1200000, $variants[3]['primary_price'], 'blue variation price in rial'); $assertSame(0, $variants[3]['stock'], 'out-of-stock blue variations are retained'); $scenarios[] = 'each color expands all sizes with its own price and stock'; $product = $fixture(['سایز' => ['S', 'M', 'L'], 'رنگ' => ['قرمز', 'آبی']], [ ['attributes' => ['سایز' => '', 'رنگ' => '']], ]); $variants = $service->getVariants($product); $checkVariants($variants, 6); $remote = []; foreach ($variants as $index => $variant) { $remote[] = ['id' => 80000000 + $index, 'properties' => array_reverse(array_map(static function ($property) { return ['property' => ['title' => $property['property']], 'value' => ['title' => $property['value']]]; }, $variant['properties']))]; } $service->syncExpandedVariationIds($product, array_reverse($remote)); $mapped = $service->getVariants($product); $assertSame(array_column($remote, 'id'), array_column($mapped, 'id'), 'mapping ignores response order'); $variationId = $product->get_children()[0]; $assertSame(6, count(get_post_meta($variationId, 'sync_basalam_variation_id', false)), 'all remote ids are stored'); foreach ($remote as $variant) { $assertSame($variationId, OrderManager::getWooProductVariableId($variant['id']), 'order lookup for each expanded id'); } $scenarios[] = 'two Any attributes expand all six combinations and map every id'; $apiRecorder = new class { public array $requests = []; public function patch($url, $data) { $this->requests[] = ['url' => $url, 'data' => $data]; return ['status_code' => 200, 'body' => '{}']; } public function post($url, $data) { $this->requests[] = ['url' => $url, 'data' => $data]; return ['status_code' => 202, 'body' => '{}']; } public function delete($url, $headers, $data) { $this->requests[] = ['url' => $url, 'data' => $data]; return ['status_code' => 202, 'body' => '{}']; } }; update_post_meta($product->get_id(), ProductMetaKey::basalamProductId(), 99999999); $configure([SettingsConfig::SYNC_PRODUCT_FIELDS => 'custom', SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_PRICE => 1, SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_STOCK => 1]); $updater = new UpdateSingleProductService($apiRecorder, new UpdateProductVariationsService($apiRecorder), $service); $updater->updateProductInBasalam(['id' => 99999999, 'type' => 'variable', 'variants' => $mapped], $product->get_id()); $assertSame(6, count($apiRecorder->requests), 'custom updates visit all six remote ids'); foreach ($remote as $index => $variant) { $assertSame(sprintf(Endpoints::PRODUCT_VARIATION_UPDATE, 99999999, $variant['id']), $apiRecorder->requests[$index]['url'], 'custom update routes each mapped id'); } $configure([]); $apiRecorder->requests = []; $variation = wc_get_product($variationId); $variation->set_sale_price('80000'); $variation->save(); $discount = new VariableProductDiscount(new DiscountManager($apiRecorder)); $discount->apply($product); $discount->remove($product); $metaIds = array_map('strval', array_column($remote, 'id')); $assertSame($metaIds, $apiRecorder->requests[0]['data']['product_filter']['variation_ids'], 'discount applies to every expanded id'); $assertSame($metaIds, $apiRecorder->requests[1]['data']['product_filter']['variation_ids'], 'discount removal visits every expanded id'); $taskModel = new class { public array $tasks = []; public function create($data) { $this->tasks[] = $data; return count($this->tasks); } }; $jobManager = new class { public function getCountJobs($criteria) { return 1; } }; $processor = new DiscountTaskProcessor(new DiscountManager($apiRecorder), $taskModel, $jobManager); $processor->addDiscountTasks([['variation_id' => $variationId, 'discount_percent' => 20]]); $assertSame($metaIds, array_column($taskModel->tasks, 'variation_id'), 'discount queue stores all expanded ids'); $scenarios[] = 'custom updates and discounts visit every expanded remote id'; $product = $fixture(['سایز' => ['S', 'M']], [ ['attributes' => ['سایز' => 'S'], 'price' => '150000'], ['attributes' => ['سایز' => ''], 'price' => '100000'], ['attributes' => ['سایز' => 'M'], 'price' => '200000', 'status' => 'private'], ]); $variants = $service->getVariants($product); $checkVariants($variants, 2); $assertSame([1500000, 1000000], array_column($variants, 'primary_price'), 'first matching Woo variation wins'); $scenarios[] = 'overlaps follow Woo order without duplicate or disabled variants'; $slug = 'expansion_' . strtolower(wp_generate_password(8, false)); $attributeId = wc_create_attribute(['name' => 'اندازه', 'slug' => $slug, 'type' => 'select']); if (is_wp_error($attributeId)) throw new RuntimeException($attributeId->get_error_message()); $globalAttributes[] = $attributeId; $taxonomy = wc_attribute_taxonomy_name($slug); register_taxonomy($taxonomy, ['product'], ['label' => 'اندازه', 'public' => false]); $GLOBALS['wc_product_attributes'][$taxonomy] = wc_get_attribute_taxonomies()['id:' . $attributeId]; $termIds = []; foreach (['کوچک', 'بزرگ'] as $title) { $term = wp_insert_term($title, $taxonomy); if (is_wp_error($term)) throw new RuntimeException($term->get_error_message()); $termIds[] = $term['term_id']; } $product = $fixture([$taxonomy => $termIds], [['attributes' => [$taxonomy => '']]]); $variants = $service->getVariants($product); $checkVariants($variants, 2); $values = array_map(static function ($v) { return $v['properties'][0]['value']; }, $variants); sort($values); $expectedValues = ['کوچک', 'بزرگ']; sort($expectedValues); $assertSame($expectedValues, $values, 'taxonomy slugs resolve to Persian term names'); $scenarios[] = 'global taxonomy options use readable Persian names'; $product = $fixture(['شماره' => ['0', '1']], [['attributes' => ['شماره' => '']]]); $checkVariants($service->getVariants($product), 2); $product = $fixture(['سایز' => ['S']], [['attributes' => ['سایز' => 'S'], 'price' => '']]); $checkVariants($service->getVariants($product), 0); $validation = (new CreateProductDataValidator())->validate(['category_id' => 1, 'photo' => 1, 'variants' => []], $product->get_id()); $assertSame(false, $validation['valid'], 'variable products cannot be created without valid variants'); $scenarios[] = 'zero option is preserved and unpriced variations are skipped'; $product = $fixture(['سایز' => ['S']], [['attributes' => ['سایز' => '']]]); $variationId = $product->get_children()[0]; update_post_meta($variationId, 'sync_basalam_variation_id', 70000000); $assertSame(false, isset($service->getVariants($product)[0]['id']), 'legacy empty properties need a full remap even with one option'); $remote = [['id' => 80000001, 'properties' => [['property' => 'سایز', 'value' => 'S']]]]; $service->syncExpandedVariationIds($product, $remote); $variation = wc_get_product($variationId); $variation->set_attributes([sanitize_title('سایز') => 'S']); $variation->save(); $remote[0]['id'] = 80000002; $service->syncExpandedVariationIds($product, $remote); $assertSame(80000002, $service->getVariants($product)[0]['id'], 'mapping refreshes after Any becomes a specific option'); $scenarios[] = 'legacy wildcard ids and changing wildcard selections remap safely'; if (in_array('--live', $argv, true)) { $api = syncBasalamContainer()->get(ApiServiceManager::class); $base = null; foreach (wc_get_products(['type' => 'simple', 'limit' => 20]) as $candidate) { if ($candidate->get_image_id() && get_post_meta($candidate->get_id(), ProductMetaKey::basalamProductId(), true)) { $base = $candidate; break; } } if (!$base) throw new RuntimeException('The live test needs an existing connected simple product with an image.'); $baseRemoteId = get_post_meta($base->get_id(), ProductMetaKey::basalamProductId(), true); $response = $api->get(sprintf(Endpoints::PRODUCT_UPDATE, $baseRemoteId)); $baseData = json_decode($response['body'], true); $product = $fixture(['سایز' => ['S', 'M', 'L'], 'رنگ' => ['قرمز', 'آبی']], [ ['attributes' => ['سایز' => '', 'رنگ' => ''], 'stock' => 0], ]); $liveProductId = $product->get_id(); $product->set_image_id($base->get_image_id()); $product->set_weight('0.2'); $product->save(); $payloadFilter = static function ($data, $p, $handler, $mode) use ($liveProductId, $baseData) { if ($p->get_id() !== $liveProductId || $mode !== 'create') return $data; return array_merge((array) $data, [ 'category_id' => $baseData['category_id'] ?? $baseData['category']['id'], 'photo' => $baseData['photo']['id'], 'photos' => [], 'weight' => 200, 'package_weight' => 300, 'preparation_days' => 3, ]); }; add_filter('sync_basalam_product_payload', $payloadFilter, 100, 4); $operations = syncBasalamContainer()->get(ProductOperations::class); try { $result = $operations->createNewProduct($liveProductId, null); } finally { remove_filter('sync_basalam_product_payload', $payloadFilter, 100); } $liveRemoteId = $result['basalam_id']; $assertSame(true, $result['success'], 'live creation succeeds'); $response = $api->get(sprintf(Endpoints::PRODUCT_UPDATE, $liveRemoteId)); $created = json_decode($response['body'], true); $createdVariants = $created['variants'] ?? $created['variant'] ?? []; $assertSame(6, count($createdVariants), 'six variants persist in Basalam'); $mapped = $service->getVariants(wc_get_product($liveProductId)); $assertSame(6, count(array_column($mapped, 'id')), 'six live remote ids map back to Woo'); foreach ($createdVariants as $variant) { $assertSame(0, $variant['stock'], 'live test stays out of stock'); foreach ($variant['properties'] as $property) { $assertSame(true, trim($property['value']['title']) !== '', 'live property is populated'); } } $liveResult = ['basalam_id' => $liveRemoteId, 'variant_count' => 6, 'variants' => $createdVariants]; $scenarios[] = 'real product creation and readback in Basalam'; } } catch (Throwable $error) { fwrite(STDERR, wp_json_encode(['passed' => false, 'error' => $error->getMessage(), 'basalam_id' => $liveRemoteId], JSON_UNESCAPED_UNICODE) . PHP_EOL); throw $error; } finally { try { if ($liveRemoteId) { syncBasalamContainer()->get(ProductOperations::class)->archiveExistProduct($liveProductId); $response = syncBasalamContainer()->get(ApiServiceManager::class)->get(sprintf(Endpoints::PRODUCT_UPDATE, $liveRemoteId)); $archived = json_decode($response['body'], true); $assertSame(3790, $archived['revision']['data']['status']['value'] ?? $archived['status']['value'], 'archive status is persisted'); $liveResult['unpublished'] = true; } } finally { foreach ($products as $id) { $product = wc_get_product($id); if ($product) $product->delete(true); } foreach ($globalAttributes as $id) wc_delete_attribute($id); if ($originalSettings === false) delete_option('sync_basalam_settings'); else update_option('sync_basalam_settings', $originalSettings); } } echo wp_json_encode(['passed' => true, 'assertions' => $assertions, 'scenarios' => $scenarios, 'live' => $liveResult], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;