updates[] = $payload; if ($this->failure !== '') { throw new RuntimeException($this->failure); } return ['errcode' => 0, 'errmsg' => 'ok']; } public function getLink(string $linkId): array { $this->gets[] = $linkId; $payload = $this->updates[count($this->updates) - 1] ?? []; $response = [ 'errcode' => 0, 'link' => ['link_id' => $linkId, 'url' => 'https://work.weixin.qq.com/ca/isolated-test'], // Official GET shape: range is at the root, not under link. 'range' => [ 'user_list' => $this->remoteUsers ?? ($payload['range']['user_list'] ?? []), 'department_list' => $this->remoteDepartments, ], ]; if ($this->onGet !== null) { ($this->onGet)(); } return $response; } } $port = (int) getenv('ZYT_WECOM_MEMBER_TEST_MYSQL_PORT'); if ($port < 1024 || $port === 3306 || $port > 65535) { fwrite(STDERR, "Set ZYT_WECOM_MEMBER_TEST_MYSQL_PORT to an isolated local MySQL port (not 3306).\n"); exit(1); } $database = 'wecom_member_test_' . bin2hex(random_bytes(6)); $pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); $pdo->exec("USE `{$database}`"); $testApp = new think\App(); $manager = new DbManager(); $manager->setConfig([ 'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false, 'connections' => ['mysql' => [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port, 'database' => $database, 'username' => 'root', 'password' => '', 'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, ]], ]); Container::getInstance()->instance('think\DbManager', $manager); Container::getInstance()->instance('config', new think\Config()); $checks = 0; $passed = []; $failed = []; $expect = static function (bool $ok, string $message) use (&$checks): void { if (!$ok) { throw new RuntimeException($message); } $checks++; }; $run = static function (string $name, Closure $test) use (&$passed, &$failed): void { try { $test(); $passed[] = $name; echo "PASS {$name}\n"; } catch (Throwable $error) { $failed[$name] = $error->getMessage(); echo "FAIL {$name}: {$error->getMessage()}\n"; } }; $admin = ['root' => 1, 'admin_id' => 1, 'name' => 'Isolated test administrator']; try { // Use the deployed table definitions, without executing unrelated menu/cron mutations. $schemas = [ '1.9.20260805/add_first_visit_wecom_promotion.sql' => ['qywx_promotion_pool', 'qywx_promotion_link'], '1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql' => [ 'qywx_promotion_pool_member', 'qywx_promotion_dispatch_event', 'qywx_promotion_range_sync', ], '1.9.20260828/add_wecom_promotion_pool_operators.sql' => ['qywx_promotion_pool_operator'], ]; foreach ($schemas as $file => $tables) { $sql = file_get_contents(dirname(__DIR__) . '/sql/' . $file); foreach ($tables as $table) { if (!preg_match('/CREATE TABLE IF NOT EXISTS `zyt_' . preg_quote($table, '/') . '` \([\s\S]*?;/', $sql, $match)) { throw new RuntimeException('Missing fixture schema: ' . $table); } $pdo->exec($match[0]); } } $pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)'); $pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)'); $pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(100), is_disable INT DEFAULT 0)'); $fixture = static function (array $syncFields = [], array $cachedUsers = ['XuKe', 'OldAssistant'], array $cachedDepartments = []): array { $poolId = (int) Db::name('qywx_promotion_pool')->insertGetId([ 'name' => '隔离范围同步测试', 'public_key' => bin2hex(random_bytes(16)), 'owner_admin_id' => 1, ]); $linkId = (int) Db::name('qywx_promotion_link')->insertGetId([ 'pool_id' => $poolId, 'remote_link_id' => 'test-remote-' . $poolId, 'wecom_url' => 'https://work.weixin.qq.com/ca/isolated-test', 'remote_status' => 1, 'range_user_json' => json_encode($cachedUsers), 'range_department_json' => json_encode($cachedDepartments), ]); foreach ([['XuKe', 1], ['OldAssistant', 0], ['AnotherOldAssistant', 0]] as $index => [$userId, $enabled]) { Db::name('qywx_promotion_pool_member')->insert([ 'pool_id' => $poolId, 'admin_id' => $index + 1, 'userid' => $userId, 'enabled' => $enabled, 'today_date' => date('Y-m-d'), ]); } Db::name('qywx_promotion_range_sync')->insert(array_replace([ 'pool_id' => $poolId, 'promotion_link_id' => $linkId, 'status' => 1, 'desired_version' => 2, 'applied_version' => 1, ], $syncFields)); return [$poolId, $linkId, new MemberSyncFakeApi()]; }; $syncRow = static fn (int $poolId): array => Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find(); $linkRow = static fn (int $linkId): array => Db::name('qywx_promotion_link')->where('id', $linkId)->find(); $retry = static fn (int $poolId, MemberSyncFakeApi $api): array => WecomPromotionLogic::syncMemberRange( $poolId, 1, $admin, new QywxPromotionRangeSyncService($api) ); $run('only XuKe is sent and both remote range dimensions are verified', static function () use ($fixture, $expect, $syncRow, $linkRow): void { [$poolId, $linkId, $api] = $fixture([], ['XuKe', 'OldAssistant'], ['42']); $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); $expect($result['status'] === 'synced', 'Exact confirmed range should sync'); $expect(count($api->updates) === 1 && $api->gets === ['test-remote-' . $poolId], 'Must update then GET the same official link'); $expect($api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Disabled members and departments must be removed from update'); $link = $linkRow($linkId); $expect(json_decode($link['range_user_json'], true) === ['XuKe'] && json_decode($link['range_department_json'], true) === [], 'Save the GET-verified users and empty departments'); $row = $syncRow($poolId); $expect((int) $row['status'] === 0 && (int) $row['applied_version'] === (int) $row['desired_version'] && $row['lock_token'] === '', 'Confirmed sync must finish its version and release the lease'); }); foreach (['old user' => [['XuKe', 'OldAssistant'], []], 'department expansion' => [['XuKe'], ['42']]] as $name => [$users, $departments]) { $run('GET mismatch rejects ' . $name . ' and remains retryable', static function () use ($fixture, $expect, $syncRow, $linkRow, $users, $departments): void { [$poolId, $linkId, $api] = $fixture(); $api->remoteUsers = $users; $api->remoteDepartments = $departments; $error = null; try { (new QywxPromotionRangeSyncService($api))->syncPool($poolId); } catch (Throwable $caught) { $error = $caught; } $expect($error !== null, 'Mismatched confirmed range must reject sync'); $row = $syncRow($poolId); $expect((int) $row['status'] === 3 && (int) $row['next_retry'] > time(), 'Mismatch must leave a scheduled retry'); $expect((int) $row['applied_version'] === 1 && $row['last_error'] !== '' && $linkRow($linkId)['sync_error'] !== '', 'Failure must not advance confirmed version and must remain visible'); $expect((int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('enabled') === 0, 'Failure must preserve the saved offline switch'); }); } $run('transport failure preserves last confirmed snapshot and retry', static function () use ($fixture, $expect, $syncRow, $linkRow): void { [$poolId, $linkId, $api] = $fixture(); $before = $linkRow($linkId)['range_user_json']; $api->failure = 'fake upstream timeout'; try { (new QywxPromotionRangeSyncService($api))->syncPool($poolId); throw new LogicException('Expected fake transport error'); } catch (RuntimeException $error) { $expect($error->getMessage() === $api->failure, 'Surface the transport error'); } $row = $syncRow($poolId); $expect((int) $row['status'] === 3 && (int) $row['attempts'] === 1 && (int) $row['next_retry'] > time(), 'Transport failure must retain retry backoff'); $expect($linkRow($linkId)['range_user_json'] === $before && $api->gets === [], 'Failed update cannot replace confirmed remote snapshot'); }); $run('active sync lease cannot be reported as synced or stolen', static function () use ($fixture, $expect, $syncRow): void { $token = str_repeat('a', 32); [$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]); $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); $expect($result['status'] !== 'synced' && $api->updates === [] && $syncRow($poolId)['lock_token'] === $token, 'Existing worker keeps its active lease and caller stays pending'); }); $run('version change during GET reports pending and resyncs', static function () use ($fixture, $expect, $syncRow): void { [$poolId, , $api] = $fixture(); $api->onGet = static function () use ($poolId): void { Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->inc('desired_version')->update(); }; $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); $row = $syncRow($poolId); $expect($result['status'] === 'pending' && (int) $row['status'] === 1, 'Stale confirmed version must report pending, never synced'); $expect((int) $row['applied_version'] < (int) $row['desired_version'], 'Concurrent version must remain unconfirmed'); $api->onGet = null; $expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced', 'Next attempt should confirm the newer version'); }); $run('lease expiry during GET cannot commit success', static function () use ($fixture, $expect, $syncRow): void { [$poolId, , $api] = $fixture(); $api->onGet = static function () use ($poolId): void { Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]); }; $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); $expect($result['status'] === 'pending' && (int) $syncRow($poolId)['status'] !== 0, 'Expired worker cannot acknowledge a completed sync'); }); $run('superseded lease cannot overwrite newer worker snapshot', static function () use ($fixture, $expect, $syncRow, $linkRow): void { [$poolId, $linkId, $api] = $fixture(); $newToken = str_repeat('b', 32); $api->onGet = static function () use ($poolId, $linkId, $newToken): void { Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ 'lock_token' => $newToken, 'lock_until' => time() + 90, 'desired_version' => 3, ]); Db::name('qywx_promotion_link')->where('id', $linkId)->update(['range_user_json' => '["NewWorkerSnapshot"]']); }; $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); $expect($result['status'] === 'pending' && $syncRow($poolId)['lock_token'] === $newToken, 'Superseded worker must not release or acknowledge the new lease'); $expect($linkRow($linkId)['range_user_json'] === '["NewWorkerSnapshot"]', 'Superseded worker must not overwrite a newer remote snapshot'); }); foreach ([ 'expired running lease' => ['status' => 2, 'lock_token' => str_repeat('c', 32), 'lock_until' => time() - 1], 'unconfirmed version' => ['status' => 0, 'desired_version' => 7, 'applied_version' => 6], ] as $name => $fields) { $run('reconcile keeps ' . $name . ' pending despite matching cache', static function () use ($fixture, $expect, $syncRow, $fields): void { [$poolId] = $fixture($fields, ['XuKe']); $result = QywxPromotionMemberSchedulerService::reconcilePool($poolId); $expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'An unverified version or expired worker must be retried even when user cache matches'); }); } $run('reconcile removes cached departments even when users match', static function () use ($fixture, $expect, $syncRow): void { [$poolId] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2], ['XuKe'], ['42']); $result = QywxPromotionMemberSchedulerService::reconcilePool($poolId); $expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'Residual department routes require a fresh update'); }); foreach (['pending' => ['status' => 1], 'failed backoff' => ['status' => 3, 'next_retry' => time() + 300, 'last_error' => 'previous failure']] as $name => $fields) { $run('explicit retry repairs old ' . $name . ' without changing member switches', static function () use ($fixture, $expect, $syncRow, $retry, $fields): void { [$poolId, , $api] = $fixture($fields); $before = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid'); $result = $retry($poolId, $api); $expect($result['sync_status'] === 'synced' && $result['sync_error'] === '' && !$result['sync_queued'], 'Explicit retry must complete and return confirmed structured state'); $expect($result['range_userids'] === ['XuKe'] && $result['range_department_ids'] === [] && count($api->updates) === 1, 'Retry must return the GET-confirmed member range'); $expect($before === Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid') && (int) $syncRow($poolId)['status'] === 0, 'Retry must leave all switches unchanged'); }); } $run('explicit retry active lease returns pending without false success', static function () use ($fixture, $expect, $retry, $syncRow): void { $token = str_repeat('d', 32); [$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]); $result = $retry($poolId, $api); $expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $api->updates === [], 'In-flight retry must say pending'); $expect($syncRow($poolId)['lock_token'] === $token && (int) $syncRow($poolId)['status'] === 2, 'Retry must preserve active worker ownership'); }); $run('explicit retry API failure remains failed with saved local state', static function () use ($fixture, $expect, $retry, $syncRow): void { [$poolId, , $api] = $fixture(); $api->failure = 'fake permission denied'; $result = $retry($poolId, $api); $expect($result['sync_status'] === 'failed' && $result['sync_error'] !== '' && $result['sync_queued'], 'Failure should return a visible error and scheduled retry'); $expect((int) $syncRow($poolId)['status'] === 3 && $result['range_userids'] === ['XuKe', 'OldAssistant'], 'Failure must expose the last confirmed range, including pending removal'); }); $run('no eligible member is blocked and never sends empty official range', static function () use ($fixture, $expect, $retry, $syncRow): void { [$poolId, , $api] = $fixture(); Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 0]); $result = $retry($poolId, $api); $expect($result['sync_status'] === 'blocked' && $result['sync_error'] !== '' && !$result['sync_queued'], 'Empty eligible range must clearly report blocked'); $expect($api->updates === [] && (int) $syncRow($poolId)['status'] === 4, 'Blocked pool must not send an empty enterprise WeChat range'); }); $run('explicit retry rejects pools outside operator scope', static function () use ($fixture, $expect): void { [$poolId, , $api] = $fixture(); $error = null; try { WecomPromotionLogic::syncMemberRange($poolId, 99, ['root' => 0, 'admin_id' => 99], new QywxPromotionRangeSyncService($api)); } catch (RuntimeException $caught) { $error = $caught; } $expect($error !== null && $api->updates === [], 'Unrelated account must not mutate remote member ranges'); }); $run('batch repeated offline selection requeues the unsynced remote range', static function () use ($fixture, $expect, $syncRow, $admin): void { [$poolId, , $api] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2]); $result = WecomPromotionLogic::batchUpdatePools([ 'pool_ids' => [$poolId], 'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]], ], 1, $admin); $expect($result['failed'] === 0 && $result['member_matched'] === 2 && $result['member_updated'] === 0, 'An already-offline selection remains a valid repeat action'); $expect($result['sync_queued_count'] === 1 && $result['results'][0]['sync_queued'] && (int) $syncRow($poolId)['status'] === 1, 'No-op local switches must still queue the stale official range'); $expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'The queued batch repair must leave only XuKe in the official link'); }); $run('batch disables both old assistants and queue confirms XuKe only', static function () use ($fixture, $expect, $admin): void { [$poolId, , $api] = $fixture([], ['XuKe', 'OldAssistant', 'AnotherOldAssistant']); Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 1]); $result = WecomPromotionLogic::batchUpdatePools([ 'pool_ids' => [$poolId], 'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]], ], 1, $admin); $expect($result['member_updated'] === 2 && $result['failed'] === 0 && $result['sync_queued_count'] === 1, 'Batch offline must save both switches and report queued remote work'); $expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Actual batch queue must update/get the final exact range'); }); $run('single offline with active worker reports pending then retries successfully', static function () use ($fixture, $expect, $admin, $retry, $syncRow): void { $token = str_repeat('e', 32); [$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]); Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->update(['enabled' => 1]); $memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('id'); // Active lease prevents any real API request from the endpoint's default service. $result = WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin); $expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $syncRow($poolId)['lock_token'] === $token, 'Single toggle must not turn a service noop into success: ' . json_encode($result, JSON_UNESCAPED_UNICODE)); $expect((int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 0, 'Single toggle must persist offline locally while waiting for its worker'); Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]); $expect($retry($poolId, $api)['sync_status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'Explicit retry must clear the single-toggle pending removal'); }); $run('single and batch preserve at least one eligible assistant', static function () use ($fixture, $expect, $admin): void { [$poolId] = $fixture(); $memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'XuKe')->value('id'); foreach (['single', 'batch'] as $method) { $error = null; try { if ($method === 'single') { WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin); } else { WecomPromotionLogic::batchUpdatePools([ 'pool_ids' => [$poolId], 'changes' => ['member_status' => ['member_admin_ids' => [1], 'status' => 0]], ], 1, $admin); } } catch (RuntimeException $caught) { $error = $caught; } $expect($error !== null && (int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 1, 'The final available member must remain online after rejected ' . $method . ' action'); } }); $run('shared operator can retry its own assigned pool', static function () use ($fixture, $expect): void { [$poolId, , $api] = $fixture(); Db::name('system_menu')->insert(['id' => 1, 'perms' => 'firstvisit.wecomPromotion/overview']); Db::name('qywx_promotion_pool_operator')->insert(['pool_id' => $poolId, 'admin_id' => 88]); $result = WecomPromotionLogic::syncMemberRange($poolId, 88, ['root' => 0, 'admin_id' => 88], new QywxPromotionRangeSyncService($api)); $expect($result['sync_status'] === 'synced' && count($api->updates) === 1, 'Assigned operator should be allowed the targeted range retry'); }); foreach ([ 'deleting' => ['status' => 5, 'lock_token' => str_repeat('f', 32), 'lock_until' => time() + 90], 'delete failed' => ['status' => 4, 'last_error' => '企业微信官方获客链接删除失败: fake timeout'], ] as $name => $fields) { $run('retry never revives ' . $name . ' pool', static function () use ($fixture, $expect, $retry, $syncRow, $fields): void { [$poolId, , $api] = $fixture($fields); $before = $syncRow($poolId); $result = $retry($poolId, $api); $expect($result['sync_status'] === 'blocked' && $api->updates === [] && $syncRow($poolId) === $before, 'Retry must preserve deletion ownership and leave remote API untouched'); }); } echo json_encode(['passed' => count($passed), 'failed' => count($failed), 'checks' => $checks, 'failures' => $failed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; } finally { $pdo->exec("DROP DATABASE IF EXISTS `{$database}`"); echo "Disposable test database dropped.\n"; } exit($failed === [] ? 0 : 1);