feat(forum): add complete forum workflows

This commit is contained in:
kate522 committed 2026-11-11 00:09:43 +08:00
1 parent a254cbd3e9
commit ee65fc7fd2
39 files changed
+3425 -4

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { spawn } = require('node:child_process')
const edge = process.env.JIO_FORUM_BROWSER || 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'
const base = process.env.JIO_FORUM_BASE_URL || 'http://localhost:9081'
const lockedUrl = process.env.JIO_FORUM_LOCKED_URL || ''
const multiVoteUrl = process.env.JIO_FORUM_MULTI_VOTE_URL || ''
const browserPassword = process.env.JIO_FORUM_TEST_PASSWORD || ''
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'jio-forum-browser-'))
const browser = spawn(edge, [
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank',
], { stdio: 'ignore', windowsHide: true })
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const until = async (fn, timeout = 15000) => {
const end = Date.now() + timeout
while (Date.now() < end) {
const result = await fn().catch(() => null)
if (result) return result
await sleep(100)
}
throw new Error('browser timeout')
}
async function run() {
const portFile = path.join(profile, 'DevToolsActivePort')
const port = await until(async () => fs.existsSync(portFile) && Number(fs.readFileSync(portFile, 'utf8').split('\n')[0]))
const target = await until(async () => {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
return list.find((item) => item.type === 'page')?.webSocketDebuggerUrl
})
const socket = new WebSocket(target)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let nextId = 0
const pending = new Map()
const problems = []
socket.addEventListener('message', ({ data }) => {
const item = JSON.parse(data)
if (item.id && pending.has(item.id)) {
const { resolve, reject } = pending.get(item.id)
pending.delete(item.id)
item.error ? reject(new Error(item.error.message)) : resolve(item.result)
}
if (item.method === 'Runtime.exceptionThrown') problems.push(item.params.exceptionDetails.text)
if (item.method === 'Runtime.consoleAPICalled' && item.params.type === 'error') {
problems.push(item.params.args.map((arg) => arg.value || arg.description || '').join(' '))
}
if (item.method === 'Network.loadingFailed' && !item.params.canceled) problems.push(item.params.errorText)
})
const call = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++nextId
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
await Promise.all([call('Page.enable'), call('Runtime.enable'), call('Network.enable')])
const check = async (width, height, url, label) => {
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 })
await call('Page.navigate', { url: base + url })
await until(async () => {
const result = await call('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true })
return result.result.value === 'complete'
})
await sleep(800)
const result = await call('Runtime.evaluate', {
expression: `({forum:!!document.getElementById('forum'),posts:document.querySelectorAll('.jio-forum__post').length,locked:document.body.textContent.includes('内容已隐藏'),leaked:document.documentElement.innerHTML.includes('需要积分查看的正文'),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,width:innerWidth})`,
returnByValue: true,
})
const state = result.result.value
if (!state.forum || state.overflow || (url === '/forums/' && state.posts < 1) || (url === lockedUrl && (!state.locked || state.leaked))) {
throw new Error(`${label}: ${JSON.stringify(state)}`)
}
console.log(`${label}: ${JSON.stringify(state)}`)
}
try {
await check(1440, 900, '/forums/', 'desktop home')
await check(390, 844, '/forums/', 'mobile home')
if (lockedUrl) {
await check(390, 844, lockedUrl, 'mobile locked post')
await call('Runtime.evaluate', { expression: `document.body.classList.remove('dark-theme');document.documentElement.classList.remove('dark')` })
await sleep(650)
const before = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true })
await call('Runtime.evaluate', { expression: `document.body.classList.add('dark-theme');document.documentElement.classList.add('dark')` })
await sleep(650)
const after = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true })
if (before.result.value === after.result.value) throw new Error(`forum dark mode does not change locked surface: ${before.result.value}`)
console.log(`dark mode: ${JSON.stringify({ light: before.result.value, dark: after.result.value })}`)
}
if (browserPassword) {
await call('Page.navigate', { url: base + '/sign/?tab=signin' })
await until(async () => {
const state = await call('Runtime.evaluate', { expression: 'document.readyState === "complete" && !!window.jioData?.restNonce', returnByValue: true })
return state.result.value
})
const login = await call('Runtime.evaluate', {
expression: `(async () => { const response=await fetch('/wp-json/jio/v1/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-WP-Nonce':window.jioData.restNonce},body:JSON.stringify({target:'forum_admin',password:${JSON.stringify(browserPassword)},redirect_to:${JSON.stringify(base + '/wp-admin/')}})});return {status:response.status,body:await response.json()}})()`,
awaitPromise: true,
returnByValue: true,
})
if (login.result.value?.status !== 200) throw new Error(`login failed: ${JSON.stringify(login.result.value)}`)
for (const [width, height, label] of [[1440, 900, 'desktop admin'], [390, 844, 'mobile admin']]) {
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 })
await call('Page.navigate', { url: base + '/wp-admin/admin.php?page=jio-settings#/forum/general' })
const state = await until(async () => {
const result = await call('Runtime.evaluate', { expression: `({app:!!document.querySelector('#jio-admin-app'),ready:document.querySelector('#jio-admin-app')?.textContent.includes('启用论坛'),width:innerWidth,overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth})`, returnByValue: true })
return result.result.value?.ready && result.result.value
}, 20000)
if (!state.app || state.overflow) throw new Error(`${label}: ${JSON.stringify(state)}`)
console.log(`${label}: ${JSON.stringify(state)}`)
}
if (multiVoteUrl) {
await call('Page.navigate', { url: base + multiVoteUrl })
await until(async () => {
const result = await call('Runtime.evaluate', { expression: `document.querySelectorAll('.jio-forum__vote input[type=checkbox]').length >= 2`, returnByValue: true })
return result.result.value
})
await call('Runtime.evaluate', { expression: `(() => {const form=document.querySelector('[data-forum-form=vote]');const boxes=form.querySelectorAll('input[type=checkbox]');boxes[0].checked=true;boxes[1].checked=true;form.requestSubmit();return true})()`, returnByValue: true })
const voted = await until(async () => {
const result = await call('Runtime.evaluate', { expression: `({done:document.querySelector('.jio-forum__vote')?.textContent.includes('已投票'),error:document.querySelector('.jio-forum__vote .jio-forum__message')?.textContent||''})`, returnByValue: true })
return result.result.value?.done && result.result.value
}, 12000)
if (voted.error) throw new Error(`multiple vote browser error: ${voted.error}`)
console.log(`multiple vote browser: ${JSON.stringify(voted)}`)
}
await call('Page.navigate', { url: base + '/posts-edit/' })
const editorState = await until(async () => {
const result = await call('Runtime.evaluate', { expression: `({form:!!document.querySelector('[data-forum-form=post]'),editor:!!document.querySelector('#jio_forum_content'),upload:!!document.querySelector('[data-forum-upload=image]')})`, returnByValue: true })
return result.result.value?.form && result.result.value
})
if (!editorState.editor || !editorState.upload) throw new Error(`forum editor unavailable: ${JSON.stringify(editorState)}`)
console.log(`forum editor: ${JSON.stringify(editorState)}`)
const editorLayout = await call('Runtime.evaluate', { expression: `({overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true })
if (editorLayout.result.value?.overflow || !editorLayout.result.value?.css) throw new Error(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`)
console.log(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`)
const upload = await call('Runtime.evaluate', {
expression: `(async()=>{const source=await fetch('/wp-content/themes/jio/assets/img/topic.jpg');const blob=await source.blob();const body=new FormData();body.append('file',new File([blob],'topic.jpg',{type:'image/jpeg'}));const response=await fetch('/wp-json/jio/v1/forum/upload/image',{method:'POST',credentials:'same-origin',headers:{'X-WP-Nonce':window.jioForum.nonce},body});const payload=await response.json();return {status:response.status,id:payload.data?.id,url:payload.data?.url,message:payload.message}})()`,
awaitPromise: true,
returnByValue: true,
})
if (upload.result.value?.status !== 200 || !upload.result.value?.url) throw new Error(`forum upload failed: ${JSON.stringify(upload.result.value)}`)
console.log(`forum upload: ${JSON.stringify({ status: upload.result.value.status, id: upload.result.value.id })}`)
const submitted = await call('Runtime.evaluate', {
expression: `(() => { const form=document.querySelector('[data-forum-form=post]');form.querySelector('[name=title]').value='浏览器表单测试';const plate=form.querySelector('[name=plate_id]');plate.value=plate.querySelector('option[value]:not([value=""])')?.value||'';const editor=window.tinymce?.get('jio_forum_content');if(editor&&!editor.isHidden())editor.setContent('<p>浏览器表单正文</p>');else form.querySelector('[name=content]').value='浏览器表单正文';form.requestSubmit();return {plate:plate.value} })()`,
returnByValue: true,
})
if (!submitted.result.value?.plate) throw new Error('no publishable plate in editor')
const saved = await until(async () => {
const result = await call('Runtime.evaluate', { expression: `({url:location.pathname,error:document.querySelector('.jio-forum__form .jio-forum__message')?.textContent||''})`, returnByValue: true })
return result.result.value?.url.startsWith('/forum-post/') && result.result.value
}, 12000)
console.log(`browser form submit: ${JSON.stringify(saved)}`)
await call('Page.navigate', { url: base + '/user/forum/' })
const center = await until(async () => {
const result = await call('Runtime.evaluate', { expression: `({panel:!!document.querySelector('#panel-forum'),posts:document.querySelector('#panel-forum')?.textContent.includes('帖子'),plates:document.querySelector('#panel-forum')?.textContent.includes('版块'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true })
return result.result.value?.panel && result.result.value
})
if (!center.posts || !center.plates || !center.css) throw new Error(`forum user panel unavailable: ${JSON.stringify(center)}`)
console.log(`user center: ${JSON.stringify(center)}`)
await call('Page.navigate', { url: base + '/author/forum_admin/' })
const publicHome = await until(async () => {
const result = await call('Runtime.evaluate', { expression: `({forum:!!document.querySelector('#panel-forum'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true })
return result.result.value?.forum && result.result.value
})
if (!publicHome.css) throw new Error(`public user home CSS missing: ${JSON.stringify(publicHome)}`)
console.log(`public user home: ${JSON.stringify(publicHome)}`)
}
if (problems.length) throw new Error(`browser errors: ${problems.join('; ')}`)
} finally {
call('Browser.close').catch(() => {})
await sleep(150)
socket.close()
}
}
const cleanup = async () => {
const target = path.resolve(profile)
const tempRoot = path.resolve(os.tmpdir()) + path.sep
if (target.startsWith(tempRoot) && path.basename(target).startsWith('jio-forum-browser-')) {
for (let attempt = 0; attempt < 20; attempt++) {
try {
fs.rmSync(target, { recursive: true, force: true })
return
} catch (error) {
if (error.code !== 'EBUSY' && error.code !== 'EPERM') throw error
await sleep(250)
}
}
throw new Error('browser profile is still in use')
}
}
run().then(async () => {
browser.kill()
await cleanup()
}).catch(async (error) => {
console.error(error)
browser.kill()
await cleanup().catch((cleanupError) => console.error(cleanupError))
process.exitCode = 1
})
+40
View File
@@ -0,0 +1,40 @@
name: jio-forum-check
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: jioforum
MYSQL_USER: jioforum
MYSQL_PASSWORD: jioforum
MYSQL_RANDOM_ROOT_PASSWORD: "1"
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u jioforum -pjioforum"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:latest
depends_on:
db:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: jioforum
WORDPRESS_DB_PASSWORD: jioforum
WORDPRESS_DB_NAME: jioforum
ports:
- "9081:80"
volumes:
- wp_data:/var/www/html
- type: bind
source: ../..
target: /var/www/html/wp-content/themes/jio
read_only: true
volumes:
db_data:
wp_data:
+47
View File
@@ -0,0 +1,47 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '../..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
test('every forum page mounts the dedicated runtime and the public write routes require a nonce', () => {
const core = read('src/Forum/Core.php')
const rest = read('src/Forum/Rest.php')
const runtime = read('assets/js/forum-runtime.js')
for (const template of ['page-forums.php', 'page-forum-list.php', 'page-forum-post.php', 'page-forum-edit.php']) {
assert.ok(fs.existsSync(path.join(root, 'templates/pages', template)))
}
assert.match(core, /wp_enqueue_script\('jio-forum'/)
assert.match(core, /wp_create_nonce\('wp_rest'\)/)
assert.match(rest, /'permission_callback' => \$auth/)
assert.match(rest, /wp_verify_nonce\(\$nonce, 'wp_rest'\)/)
assert.match(runtime, /'X-WP-Nonce'/)
})
test('paid content has server-side presentation guards beyond its page body', () => {
const access = read('src/Forum/Access.php')
const integration = read('src/Forum/Integration.php')
const view = read('src/Forum/View.php')
assert.match(access, /function can_view\(/)
assert.match(integration, /add_filter\('the_content'/)
assert.match(integration, /add_filter\('get_the_excerpt'/)
assert.match(integration, /add_filter\('jio_seo_head_payload'/)
assert.match(view, /Access::can_view\(get_current_user_id\(\), \$post->ID\)/)
})
test('forum management, account, search and responsive styles remain connected', () => {
const admin = read('assets/admin/src/constants/sections/forum.js')
const schema = read('src/Admin/Schema/SchemaForum.php')
const search = read('search.php')
const css = read('assets/css/routes/forum.css')
for (const section of ['general', 'home', 'publishing', 'access', 'replies', 'payment', 'urls']) {
assert.match(admin, new RegExp(`'${section}'`))
assert.match(schema, new RegExp(`'${section}'`))
}
assert.match(search, /search_type === 'forum'/)
assert.match(css, /@media\(max-width:800px\)/)
assert.match(css, /@media\(max-width:480px\)/)
assert.match(read('src/Forum/View.php'), /function user_panel\(/)
})
+275
View File
@@ -0,0 +1,275 @@
<?php
$_SERVER['HTTP_HOST'] = 'localhost:9081';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
if (($argv[1] ?? '') === 'install') {
define('WP_INSTALLING', true);
}
require '/var/www/html/wp-load.php';
if (($argv[1] ?? '') === 'set-browser-password') {
$password = (string) getenv('JIO_FORUM_TEST_PASSWORD');
$admin = get_user_by('login', 'forum_admin');
if (strlen($password) < 16 || !$admin instanceof WP_User) {
throw new RuntimeException('browser login configuration missing');
}
wp_set_password($password, (int) $admin->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";