ID); echo "browser login ready\n"; exit; } if (($argv[1] ?? '') === 'install') { if (!is_blog_installed()) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; wp_install('Forum check', 'forum_admin', 'forum@example.test', false, '', wp_generate_password(24)); } update_option('home', 'http://localhost:9081'); update_option('siteurl', 'http://localhost:9081'); switch_theme('jio'); echo "installed\n"; exit; } function forum_check(bool $condition, string $label): void { if (!$condition) { throw new RuntimeException($label); } echo "OK: {$label}\n"; } function forum_request(string $method, string $route, int $user_id, array $params = [], string $key = ''): WP_REST_Response { wp_set_current_user($user_id); $request = new WP_REST_Request($method, '/jio/v1/forum/' . ltrim($route, '/')); $request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest')); if ($key !== '') $request->set_header('Idempotency-Key', $key); $request->set_body_params($params); $response = rest_do_request($request); forum_check($response instanceof WP_REST_Response, "REST response {$route}"); return $response; } forum_check(post_type_exists(\Jio\Forum\Core::PLATE), 'plate registered'); forum_check(post_type_exists(\Jio\Forum\Core::POST), 'post registered'); forum_check(taxonomy_exists(\Jio\Forum\Core::TOPIC), 'topic registered'); forum_check(get_page_by_path('forums') instanceof WP_Post, 'forum home created'); $schema = \Jio\Admin\SchemaProvider::get_instance()->get_schema('forum'); forum_check(is_array($schema) && count($schema['sections'] ?? []) === 7, 'forum settings schema'); $invalid_urls = \Jio\Admin\SchemaProvider::get_instance()->validate('forum', ['jio_forum_slug_plate' => 'forum-post'], 'urls'); forum_check(!$invalid_urls['valid'], 'duplicate URL slugs rejected'); $admin = get_user_by('login', 'forum_admin'); forum_check($admin instanceof WP_User, 'admin exists'); wp_set_current_user((int) $admin->ID); $admin_schema_request = new WP_REST_Request('GET', '/jio/v1/settings/schema/forum/general'); $admin_schema_request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest')); $admin_schema_response = rest_do_request($admin_schema_request); forum_check($admin_schema_response->get_status() === 200, 'admin forum settings route'); $member_login = 'forum_member_' . substr(str_replace('-', '', wp_generate_uuid4()), 0, 8); $member_id = wp_create_user($member_login, wp_generate_password(24), $member_login . '@example.test'); forum_check(!is_wp_error($member_id), 'member created'); $member = get_userdata((int) $member_id); wp_set_current_user((int) $admin->ID); $plate = wp_insert_post(['post_type' => \Jio\Forum\Core::PLATE, 'post_status' => 'publish', 'post_title' => '测试版块', 'post_author' => (int) $admin->ID], true); forum_check(!is_wp_error($plate) && $plate > 0, 'plate created'); $guest = forum_request('POST', 'posts', 0, ['title' => '测试帖子', 'content' => '正文内容', 'plate_id' => $plate]); forum_check($guest->get_status() === 401, 'guest cannot publish'); $created = forum_request('POST', 'posts', (int) $member->ID, ['title' => '测试帖子', 'content' => '正文内容', 'plate_id' => $plate, 'type' => 'question']); forum_check($created->get_status() === 200, 'member can submit'); $post_id = (int) ($created->get_data()['data']['id'] ?? 0); forum_check($post_id > 0 && get_post_status($post_id) === 'pending', 'post awaits review'); $blocked = forum_request('POST', "posts/{$post_id}/audit", (int) $member->ID); forum_check($blocked->get_status() === 403, 'member cannot audit'); $approved = forum_request('POST', "posts/{$post_id}/audit", (int) $admin->ID); forum_check($approved->get_status() === 200 && get_post_status($post_id) === 'publish', 'moderator approves post'); $follow = forum_request('POST', "plates/{$plate}/follow", (int) $member->ID); forum_check($follow->get_status() === 200 && \Jio\Forum\Access::follows((int) $member->ID, $plate), 'plate follow'); $reply = forum_request('POST', "posts/{$post_id}/replies", (int) $admin->ID, ['content' => '这是测试回复']); forum_check($reply->get_status() === 200, 'reply submitted'); $comment_id = (int) ($reply->get_data()['data']['id'] ?? 0); $adopt = forum_request('POST', "posts/{$post_id}/adopt", (int) $member->ID, ['comment_id' => $comment_id]); forum_check($adopt->get_status() === 200, 'answer adopted'); $hot_reply = forum_request('POST', "replies/{$comment_id}/hot", (int) $admin->ID); forum_check($hot_reply->get_status() === 200 && get_comment_meta($comment_id, '_jio_forum_hot', true), 'reply marked hot'); $reply_deleted = forum_request('POST', "replies/{$comment_id}/delete", (int) $member->ID); forum_check($reply_deleted->get_status() === 403, 'other member cannot delete reply'); $reply_deleted = forum_request('POST', "replies/{$comment_id}/delete", (int) $admin->ID); forum_check($reply_deleted->get_status() === 200 && get_comment($comment_id)->comment_approved === 'trash', 'moderator deletes reply'); forum_check(!get_post_meta($post_id, '_jio_forum_adopted_comment', true), 'deleted answer clears adoption'); $reply_restored = forum_request('POST', "replies/{$comment_id}/restore", (int) $admin->ID); forum_check($reply_restored->get_status() === 200 && get_comment($comment_id)->comment_approved === '1', 'moderator restores reply'); $readopt = forum_request('POST', "posts/{$post_id}/adopt", (int) $member->ID, ['comment_id' => $comment_id]); forum_check($readopt->get_status() === 200, 'restored answer may be adopted again'); $article_id = wp_insert_post(['post_type' => 'post', 'post_status' => 'publish', 'post_title' => '普通文章', 'post_author' => (int) $admin->ID]); $article_comment = wp_insert_comment(['comment_post_ID' => $article_id, 'comment_content' => '普通文章评论', 'user_id' => (int) $member->ID, 'comment_approved' => 1]); $wrong_comment_delete = forum_request('POST', "replies/{$article_comment}/delete", (int) $member->ID); forum_check($wrong_comment_delete->get_status() === 403 && get_comment($article_comment)->comment_approved === '1', 'forum route cannot delete article comment'); $term_created = forum_request('POST', 'terms', (int) $admin->ID, ['type' => 'topic', 'name' => '测试话题']); forum_check($term_created->get_status() === 200, 'forum topic created'); $term_id = (int) ($term_created->get_data()['data']['id'] ?? 0); $term_updated = forum_request('PATCH', "terms/{$term_id}", (int) $admin->ID, ['name' => '新测试话题']); forum_check($term_updated->get_status() === 200 && get_term($term_id)->name === '新测试话题', 'forum topic edited'); $term_deleted = forum_request('DELETE', "terms/{$term_id}", (int) $admin->ID); forum_check($term_deleted->get_status() === 200, 'forum topic deleted'); $vote = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '测试投票', 'content' => '请选择选项', 'plate_id' => $plate, 'type' => 'vote', 'vote_options' => "选项一\n选项二"]); forum_check($vote->get_status() === 200, 'vote post created'); $vote_id = (int) ($vote->get_data()['data']['id'] ?? 0); $cast = forum_request('POST', "posts/{$vote_id}/vote", (int) $member->ID, ['option' => 0]); forum_check($cast->get_status() === 200, 'vote cast'); $repeat = forum_request('POST', "posts/{$vote_id}/vote", (int) $member->ID, ['option' => 1]); forum_check($repeat->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'vote idempotent'); $multi = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '多选投票', 'content' => '选择多个选项', 'plate_id' => $plate, 'type' => 'vote', 'vote_type' => 'multiple', 'vote_title' => '你喜欢什么', 'vote_options' => "一\n二\n三"]); forum_check($multi->get_status() === 200, 'multiple vote created'); $multi_id = (int) ($multi->get_data()['data']['id'] ?? 0); $multi_cast = forum_request('POST', "posts/{$multi_id}/vote", (int) $member->ID, ['options' => [0, 2]]); $multi_counts = (array) get_post_meta($multi_id, '_jio_forum_vote_counts', true); forum_check($multi_cast->get_status() === 200 && (int) get_post_meta($multi_id, '_jio_forum_vote_total', true) === 1 && (int) ($multi_counts[0] ?? 0) === 1 && (int) ($multi_counts[2] ?? 0) === 1, 'multiple vote counts one voter'); $expired_vote = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '过期投票', 'content' => '已过期', 'plate_id' => $plate, 'type' => 'vote', 'vote_days' => 1, 'vote_options' => "甲\n乙"]); forum_check($expired_vote->get_status() === 200, 'limited vote created'); $expired_id = (int) ($expired_vote->get_data()['data']['id'] ?? 0); update_post_meta($expired_id, '_jio_forum_vote_deadline', current_time('timestamp') - 10); $late_cast = forum_request('POST', "posts/{$expired_id}/vote", (int) $member->ID, ['option' => 0]); forum_check($late_cast->get_status() === 409, 'expired vote rejects ballot'); $locked_vote = \Jio\Forum\Lock::acquire('vote', (int) $admin->ID, $vote_id); forum_check($locked_vote !== '', 'vote lock acquired'); $overlap_vote = forum_request('POST', "posts/{$vote_id}/vote", (int) $admin->ID, ['option' => 0]); forum_check($overlap_vote->get_status() === 409 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'overlapping vote rejected'); \Jio\Forum\Lock::release('vote', (int) $admin->ID, $vote_id, $locked_vote); $object_lock = \Jio\Forum\Lock::acquire('vote-object', 0, $vote_id); forum_check($object_lock !== '', 'vote object lock acquired'); $cross_user_vote = forum_request('POST', "posts/{$vote_id}/vote", (int) $admin->ID, ['option' => 0]); forum_check($cross_user_vote->get_status() === 409 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'cross-user overlapping vote rejected'); \Jio\Forum\Lock::release('vote-object', 0, $vote_id, $object_lock); $pinned = forum_request('POST', "posts/{$vote_id}/pin", (int) $admin->ID); forum_check($pinned->get_status() === 200, 'post pinned'); $ordered = new WP_Query(['post_type' => \Jio\Forum\Core::POST, 'post_status' => 'publish', 'posts_per_page' => 1, 'jio_forum_list' => 'latest']); forum_check((int) ($ordered->posts[0]->ID ?? 0) === $vote_id, 'pinned post leads list'); $favorite = forum_request('POST', "posts/{$vote_id}/favorite", (int) $member->ID); forum_check($favorite->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_favorite_count', true) === 1, 'favorite count updated'); $scored = forum_request('POST', "posts/{$vote_id}/score", (int) $member->ID, ['direction' => 'up']); forum_check($scored->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_score', true) === 1, 'score count updated'); $apply = forum_request('POST', "plates/{$plate}/apply", (int) $member->ID, ['reason' => '愿意维护版块']); forum_check($apply->get_status() === 200, 'moderator application submitted'); $process = forum_request('POST', "plates/{$plate}/applications/{$member->ID}", (int) $admin->ID, ['status' => 'approved']); forum_check($process->get_status() === 200 && \Jio\Forum\Access::is_moderator((int) $member->ID, $plate), 'moderator application approved'); $member_plate = forum_request('POST', 'plates', (int) $member->ID, ['title' => '用户创建版块']); forum_check($member_plate->get_status() === 200, 'member plate submitted'); $member_plate_id = (int) ($member_plate->get_data()['data']['id'] ?? 0); forum_check(get_post_status($member_plate_id) === 'pending', 'member plate awaits review'); $member_plate_approved = forum_request('POST', "plates/{$member_plate_id}/audit", (int) $admin->ID); forum_check($member_plate_approved->get_status() === 200, 'member plate approved'); $member_plate_post = forum_request('POST', 'posts', (int) $member->ID, ['title' => '用户版块帖子', 'content' => '删除版块后应该一起入回收站', 'plate_id' => $member_plate_id]); forum_check($member_plate_post->get_status() === 200, 'member plate post created'); $member_plate_post_id = (int) ($member_plate_post->get_data()['data']['id'] ?? 0); $deleted_plate = forum_request('POST', "plates/{$member_plate_id}/delete", (int) $member->ID, ['posts_action' => 'trash']); forum_check($deleted_plate->get_status() === 200 && get_post_status($member_plate_post_id) === 'trash', 'plate deletion cascades to posts'); $restored_plate = forum_request('POST', "plates/{$member_plate_id}/restore", (int) $member->ID); forum_check($restored_plate->get_status() === 200 && get_post_status($member_plate_post_id) === 'publish', 'plate restore recovers posts (' . get_post_status($member_plate_post_id) . ')'); wp_set_current_user((int) $admin->ID); $pay_test_plate = wp_insert_post(['post_type' => \Jio\Forum\Core::PLATE, 'post_status' => 'publish', 'post_title' => '付费测试版块', 'post_author' => (int) $admin->ID], true); forum_check(!is_wp_error($pay_test_plate) && $pay_test_plate > 0, 'payment test plate created'); $pending_post = forum_request('POST', 'posts', (int) $member->ID, ['title' => '待审核恢复测试', 'content' => '恢复后不能绕过审核', 'plate_id' => $pay_test_plate]); $pending_id = (int) ($pending_post->get_data()['data']['id'] ?? 0); forum_check($pending_post->get_status() === 200 && get_post_status($pending_id) === 'pending', 'pending post created'); $pending_deleted = forum_request('POST', "posts/{$pending_id}/delete", (int) $member->ID); forum_check($pending_deleted->get_status() === 200 && get_post_status($pending_id) === 'trash', 'pending post trashed'); $pending_restored = forum_request('POST', "posts/{$pending_id}/restore", (int) $member->ID); forum_check($pending_restored->get_status() === 200 && get_post_status($pending_id) === 'pending', 'pending status preserved on restore'); \Jio\Core\DBMigrator::maybe_migrate(); $ledger = \Jio\Ledger\LedgerService::get_instance(); $credit = $ledger->credit((int) $member->ID, \Jio\Ledger\AssetType::POINTS, 30, \Jio\Ledger\OperationKey::unique('forum-smoke-credit'), 'forum_test_credit'); forum_check(!is_wp_error($credit), 'points credited'); $before = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS); $locked = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '积分帖子', 'content' => '需要积分查看的正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'points', 'price' => '5']); forum_check($locked->get_status() === 200, 'paid post created'); $locked_id = (int) ($locked->get_data()['data']['id'] ?? 0); forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $locked_id), 'unpaid content locked'); $preview = forum_request('GET', "posts/{$locked_id}/checkout", (int) $member->ID); forum_check($preview->get_status() === 200 && (string) ($preview->get_data()['data']['price'] ?? '') === '5', 'points preview'); $checkout_key = wp_generate_uuid4(); $checkout = forum_request('POST', "posts/{$locked_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], $checkout_key); forum_check($checkout->get_status() === 200, 'points checkout'); forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $locked_id), 'paid content unlocked'); $after = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS); forum_check($after === $before - 5, 'points debited once'); $checkout_repeat = forum_request('POST', "posts/{$locked_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], $checkout_key); forum_check($checkout_repeat->get_status() === 200 && $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS) === $after, 'checkout idempotent'); $paid_plate = forum_request('POST', 'plates', (int) $admin->ID, ['title' => '积分版块', 'follow_mode' => 'points', 'follow_price' => '3', 'view_limit' => 'points']); forum_check($paid_plate->get_status() === 200, 'paid plate created'); $paid_plate_id = (int) ($paid_plate->get_data()['data']['id'] ?? 0); $plate_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '版块内帖子', 'content' => '付费版块内的内容', 'plate_id' => $paid_plate_id]); forum_check($plate_post->get_status() === 200, 'paid plate post created'); $plate_post_id = (int) ($plate_post->get_data()['data']['id'] ?? 0); forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'paid plate content locked'); $nested_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '双层解锁帖子', 'content' => '双层付费正文', 'plate_id' => $paid_plate_id, 'view_limit' => 'points', 'price' => '1']); forum_check($nested_post->get_status() === 200, 'nested paid post created'); $nested_post_id = (int) ($nested_post->get_data()['data']['id'] ?? 0); $premature_checkout = forum_request('POST', "posts/{$nested_post_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4()); forum_check($premature_checkout->get_status() === 402, 'post checkout requires parent plate access'); $denied_follow = forum_request('POST', "plates/{$paid_plate_id}/follow", (int) $member->ID); forum_check($denied_follow->get_status() === 402, 'paid plate follow requires payment'); $follow_paid = forum_request('POST', "plates/{$paid_plate_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4()); forum_check($follow_paid->get_status() === 200 && \Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'paid plate entitlement'); forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'paid plate content unlocked'); $unfollow = forum_request('POST', "plates/{$paid_plate_id}/follow", (int) $member->ID); forum_check($unfollow->get_status() === 200 && !\Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'unfollow revokes entitlement'); forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'unfollow relocks content'); $points_before_refollow = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS); $repurchase = forum_request('POST', "plates/{$paid_plate_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4()); forum_check($repurchase->get_status() === 200 && \Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'paid follow can be purchased again'); forum_check($ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS) === $points_before_refollow - 3, 'second follow charged once'); $cash_credit = $ledger->credit((int) $member->ID, \Jio\Ledger\AssetType::BALANCE, 500, \Jio\Ledger\OperationKey::unique('forum-smoke-balance'), 'forum_test_credit'); forum_check(!is_wp_error($cash_credit), 'balance credited'); $cash_before = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::BALANCE); $cash_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '余额帖子', 'content' => '需要余额查看的正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '2.00']); forum_check($cash_post->get_status() === 200, 'cash post created'); $cash_post_id = (int) ($cash_post->get_data()['data']['id'] ?? 0); $cash_paid = forum_request('POST', "posts/{$cash_post_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4()); forum_check($cash_paid->get_status() === 200, 'balance checkout'); forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $cash_post_id), 'balance content unlocked'); forum_check($ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::BALANCE) === $cash_before - 200, 'balance debited once'); $lock = \Jio\Forum\Lock::acquire('checkout-forum_post', (int) $member->ID, $vote_id); forum_check($lock !== '' && \Jio\Forum\Lock::acquire('checkout-forum_post', (int) $member->ID, $vote_id) === '', 'checkout lock excludes overlap'); \Jio\Forum\Lock::release('checkout-forum_post', (int) $member->ID, $vote_id, $lock); $expensive = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '余额不足测试', 'content' => '订单复用正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '1000.00']); $expensive_id = (int) ($expensive->get_data()['data']['id'] ?? 0); forum_check($expensive->get_status() === 200, 'expensive post created'); $first_attempt = forum_request('POST', "posts/{$expensive_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4()); forum_check($first_attempt->get_status() === 402, 'insufficient balance rejected'); $second_attempt = forum_request('POST', "posts/{$expensive_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4()); forum_check($second_attempt->get_status() === 402, 'retry still insufficient (' . $second_attempt->get_status() . ': ' . wp_json_encode($second_attempt->get_data()) . ')'); $pending_orders = array_filter(\Jio\Payment\OrderRepository::get_instance()->get_user_orders((int) $member->ID, ['product_type' => 'forum_post', 'status' => 'pending', 'limit' => 100]), static fn(array $order): bool => (int) $order['product_id'] === $expensive_id); forum_check(count($pending_orders) === 1, 'checkout retry reuses pending order'); $too_large = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '无效价格测试', 'content' => '异常金额不应入库', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '9999999999999999999999999999999.00']); forum_check($too_large->get_status() === 400, 'oversized price rejected before save'); update_post_meta($expensive_id, '_jio_forum_price', '9999999999999999999999999999999.00'); $invalid_preview = forum_request('GET', "posts/{$expensive_id}/checkout", (int) $member->ID); forum_check($invalid_preview->get_status() === 400, 'invalid stored price cannot become free'); update_post_meta($expensive_id, '_jio_forum_price', '1000.00'); $settings = get_option('jio_settings', []); $settings['jio_forum_enabled'] = false; update_option('jio_settings', $settings); \Jio\Settings\Settings::flush_settings_cache(); $disabled = forum_request('POST', 'posts', (int) $member->ID, ['title' => '已关闭论坛', 'content' => '不能发帖', 'plate_id' => $plate]); forum_check($disabled->get_status() === 404, 'disabled forum rejects writes'); $settings['jio_forum_enabled'] = true; update_option('jio_settings', $settings); \Jio\Settings\Settings::flush_settings_cache(); echo "forum smoke passed\n";