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 / product-sync-operations.php

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

279 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // Run in the local WordPress stack:
4 // docker exec -w /var/www/html/wp-content/plugins/sync-basalam \
5 // basalam-wordpress-local-wordpress-1 php tests/integration/product-sync-operations.php
6 defined('DISABLE_WP_CRON') || define('DISABLE_WP_CRON', true);
7 require getenv('WORDPRESS_BOOTSTRAP') ?: dirname(__DIR__, 5) . '/wp-load.php';
8
9 use SyncBasalam\Admin\Components\SettingPageComponents;
10 use SyncBasalam\Admin\Settings\SettingsConfig;
11 use SyncBasalam\Admin\Settings\SettingsContainer;
12 use SyncBasalam\Admin\Settings\SettingsManager;
13 use SyncBasalam\Admin\Settings\SettingsPageHandler;
14 use SyncBasalam\Config\Endpoints;
15 use SyncBasalam\JobManager;
16 use SyncBasalam\Jobs\JobExecutor;
17 use SyncBasalam\Jobs\LockManager;
18 use SyncBasalam\JobsRunner;
19 use SyncBasalam\Utilities\ProductMetaKey;
20
21 if (wp_get_environment_type() !== 'local') {
22 throw new RuntimeException('Run these integration tests only in a local WordPress environment.');
23 }
24
25 $lock = new LockManager();
26 if (!$lock->acquireGlobalJobsLock()) throw new RuntimeException('The local product worker is busy. Retry when it is idle.');
27
28 $originalSettings = get_option('sync_basalam_settings');
29 $originalPost = $_POST;
30 $products = [];
31 $attachmentId = 0;
32 $requests = [];
33 $assertions = 0;
34 $scenarios = [];
35 $mockProductId = 900000000;
36 $mockVariantId = 800000000;
37 $jobManager = new JobManager();
38 $jobTable = $wpdb->prefix . 'sync_basalam_job_manager';
39 $runner = syncBasalamContainer()->get(JobsRunner::class);
40 remove_action('shutdown', [$runner, 'maybeDispatchAsyncRequest'], 2);
41 remove_action('sync_basalam_job_created', [$runner, 'maybeDispatchAsyncRequest'], 10);
42
43 $assertSame = static function ($expected, $actual, string $scenario) use (&$assertions): void {
44 if ($expected !== $actual) {
45 throw new RuntimeException($scenario . ': expected ' . var_export($expected, true) . ', got ' . var_export($actual, true));
46 }
47 $assertions++;
48 };
49 $refreshSettings = static function (): void {
50 $property = new ReflectionProperty(SettingsContainer::class, 'settings');
51 $property->setAccessible(true);
52 $property->setValue(syncBasalamSettings(), null);
53 };
54 $configure = static function (bool $create, bool $update) use ($refreshSettings): void {
55 SettingsManager::updateSettings([
56 SettingsConfig::SYNC_STATUS_PRODUCT_CREATE => $create ? '1' : '0',
57 SettingsConfig::SYNC_STATUS_PRODUCT_UPDATE => $update ? '1' : '0',
58 ]);
59 $refreshSettings();
60 };
61 $jobsFor = static function (int $productId) use ($wpdb, $jobTable): array {
62 $jobs = $wpdb->get_results("SELECT * FROM {$jobTable} ORDER BY id");
63 return array_values(array_filter($jobs, static function ($job) use ($productId): bool {
64 $payload = json_decode($job->payload, true);
65 return is_array($payload) && (int) ($payload['product_id'] ?? 0) === $productId;
66 }));
67 };
68 $addVariableVariation = static function ($product, string $type) use (&$products): void {
69 if ($type !== WC_Product_Variable::class) return;
70
71 $variation = new WC_Product_Variation();
72 $variation->set_parent_id($product->get_id());
73 $variation->set_status('publish');
74 $variation->set_attributes([sanitize_title('سایز') => 'S']);
75 $variation->set_regular_price('25000');
76 $variation->set_manage_stock(true);
77 $variation->set_stock_quantity(3);
78 $variation->set_stock_status('instock');
79 $products[] = $variation->save();
80
81 WC_Product_Variable::sync($product->get_id());
82 wc_delete_product_transients($product->get_id());
83 };
84
85 // Keep the real product operations and HTTP adapters; only the remote responses
86 // and uploaded media/category fixture values are replaced.
87 $mockHttp = static function ($preempt, $args, $url) use (&$requests, &$mockProductId, &$mockVariantId) {
88 $method = $args['method'] ?? 'GET';
89 if ($method === 'POST' && preg_match('~/v1/vendors/987654321/products$~', $url)) {
90 $id = ++$mockProductId;
91 $status = 201;
92 } elseif ($method === 'PATCH' && preg_match('~/v1/products/(9[0-9]{8})$~', $url, $matches)) {
93 $id = (int) $matches[1];
94 $status = 200;
95 } else {
96 return new WP_Error('unexpected_integration_http_request', 'Unexpected HTTP request during integration test: ' . $method . ' ' . $url);
97 }
98 $payload = json_decode($args['body'], true);
99 $requests[] = ['method' => $method, 'url' => $url, 'payload' => $payload, 'id' => $id];
100 $responsePayload = array_merge($payload, ['id' => $id]);
101 if (isset($responsePayload['variants']) && is_array($responsePayload['variants'])) {
102 $responsePayload['variants'] = array_map(static function (array $variant) use (&$mockVariantId): array {
103 $variant['id'] = ++$mockVariantId;
104 $variant['properties'] = array_map(static function (array $property): array {
105 return [
106 'property' => ['title' => (string) ($property['property'] ?? '')],
107 'value' => ['title' => (string) ($property['value'] ?? '')],
108 ];
109 }, $variant['properties'] ?? []);
110 return $variant;
111 }, $responsePayload['variants']);
112 }
113 return [
114 'headers' => [],
115 'body' => wp_json_encode($responsePayload),
116 'response' => ['code' => $status, 'message' => 'OK'],
117 'cookies' => [],
118 ];
119 };
120 $fixturePayload = static function ($data, $product) use (&$products) {
121 if (!in_array($product->get_id(), $products, true)) return $data;
122 return [
123 'name' => $product->get_name(), 'sku' => null, 'description' => 'Product sync integration fixture',
124 'category_id' => 100, 'photo' => 200, 'photos' => [], 'video' => null,
125 'weight' => 100, 'package_weight' => 50, 'status' => 2976, 'preparation_days' => 1,
126 'unit_type' => 6304, 'unit_quantity' => 1, 'is_wholesale' => false,
127 'product_attribute' => [], 'primary_price' => 250000, 'stock' => 3,
128 ];
129 };
130 add_filter('pre_http_request', $mockHttp, PHP_INT_MAX, 3);
131 add_filter('sync_basalam_product_payload', $fixturePayload, PHP_INT_MAX, 4);
132
133 try {
134 update_option('sync_basalam_settings', array_merge(SettingsConfig::getDefaultSettings(), [
135 SettingsConfig::VENDOR_ID => 987654321,
136 SettingsConfig::TOKEN => 'local-integration-fixture',
137 SettingsConfig::AUTO_FULL_UPDATE_INTERVAL => 0,
138 ]));
139 $refreshSettings();
140 $attachmentId = wp_insert_attachment([
141 'post_title' => 'Product sync integration image',
142 'post_mime_type' => 'image/png',
143 'post_status' => 'inherit',
144 ]);
145 if (is_wp_error($attachmentId)) throw new RuntimeException($attachmentId->get_error_message());
146
147 foreach ([[false, false], [true, false], [false, true], [true, true]] as [$create, $update]) {
148 foreach ([WC_Product_Simple::class, WC_Product_Variable::class] as $type) {
149 foreach (['new', 'unconnected_update', 'connected_update'] as $scenario) {
150 $configure(false, false);
151 $product = new $type();
152 $product->set_name('�
153 حصول تست ه�
154 گا�
155 ‌سازی');
156 $product->set_status('publish');
157 $product->set_regular_price('25000');
158 $product->set_image_id($attachmentId);
159 $product->set_weight('100');
160
161 if ($type === WC_Product_Variable::class) {
162 $attribute = new WC_Product_Attribute();
163 $attribute->set_name('سایز');
164 $attribute->set_options(['S']);
165 $attribute->set_variation(true);
166 $product->set_attributes([$attribute]);
167 }
168
169 if ($scenario !== 'new') {
170 $products[] = $product->save();
171 $addVariableVariation($product, $type);
172 $assertSame([], $jobsFor($product->get_id()), 'fixture save while sync disabled');
173 if ($scenario === 'connected_update') {
174 update_post_meta($product->get_id(), ProductMetaKey::basalamProductId(), ++$mockProductId);
175 }
176 }
177
178 $configure($create, $update);
179 $label = $scenario . ' / ' . $type . ' / create=' . (int) $create . ', update=' . (int) $update;
180 $product->set_name('�
181 حصول تست ه�
182 گا�
183 ‌سازی ' . $label);
184 $productId = $product->save(); // Actual WooCommerce hooks, including overlapping save hooks.
185 $addVariableVariation($product, $type);
186 if ($scenario === 'new') $products[] = $productId;
187 $jobs = $jobsFor($productId);
188 $enabled = $scenario === 'connected_update' ? $update : $create;
189 $assertSame($enabled ? 1 : 0, count($jobs), $label . ' queued job count');
190
191 $requestCount = count($requests);
192 if ($enabled) {
193 $operation = $scenario === 'connected_update' ? 'update' : 'create';
194 $expectedType = 'sync_basalam_' . $operation . '_single_product';
195 $assertSame($expectedType, $jobs[0]->job_type, $label . ' job type');
196 $assertSame(true, json_decode($jobs[0]->payload, true)['automatic'], $label . ' automatic job marker');
197 $assertSame(true, syncBasalamContainer()->get(JobExecutor::class)->execute($jobs[0]->job_type, $jobs[0]), $label . ' job execution');
198 $assertSame($requestCount + 1, count($requests), $label . ' HTTP request count');
199 $request = end($requests);
200 $assertSame($operation === 'create' ? 'POST' : 'PATCH', $request['method'], $label . ' HTTP method');
201 $expectedUrl = $operation === 'create' ? sprintf(Endpoints::PRODUCT_CREATE, 987654321) : sprintf(Endpoints::PRODUCT_UPDATE, $request['id']);
202 $assertSame($expectedUrl, $request['url'], $label . ' HTTP endpoint');
203 $assertSame($product->get_name(), $request['payload']['name'], $label . ' persisted product name');
204 $assertSame($request['id'], (int) get_post_meta($productId, ProductMetaKey::basalamProductId(), true), $label . ' Basalam connection');
205 $assertSame('synced', get_post_meta($productId, ProductMetaKey::basalamProductSyncStatus(), true), $label . ' sync status');
206 } else {
207 $assertSame($requestCount, count($requests), $label . ' disabled HTTP count');
208 }
209 $assertSame([], $jobsFor($productId), $label . ' empty fixture queue');
210 $scenarios[] = $label;
211 }
212 }
213 }
214
215 // Disabling each operation after enqueueing must prevent its HTTP request.
216 foreach (['create', 'update'] as $operation) {
217 $configure(false, false);
218 $product = new WC_Product_Simple();
219 $product->set_name('�
220 حصول تست توقف صف');
221 $product->set_status('publish');
222 $product->set_regular_price('25000');
223 $product->set_image_id($attachmentId);
224 $products[] = $product->save();
225 $id = $product->get_id();
226 if ($operation === 'update') update_post_meta($id, ProductMetaKey::basalamProductId(), ++$mockProductId);
227 $configure(true, true);
228 $product->save();
229 $jobs = $jobsFor($id);
230 $assertSame(1, count($jobs), $operation . ' stop fixture queue');
231 $configure($operation !== 'create', $operation !== 'update');
232 $requestCount = count($requests);
233 $assertSame(true, syncBasalamContainer()->get(JobExecutor::class)->execute($jobs[0]->job_type, $jobs[0]), $operation . ' disabled queue execution');
234 $assertSame($requestCount, count($requests), $operation . ' disabled queue makes no HTTP request');
235 $assertSame([], $jobsFor($id), $operation . ' disabled queue cleared');
236 $scenarios[] = $operation . ' disabled after enqueue';
237 }
238
239 // Rendered unchecked checkboxes must submit zero and save independently.
240 foreach ([[false, false], [true, false], [false, true], [true, true]] as [$create, $update]) {
241 $configure($create, $update);
242 ob_start();
243 SettingPageComponents::renderProductSyncOperations();
244 $html = ob_get_clean();
245 $dom = new DOMDocument();
246 $dom->loadHTML('<?xml encoding="UTF-8">' . $html);
247 $submitted = [];
248 foreach ($dom->getElementsByTagName('input') as $input) {
249 if ($input->getAttribute('type') === 'checkbox' && !$input->hasAttribute('checked')) continue;
250 preg_match('/sync_basalam_settings\[([^\]]+)\]/', $input->getAttribute('name'), $matches);
251 $submitted[$matches[1]] = $input->getAttribute('value');
252 }
253 $assertSame($create ? '1' : '0', $submitted[SettingsConfig::SYNC_STATUS_PRODUCT_CREATE], 'rendered create choice');
254 $assertSame($update ? '1' : '0', $submitted[SettingsConfig::SYNC_STATUS_PRODUCT_UPDATE], 'rendered update choice');
255 $configure(!$create, !$update);
256 $_POST = ['sync_basalam_settings' => wp_slash($submitted)];
257 $assertSame(true, SettingsPageHandler::saveSettings(), 'settings form saves');
258 $refreshSettings();
259 $assertSame($create, SettingsManager::isProductCreationSyncEnabled(), 'creation choice saved');
260 $assertSame($update, SettingsManager::isProductUpdateSyncEnabled(), 'update choice saved');
261 $scenarios[] = 'settings form / create=' . (int) $create . ', update=' . (int) $update;
262 }
263
264 echo wp_json_encode(['success' => true, 'scenarios' => count($scenarios), 'assertions' => $assertions, 'mocked_http_requests' => count($requests)], JSON_PRETTY_PRINT) . PHP_EOL;
265 } finally {
266 $configure(false, false);
267 foreach ($products as $id) {
268 foreach ($jobsFor($id) as $job) $jobManager->deleteJob(['id' => $job->id]);
269 wp_delete_post($id, true);
270 }
271 if ($attachmentId) wp_delete_attachment($attachmentId, true);
272 update_option('sync_basalam_settings', $originalSettings);
273 $refreshSettings();
274 $_POST = $originalPost;
275 remove_filter('pre_http_request', $mockHttp, PHP_INT_MAX);
276 remove_filter('sync_basalam_product_payload', $fixturePayload, PHP_INT_MAX);
277 $lock->releaseGlobalJobsLock();
278 }
279