| 1 |
<?php |
| 2 |
|
| 3 |
// Local WordPress + real MySQL queue/advisory locks. One child is killed mid-job. |
| 4 |
// docker exec -w /var/www/html/wp-content/plugins/sync-basalam \ |
| 5 |
// basalam-wordpress-local-wordpress-1 php tests/integration/async-worker-health.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\JobManager; |
| 10 |
use SyncBasalam\Jobs\JobExecutor; |
| 11 |
use SyncBasalam\Jobs\JobRegistry; |
| 12 |
use SyncBasalam\Jobs\JobResult; |
| 13 |
use SyncBasalam\Jobs\JobType; |
| 14 |
use SyncBasalam\Jobs\LockManager; |
| 15 |
use SyncBasalam\Jobs\Exceptions\NonRetryableException; |
| 16 |
use SyncBasalam\JobsRunner; |
| 17 |
use SyncBasalam\Services\AsyncWorkerHealth; |
| 18 |
use SyncBasalam\Services\CheckHttpBlockService; |
| 19 |
|
| 20 |
if (wp_get_environment_type() !== 'local') throw new RuntimeException('Local environment only.'); |
| 21 |
|
| 22 |
$installedRunner = syncBasalamContainer()->get(JobsRunner::class); |
| 23 |
remove_action('shutdown', [$installedRunner, 'maybeDispatchAsyncRequest'], 2); |
| 24 |
remove_action('sync_basalam_job_created', [$installedRunner, 'maybeDispatchAsyncRequest'], 10); |
| 25 |
remove_action('admin_notices', [$installedRunner, 'renderWorkerHealthNotice']); |
| 26 |
|
| 27 |
class WorkerHealthFixture implements JobType |
| 28 |
{ |
| 29 |
public function getType(): string { return 'sync_basalam_health_integration'; } |
| 30 |
public function getPriority(): int { return 0; } |
| 31 |
public function canRun(): bool { return true; } |
| 32 |
public function execute(array $payload) |
| 33 |
{ |
| 34 |
if (($payload['mode'] ?? '') === 'hang') sleep(30); |
| 35 |
if (($payload['mode'] ?? '') === 'reject') { |
| 36 |
return JobResult::failed(new NonRetryableException('fixture API rejection (422)', 422)); |
| 37 |
} |
| 38 |
return JobResult::success(); |
| 39 |
} |
| 40 |
} |
| 41 |
|
| 42 |
class FixtureQueue extends JobManager |
| 43 |
{ |
| 44 |
// Leave other local-development queues alone. |
| 45 |
public function hasPendingOrStaleProcessingJobs(int $timeout = 120): bool |
| 46 |
{ |
| 47 |
return $this->getCountJobs(['job_type' => 'sync_basalam_health_integration', 'status' => ['pending', 'processing']]) > 0; |
| 48 |
} |
| 49 |
} |
| 50 |
|
| 51 |
class LiteSpeedHealthFixture extends AsyncWorkerHealth |
| 52 |
{ |
| 53 |
protected function isLiteSpeed(): bool { return true; } |
| 54 |
} |
| 55 |
|
| 56 |
$queue = new FixtureQueue(); |
| 57 |
$lock = new LockManager(); |
| 58 |
$health = new LiteSpeedHealthFixture(); |
| 59 |
$runner = new JobsRunner($queue, new JobExecutor($queue, $lock, new JobRegistry([new WorkerHealthFixture()])), new class { |
| 60 |
public function process(): void {} |
| 61 |
}, new CheckHttpBlockService(), $health); |
| 62 |
remove_action('shutdown', [$runner, 'maybeDispatchAsyncRequest'], 2); |
| 63 |
remove_action('sync_basalam_job_created', [$runner, 'maybeDispatchAsyncRequest'], 10); |
| 64 |
$run = new ReflectionMethod($runner, 'runAsyncBatch'); |
| 65 |
$run->setAccessible(true); |
| 66 |
add_filter('sync_basalam_jobs_runner_async_time_limit', static function () { return 0.15; }); |
| 67 |
|
| 68 |
if (($argv[1] ?? '') === 'worker') { |
| 69 |
$_SERVER['noabort'] = '1'; |
| 70 |
$run->invoke($runner); |
| 71 |
exit; |
| 72 |
} |
| 73 |
|
| 74 |
$table = $wpdb->prefix . 'sync_basalam_job_manager'; |
| 75 |
$snapshots = []; |
| 76 |
foreach ([AsyncWorkerHealth::OPTION_KEY, 'sync_basalam_settings', 'sync_basalam_jobs_runner_last_run'] as $name) { |
| 77 |
$snapshots[$name] = get_option($name, null); |
| 78 |
} |
| 79 |
$originalGet = $_GET; |
| 80 |
$originalServer = $_SERVER; |
| 81 |
$originalUser = get_current_user_id(); |
| 82 |
$jobs = []; |
| 83 |
$assertions = 0; |
| 84 |
$scenarios = []; |
| 85 |
$child = null; |
| 86 |
$assert = static function (bool $ok, string $message) use (&$assertions): void { |
| 87 |
if (!$ok) throw new RuntimeException($message); |
| 88 |
$assertions++; |
| 89 |
}; |
| 90 |
$create = static function (string $mode) use ($queue, $wpdb, &$jobs): int { |
| 91 |
$queue->createJob('sync_basalam_health_integration', 'pending', wp_json_encode(['mode' => $mode])); |
| 92 |
$id = (int) $wpdb->insert_id; |
| 93 |
$jobs[] = $id; |
| 94 |
update_option('sync_basalam_jobs_runner_last_run', 0, false); |
| 95 |
return $id; |
| 96 |
}; |
| 97 |
$notice = static function () use ($runner): string { |
| 98 |
ob_start(); |
| 99 |
$runner->renderWorkerHealthNotice(); |
| 100 |
return ob_get_clean(); |
| 101 |
}; |
| 102 |
|
| 103 |
try { |
| 104 |
$assert($lock->acquireGlobalJobsLock(), 'Local runner must be idle before testing.'); |
| 105 |
$lock->releaseGlobalJobsLock(); |
| 106 |
$assert($queue->getCountJobs(['status' => 'processing']) === 0, 'Other local jobs must not be processing.'); |
| 107 |
$assert($queue->getCountJobs(['status' => 'pending']) === 0, 'Other local jobs must not be pending during HTTP tests.'); |
| 108 |
|
| 109 |
$response = wp_remote_post(admin_url('admin-ajax.php?action=sync_basalam_run_jobs_async'), [ |
| 110 |
'timeout' => 10, |
| 111 |
'body' => ['action' => 'sync_basalam_run_jobs_async', 'nonce' => 'invalid-fixture-nonce'], |
| 112 |
]); |
| 113 |
$assert(!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 403, 'Actual AJAX endpoint must reject an invalid nonce.'); |
| 114 |
$response = wp_remote_post(admin_url('admin-ajax.php?action=sync_basalam_run_jobs_async'), [ |
| 115 |
'timeout' => 10, |
| 116 |
'body' => ['action' => 'sync_basalam_run_jobs_async', 'nonce' => wp_create_nonce('sync_basalam_run_jobs_async')], |
| 117 |
]); |
| 118 |
$json = json_decode(wp_remote_retrieve_body($response), true); |
| 119 |
$assert(!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200, 'Actual AJAX endpoint must accept a valid nonce.'); |
| 120 |
$assert(($json['success'] ?? false) && ($json['data']['processed'] ?? null) === 0, 'Empty AJAX worker must report zero processed jobs.'); |
| 121 |
$scenarios[] = 'actual HTTP AJAX nonce validation and empty response'; |
| 122 |
delete_option(AsyncWorkerHealth::OPTION_KEY); |
| 123 |
$settings = get_option('sync_basalam_settings', []); |
| 124 |
$settings['tasks_per_minute'] = 600; |
| 125 |
$settings['tasks_per_minute_auto'] = false; |
| 126 |
update_option('sync_basalam_settings', $settings); |
| 127 |
|
| 128 |
$_SERVER['noabort'] = '1'; |
| 129 |
$id = $create('success'); |
| 130 |
$assert($run->invoke($runner) === 1, 'Protected real runner must execute a job.'); |
| 131 |
$assert($queue->getJob(['id' => $id]) === null, 'Successful job must be removed normally.'); |
| 132 |
$assert($health->getNotice() === null, 'Healthy worker must not alert.'); |
| 133 |
$scenarios[] = 'actual queue success and normal cleanup'; |
| 134 |
|
| 135 |
$id = $create('reject'); |
| 136 |
$run->invoke($runner); |
| 137 |
$job = $queue->getJob(['id' => $id]); |
| 138 |
$assert($job->status === 'failed' && strpos($job->error_message, '422') !== false, 'API rejection must be preserved.'); |
| 139 |
$assert($health->getNotice() === null, 'API rejection must not be a host alert.'); |
| 140 |
$scenarios[] = 'explicit API rejection remains separate'; |
| 141 |
|
| 142 |
unset($_SERVER['noabort']); |
| 143 |
$create('success'); |
| 144 |
$run->invoke($runner); |
| 145 |
$assert($health->getNotice()['class'] === 'notice-warning', 'Missing noabort must warn even when a quick job returns.'); |
| 146 |
$scenarios[] = 'missing protection persists after fast completion'; |
| 147 |
|
| 148 |
$_GET['page'] = 'sync_basalam'; |
| 149 |
$admins = get_users(['role' => 'administrator', 'number' => 1]); |
| 150 |
$assert(!empty($admins), 'A local administrator must exist.'); |
| 151 |
wp_set_current_user($admins[0]->ID); |
| 152 |
$html = $notice(); |
| 153 |
$assert(strpos($html, 'role="alert"') !== false && strpos($html, 'noabort') !== false, 'Authorized admin sees actionable RTL alert.'); |
| 154 |
$assert(strpos($html, '<IfModule') !== false, 'Technical rule must be escaped.'); |
| 155 |
file_put_contents('/tmp/sync-basalam-health-warning.html', '<!doctype html><meta charset="utf-8"><body dir="rtl">' . $html); |
| 156 |
wp_set_current_user(0); |
| 157 |
$assert($notice() === '', 'Anonymous users cannot see admin notice.'); |
| 158 |
wp_set_current_user($admins[0]->ID); |
| 159 |
$scenarios[] = 'real WordPress permissions and rendered notice'; |
| 160 |
|
| 161 |
$_SERVER['noabort'] = '1'; |
| 162 |
$id = $create('hang'); |
| 163 |
$child = proc_open([PHP_BINARY, __FILE__, 'worker'], [0 => ['pipe', 'r'], 1 => ['file', '/tmp/sync-basalam-health-child.log', 'w'], 2 => ['file', '/tmp/sync-basalam-health-child.log', 'a']], $pipes); |
| 164 |
$assert(is_resource($child), 'Child worker must start.'); |
| 165 |
$deadline = microtime(true) + 8; |
| 166 |
do { |
| 167 |
usleep(100000); |
| 168 |
$job = $queue->getJob(['id' => $id]); |
| 169 |
} while ($job->status !== 'processing' && microtime(true) < $deadline); |
| 170 |
$assert($job->status === 'processing', 'Child must reach execution under real MySQL lock.'); |
| 171 |
$queue->updateJob(['started_at' => time() - 121], ['id' => $id]); |
| 172 |
$assert(!$lock->acquireGlobalJobsLock(), 'Concurrent request must not acquire live worker lock.'); |
| 173 |
$assert($run->invoke($runner) === 0, 'Concurrent runner must not duplicate execution.'); |
| 174 |
$assert(strpos($notice(), 'اجرای ه� |
| 175 |
گا� |
| 176 |
سازی نی� |
| 177 |
هکاره � |
| 178 |
توقف شده است.') === false, 'A slow live worker must not be called interrupted.'); |
| 179 |
$scenarios[] = 'real concurrent MySQL lock excludes slow active worker'; |
| 180 |
|
| 181 |
proc_terminate($child, 9); |
| 182 |
proc_close($child); |
| 183 |
$child = null; |
| 184 |
$deadline = microtime(true) + 5; |
| 185 |
do { |
| 186 |
usleep(100000); |
| 187 |
$free = $lock->acquireGlobalJobsLock(); |
| 188 |
} while (!$free && microtime(true) < $deadline); |
| 189 |
$assert($free, 'Killed worker must release MySQL lock on disconnect.'); |
| 190 |
$lock->releaseGlobalJobsLock(); |
| 191 |
$html = $notice(); |
| 192 |
$assert(strpos($html, 'اجرای ه� |
| 193 |
گا� |
| 194 |
سازی نی� |
| 195 |
هکاره � |
| 196 |
توقف شده است.') !== false, 'Next admin request must discover interruption without relying on dead worker.'); |
| 197 |
$assert($health->getNotice()['job_id'] === $id, 'Alert must identify interrupted job.'); |
| 198 |
$scenarios[] = 'SIGKILL interruption detected independently'; |
| 199 |
|
| 200 |
$queue->updateJob(['retry_after' => time() + 60], ['id' => $id]); |
| 201 |
$assert($run->invoke($runner) === 0, 'Batch without eligible jobs must not claim recovery.'); |
| 202 |
$assert($health->getNotice() !== null, 'Empty worker must not clear interruption warning.'); |
| 203 |
$queue->updateJob(['payload' => wp_json_encode(['mode' => 'success']), 'retry_after' => 0], ['id' => $id]); |
| 204 |
update_option('sync_basalam_jobs_runner_last_run', 0, false); |
| 205 |
$assert($run->invoke($runner) === 1, 'Stale job must be retried by normal runner.'); |
| 206 |
$assert($queue->getJob(['id' => $id]) === null, 'Retried job must complete normally.'); |
| 207 |
$assert($health->getNotice() === null, 'Protected real execution must clear notice.'); |
| 208 |
$scenarios[] = 'stale recovery followed by protected execution clears warning'; |
| 209 |
|
| 210 |
echo wp_json_encode(['success' => true, 'scenarios' => $scenarios, 'assertions' => $assertions], JSON_PRETTY_PRINT) . PHP_EOL; |
| 211 |
} finally { |
| 212 |
if (is_resource($child)) { proc_terminate($child, 9); proc_close($child); } |
| 213 |
foreach ($jobs as $id) $queue->deleteJob(['id' => $id]); |
| 214 |
foreach ($snapshots as $name => $value) { |
| 215 |
if ($value === null) delete_option($name); |
| 216 |
else update_option($name, $value, false); |
| 217 |
} |
| 218 |
$_GET = $originalGet; |
| 219 |
$_SERVER = $originalServer; |
| 220 |
wp_set_current_user($originalUser); |
| 221 |
$lock->releaseGlobalJobsLock(); |
| 222 |
} |
| 223 |
|