PluginProbe
ووسلام – همگام سازی ووکامرس و باسلام / 1.10.28
ووسلام – همگام سازی ووکامرس و باسلام v1.10.28
1.10.28 1.1.26 1.10.27 1.10.24 1.10.25 1.10.23 1.10.22 1.10.21 1.10.19 1.10.20 1.10.18 1.10.17 1.10.15 1.10.14 1.10.13 1.10.12 1.10.10 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 All 61 releases
sync-basalam / tests / integration / variable-product-expansion.php

variable-product-expansion.php in ووسلام – همگام سازی ووکامرس و باسلام 1.10.28, at tests/integration/variable-product-expansion.php

329 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // docker exec -w /var/www/html/wp-content/plugins/sync-basalam \
4 // basalam-wordpress-local-wordpress-1 php tests/integration/variable-product-expansion.php [--live]
5 // --live creates one zero-stock product in the configured test vendor and archives it.
6 define('WP_DISABLE_FATAL_ERROR_HANDLER', true);
7 require getenv('WORDPRESS_BOOTSTRAP') ?: dirname(__DIR__, 5) . '/wp-load.php';
8
9 use SyncBasalam\Admin\Product\Data\Services\VariantService;
10 use SyncBasalam\Admin\Product\Data\Validators\CreateProductDataValidator;
11 use SyncBasalam\Admin\Product\ProductOperations;
12 use SyncBasalam\Admin\Settings\SettingsConfig;
13 use SyncBasalam\Admin\Settings\SettingsContainer;
14 use SyncBasalam\Config\Endpoints;
15 use SyncBasalam\Services\ApiServiceManager;
16 use SyncBasalam\Services\Orders\OrderManager;
17 use SyncBasalam\Services\Products\Discount\DiscountManager;
18 use SyncBasalam\Services\Products\Discount\DiscountTaskProcessor;
19 use SyncBasalam\Services\Products\Discount\VariableProductDiscount;
20 use SyncBasalam\Services\Products\UpdateProductVariationsService;
21 use SyncBasalam\Services\Products\UpdateSingleProductService;
22 use SyncBasalam\Utilities\ProductMetaKey;
23
24 if (wp_get_environment_type() !== 'local') {
25 throw new RuntimeException('Run this integration test only in a local WordPress environment.');
26 }
27
28 $originalSettings = get_option('sync_basalam_settings');
29 $products = [];
30 $globalAttributes = [];
31 $assertions = 0;
32 $scenarios = [];
33 $liveResult = null;
34 $liveProductId = null;
35 $liveRemoteId = null;
36 $assertSame = static function ($expected, $actual, string $message) use (&$assertions) {
37 if ($expected !== $actual) {
38 throw new RuntimeException($message . ': expected ' . var_export($expected, true) . ', got ' . var_export($actual, true));
39 }
40 $assertions++;
41 };
42 $configure = static function (array $settings) use ($originalSettings) {
43 update_option('sync_basalam_settings', array_merge(SettingsConfig::getDefaultSettings(), (array) $originalSettings, [
44 SettingsConfig::PRODUCT_PRICE_FIELD => 'original_price',
45 SettingsConfig::ROUND_PRICE => 'none',
46 SettingsConfig::PRICE_CHANGE_VALUE => '',
47 SettingsConfig::SAFE_STOCK => 0,
48 SettingsConfig::VARIABLE_PRODUCT_STOCK_SOURCE => 'variation',
49 ], $settings));
50 $property = new ReflectionProperty(SettingsContainer::class, 'settings');
51 $property->setAccessible(true);
52 $property->setValue(syncBasalamSettings(), null);
53 };
54 $fixture = static function (array $options, array $variations) use (&$products) {
55 $product = new WC_Product_Variable();
56 $product->set_name('تست ووسلا�
57 تنوع سایز و رنگ ' . wp_generate_password(8, false));
58 $product->set_status('publish');
59 $attributes = [];
60 foreach ($options as $name => $values) {
61 $attribute = new WC_Product_Attribute();
62 $attribute->set_name($name);
63 if (taxonomy_exists($name)) $attribute->set_id(wc_attribute_taxonomy_id_by_name($name));
64 $attribute->set_options($values);
65 $attribute->set_variation(true);
66 $attributes[] = $attribute;
67 }
68 $product->set_attributes($attributes);
69 $products[] = $product->save();
70 foreach ($variations as $position => $data) {
71 $variation = new WC_Product_Variation();
72 $variation->set_parent_id($product->get_id());
73 $variation->set_status($data['status'] ?? 'publish');
74 $variation->set_menu_order($position);
75 $selectedAttributes = [];
76 foreach ($data['attributes'] as $name => $value) $selectedAttributes[sanitize_title($name)] = $value;
77 $variation->set_attributes($selectedAttributes);
78 $variation->set_regular_price($data['price'] ?? '100000');
79 $variation->set_manage_stock(true);
80 $variation->set_stock_quantity($data['stock'] ?? 4);
81 $variation->set_stock_status(($data['stock'] ?? 4) > 0 ? 'instock' : 'outofstock');
82 $variation->save();
83 }
84 WC_Product_Variable::sync($product->get_id());
85 wc_delete_product_transients($product->get_id());
86 return wc_get_product($product->get_id());
87 };
88 $checkVariants = static function (array $variants, int $count) use ($assertSame) {
89 $assertSame($count, count($variants), 'variant count');
90 foreach ($variants as $variant) {
91 foreach ($variant['properties'] as $property) {
92 $assertSame(true, trim($property['value']) !== '', 'no empty property values');
93 }
94 }
95 };
96
97 try {
98 // Fixture saves must not schedule synchronization of unrelated products.
99 foreach (['save_post', 'woocommerce_new_product', 'woocommerce_update_product', 'woocommerce_new_product_variation', 'woocommerce_update_product_variation'] as $hook) {
100 remove_all_actions($hook);
101 }
102 $configure([]);
103 $service = new VariantService();
104 $product = $fixture(['سایز' => ['S', 'M', 'L']], [['attributes' => ['سایز' => '']]]);
105 $variants = $service->getVariants($product);
106 $checkVariants($variants, 3);
107 $assertSame(['S', 'M', 'L'], array_map(static function ($v) { return $v['properties'][0]['value']; }, $variants), 'all sizes');
108 $scenarios[] = 'one Any-size variation expands to three sizes';
109
110 $product = $fixture(['سایز' => ['S', 'M', 'L'], 'رنگ' => ['قر�
111 ز', 'آبی']], [
112 ['attributes' => ['سایز' => '', 'رنگ' => 'قر�
113 ز'], 'price' => '110000', 'stock' => 3],
114 ['attributes' => ['سایز' => '', 'رنگ' => 'آبی'], 'price' => '120000', 'stock' => 0],
115 ]);
116 $variants = $service->getVariants($product);
117 $checkVariants($variants, 6);
118 $assertSame(1100000, $variants[0]['primary_price'], 'red variation price in rial');
119 $assertSame(3, $variants[0]['stock'], 'red variation stock');
120 $assertSame(1200000, $variants[3]['primary_price'], 'blue variation price in rial');
121 $assertSame(0, $variants[3]['stock'], 'out-of-stock blue variations are retained');
122 $scenarios[] = 'each color expands all sizes with its own price and stock';
123
124 $product = $fixture(['سایز' => ['S', 'M', 'L'], 'رنگ' => ['قر�
125 ز', 'آبی']], [
126 ['attributes' => ['سایز' => '', 'رنگ' => '']],
127 ]);
128 $variants = $service->getVariants($product);
129 $checkVariants($variants, 6);
130 $remote = [];
131 foreach ($variants as $index => $variant) {
132 $remote[] = ['id' => 80000000 + $index, 'properties' => array_reverse(array_map(static function ($property) {
133 return ['property' => ['title' => $property['property']], 'value' => ['title' => $property['value']]];
134 }, $variant['properties']))];
135 }
136 $service->syncExpandedVariationIds($product, array_reverse($remote));
137 $mapped = $service->getVariants($product);
138 $assertSame(array_column($remote, 'id'), array_column($mapped, 'id'), 'mapping ignores response order');
139 $variationId = $product->get_children()[0];
140 $assertSame(6, count(get_post_meta($variationId, 'sync_basalam_variation_id', false)), 'all remote ids are stored');
141 foreach ($remote as $variant) {
142 $assertSame($variationId, OrderManager::getWooProductVariableId($variant['id']), 'order lookup for each expanded id');
143 }
144 $scenarios[] = 'two Any attributes expand all six combinations and map every id';
145
146 $apiRecorder = new class {
147 public array $requests = [];
148 public function patch($url, $data) {
149 $this->requests[] = ['url' => $url, 'data' => $data];
150 return ['status_code' => 200, 'body' => '{}'];
151 }
152 public function post($url, $data) {
153 $this->requests[] = ['url' => $url, 'data' => $data];
154 return ['status_code' => 202, 'body' => '{}'];
155 }
156 public function delete($url, $headers, $data) {
157 $this->requests[] = ['url' => $url, 'data' => $data];
158 return ['status_code' => 202, 'body' => '{}'];
159 }
160 };
161 update_post_meta($product->get_id(), ProductMetaKey::basalamProductId(), 99999999);
162 $configure([SettingsConfig::SYNC_PRODUCT_FIELDS => 'custom', SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_PRICE => 1, SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_STOCK => 1]);
163 $updater = new UpdateSingleProductService($apiRecorder, new UpdateProductVariationsService($apiRecorder), $service);
164 $updater->updateProductInBasalam(['id' => 99999999, 'type' => 'variable', 'variants' => $mapped], $product->get_id());
165 $assertSame(6, count($apiRecorder->requests), 'custom updates visit all six remote ids');
166 foreach ($remote as $index => $variant) {
167 $assertSame(sprintf(Endpoints::PRODUCT_VARIATION_UPDATE, 99999999, $variant['id']), $apiRecorder->requests[$index]['url'], 'custom update routes each mapped id');
168 }
169 $configure([]);
170 $apiRecorder->requests = [];
171 $variation = wc_get_product($variationId);
172 $variation->set_sale_price('80000');
173 $variation->save();
174 $discount = new VariableProductDiscount(new DiscountManager($apiRecorder));
175 $discount->apply($product);
176 $discount->remove($product);
177 $metaIds = array_map('strval', array_column($remote, 'id'));
178 $assertSame($metaIds, $apiRecorder->requests[0]['data']['product_filter']['variation_ids'], 'discount applies to every expanded id');
179 $assertSame($metaIds, $apiRecorder->requests[1]['data']['product_filter']['variation_ids'], 'discount removal visits every expanded id');
180 $taskModel = new class {
181 public array $tasks = [];
182 public function create($data) { $this->tasks[] = $data; return count($this->tasks); }
183 };
184 $jobManager = new class {
185 public function getCountJobs($criteria) { return 1; }
186 };
187 $processor = new DiscountTaskProcessor(new DiscountManager($apiRecorder), $taskModel, $jobManager);
188 $processor->addDiscountTasks([['variation_id' => $variationId, 'discount_percent' => 20]]);
189 $assertSame($metaIds, array_column($taskModel->tasks, 'variation_id'), 'discount queue stores all expanded ids');
190 $scenarios[] = 'custom updates and discounts visit every expanded remote id';
191
192 $product = $fixture(['سایز' => ['S', 'M']], [
193 ['attributes' => ['سایز' => 'S'], 'price' => '150000'],
194 ['attributes' => ['سایز' => ''], 'price' => '100000'],
195 ['attributes' => ['سایز' => 'M'], 'price' => '200000', 'status' => 'private'],
196 ]);
197 $variants = $service->getVariants($product);
198 $checkVariants($variants, 2);
199 $assertSame([1500000, 1000000], array_column($variants, 'primary_price'), 'first matching Woo variation wins');
200 $scenarios[] = 'overlaps follow Woo order without duplicate or disabled variants';
201
202 $slug = 'expansion_' . strtolower(wp_generate_password(8, false));
203 $attributeId = wc_create_attribute(['name' => 'اندازه', 'slug' => $slug, 'type' => 'select']);
204 if (is_wp_error($attributeId)) throw new RuntimeException($attributeId->get_error_message());
205 $globalAttributes[] = $attributeId;
206 $taxonomy = wc_attribute_taxonomy_name($slug);
207 register_taxonomy($taxonomy, ['product'], ['label' => 'اندازه', 'public' => false]);
208 $GLOBALS['wc_product_attributes'][$taxonomy] = wc_get_attribute_taxonomies()['id:' . $attributeId];
209 $termIds = [];
210 foreach (['کوچک', 'بزرگ'] as $title) {
211 $term = wp_insert_term($title, $taxonomy);
212 if (is_wp_error($term)) throw new RuntimeException($term->get_error_message());
213 $termIds[] = $term['term_id'];
214 }
215 $product = $fixture([$taxonomy => $termIds], [['attributes' => [$taxonomy => '']]]);
216 $variants = $service->getVariants($product);
217 $checkVariants($variants, 2);
218 $values = array_map(static function ($v) { return $v['properties'][0]['value']; }, $variants);
219 sort($values);
220 $expectedValues = ['کوچک', 'بزرگ'];
221 sort($expectedValues);
222 $assertSame($expectedValues, $values, 'taxonomy slugs resolve to Persian term names');
223 $scenarios[] = 'global taxonomy options use readable Persian names';
224
225 $product = $fixture(['ش�
226 اره' => ['0', '1']], [['attributes' => ['ش�
227 اره' => '']]]);
228 $checkVariants($service->getVariants($product), 2);
229 $product = $fixture(['سایز' => ['S']], [['attributes' => ['سایز' => 'S'], 'price' => '']]);
230 $checkVariants($service->getVariants($product), 0);
231 $validation = (new CreateProductDataValidator())->validate(['category_id' => 1, 'photo' => 1, 'variants' => []], $product->get_id());
232 $assertSame(false, $validation['valid'], 'variable products cannot be created without valid variants');
233 $scenarios[] = 'zero option is preserved and unpriced variations are skipped';
234
235 $product = $fixture(['سایز' => ['S']], [['attributes' => ['سایز' => '']]]);
236 $variationId = $product->get_children()[0];
237 update_post_meta($variationId, 'sync_basalam_variation_id', 70000000);
238 $assertSame(false, isset($service->getVariants($product)[0]['id']), 'legacy empty properties need a full remap even with one option');
239 $remote = [['id' => 80000001, 'properties' => [['property' => 'سایز', 'value' => 'S']]]];
240 $service->syncExpandedVariationIds($product, $remote);
241 $variation = wc_get_product($variationId);
242 $variation->set_attributes([sanitize_title('سایز') => 'S']);
243 $variation->save();
244 $remote[0]['id'] = 80000002;
245 $service->syncExpandedVariationIds($product, $remote);
246 $assertSame(80000002, $service->getVariants($product)[0]['id'], 'mapping refreshes after Any becomes a specific option');
247 $scenarios[] = 'legacy wildcard ids and changing wildcard selections remap safely';
248
249 if (in_array('--live', $argv, true)) {
250 $api = syncBasalamContainer()->get(ApiServiceManager::class);
251 $base = null;
252 foreach (wc_get_products(['type' => 'simple', 'limit' => 20]) as $candidate) {
253 if ($candidate->get_image_id() && get_post_meta($candidate->get_id(), ProductMetaKey::basalamProductId(), true)) {
254 $base = $candidate;
255 break;
256 }
257 }
258 if (!$base) throw new RuntimeException('The live test needs an existing connected simple product with an image.');
259 $baseRemoteId = get_post_meta($base->get_id(), ProductMetaKey::basalamProductId(), true);
260 $response = $api->get(sprintf(Endpoints::PRODUCT_UPDATE, $baseRemoteId));
261 $baseData = json_decode($response['body'], true);
262 $product = $fixture(['سایز' => ['S', 'M', 'L'], 'رنگ' => ['قر�
263 ز', 'آبی']], [
264 ['attributes' => ['سایز' => '', 'رنگ' => ''], 'stock' => 0],
265 ]);
266 $liveProductId = $product->get_id();
267 $product->set_image_id($base->get_image_id());
268 $product->set_weight('0.2');
269 $product->save();
270 $payloadFilter = static function ($data, $p, $handler, $mode) use ($liveProductId, $baseData) {
271 if ($p->get_id() !== $liveProductId || $mode !== 'create') return $data;
272 return array_merge((array) $data, [
273 'category_id' => $baseData['category_id'] ?? $baseData['category']['id'],
274 'photo' => $baseData['photo']['id'],
275 'photos' => [],
276 'weight' => 200,
277 'package_weight' => 300,
278 'preparation_days' => 3,
279 ]);
280 };
281 add_filter('sync_basalam_product_payload', $payloadFilter, 100, 4);
282 $operations = syncBasalamContainer()->get(ProductOperations::class);
283 try {
284 $result = $operations->createNewProduct($liveProductId, null);
285 } finally {
286 remove_filter('sync_basalam_product_payload', $payloadFilter, 100);
287 }
288 $liveRemoteId = $result['basalam_id'];
289 $assertSame(true, $result['success'], 'live creation succeeds');
290 $response = $api->get(sprintf(Endpoints::PRODUCT_UPDATE, $liveRemoteId));
291 $created = json_decode($response['body'], true);
292 $createdVariants = $created['variants'] ?? $created['variant'] ?? [];
293 $assertSame(6, count($createdVariants), 'six variants persist in Basalam');
294 $mapped = $service->getVariants(wc_get_product($liveProductId));
295 $assertSame(6, count(array_column($mapped, 'id')), 'six live remote ids map back to Woo');
296 foreach ($createdVariants as $variant) {
297 $assertSame(0, $variant['stock'], 'live test stays out of stock');
298 foreach ($variant['properties'] as $property) {
299 $assertSame(true, trim($property['value']['title']) !== '', 'live property is populated');
300 }
301 }
302 $liveResult = ['basalam_id' => $liveRemoteId, 'variant_count' => 6, 'variants' => $createdVariants];
303 $scenarios[] = 'real product creation and readback in Basalam';
304 }
305 } catch (Throwable $error) {
306 fwrite(STDERR, wp_json_encode(['passed' => false, 'error' => $error->getMessage(), 'basalam_id' => $liveRemoteId], JSON_UNESCAPED_UNICODE) . PHP_EOL);
307 throw $error;
308 } finally {
309 try {
310 if ($liveRemoteId) {
311 syncBasalamContainer()->get(ProductOperations::class)->archiveExistProduct($liveProductId);
312 $response = syncBasalamContainer()->get(ApiServiceManager::class)->get(sprintf(Endpoints::PRODUCT_UPDATE, $liveRemoteId));
313 $archived = json_decode($response['body'], true);
314 $assertSame(3790, $archived['revision']['data']['status']['value'] ?? $archived['status']['value'], 'archive status is persisted');
315 $liveResult['unpublished'] = true;
316 }
317 } finally {
318 foreach ($products as $id) {
319 $product = wc_get_product($id);
320 if ($product) $product->delete(true);
321 }
322 foreach ($globalAttributes as $id) wc_delete_attribute($id);
323 if ($originalSettings === false) delete_option('sync_basalam_settings');
324 else update_option('sync_basalam_settings', $originalSettings);
325 }
326 }
327
328 echo wp_json_encode(['passed' => true, 'assertions' => $assertions, 'scenarios' => $scenarios, 'live' => $liveResult], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
329