feat(admin): review reports and ban appeals in dialogs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-30 23:06:04 +08:00
1 parent 32ac2112b0
commit 59ddf677b6
13 files changed
+2122 -704

No files matched your search

@@ -0,0 +1,177 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
let fixture;
function phpFixture() {
if (fixture) return fixture;
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'ban-admin-harness.php')], {
cwd: root,
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
fixture = JSON.parse(result.stdout);
return fixture;
}
function inputValue(html, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = html.match(new RegExp(`<input[^>]*name="${escaped}"[^>]*value="([^"]*)"`));
return match ? match[1] : null;
}
test('ban queue merges all records, reports status counts, and clamps pages above 100 rows', () => {
const result = phpFixture();
assert.equal(result.queue.total, 132);
assert.equal(result.queue.page, 2);
assert.equal(result.queue.items, 20);
assert.deepEqual(result.queue.counts, { all: 132, pending: 128, resolved: 2, dismissed: 2 });
assert.equal(result.queue.overlarge_page, 7);
assert.equal(result.queue.overlarge_items, 12);
assert.deepEqual(result.filters, {
type: 'report', status: '', s: 'needle', send_user: 7, orderby: 'updated', order: 'ASC',
});
});
test('real PHP rendering keeps review forms in templates and completed records read-only', () => {
const { html, filtered_html: filteredHtml } = phpFixture();
assert.equal((html.match(/<dialog id="aksrt-ban-dialog"/g) || []).length, 1);
assert.equal((html.match(/<template id="aksrt-ban-detail-/g) || []).length, 20);
assert.match(html, /<template id="aksrt-ban-detail-report-1">\s*<form method="post"/);
assert.match(html, /<template id="aksrt-ban-detail-appeal-501">\s*<form method="post"/);
const resolved = html.match(/<template id="aksrt-ban-detail-report-2">([\s\S]*?)<\/template>/)?.[1] || '';
assert.notEqual(resolved, '');
assert.doesNotMatch(resolved, /<form|data-aksrt-ban-submit/);
assert.match(resolved, /该记录已处理/);
for (const [name, value] of Object.entries({
action: 'aksrt_users_update_report', report_id: '9001', filter_type: 'report', filter_status: 'pending',
s: 'needle', send_user: '7', orderby: 'updated', order: 'ASC', paged: '1',
})) {
assert.equal(inputValue(filteredHtml, name), value, `review form should carry ${name}`);
}
assert.match(filteredHtml, /name="_wpnonce" value="aksrt_users_update_report_9001-nonce"/);
assert.match(html, /name="user_id" value="501"/);
assert.match(html, /name="_wpnonce" value="aksrt_users_process_appeal_501-nonce"/);
assert.match(html, /<input type="radio"[^>]*required>/);
assert.doesNotMatch(html, /<input type="radio"[^>]*checked/);
});
test('PHP templates escape all appeal data and report evidence while preserving safe links', () => {
const { html } = phpFixture();
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/);
assert.match(html, /Name &lt;img src=x onerror=alert\(4\)&gt;/);
assert.match(html, /Applicant &lt;b&gt;value&lt;\/b&gt;/);
assert.match(html, /reason &lt;svg onload=alert\(1\)&gt;/);
assert.match(html, /href="https:\/\/evidence\.example\.test\/proof\.jpg" target="_blank" rel="noopener noreferrer"/);
assert.match(html, /href="https:\/\/appeal\.example\.test\/file\.pdf" target="_blank" rel="noopener noreferrer"/);
assert.match(html, /<dd>https:\/\/appeal\.example\.test\/document\.pdf<\/dd>/);
assert.doesNotMatch(html, /href="javascript:/i);
assert.doesNotMatch(html, /<img src=x onerror=/i);
assert.doesNotMatch(html, /<script>alert\(/i);
});
test('dedicated ban assets enqueue only on the matching native page', () => {
const { assets } = phpFixture();
assert.equal(assets.styles.length, 1);
assert.deepEqual(assets.styles[0].slice(0, 3), [
'aksrt-admin-user-ban', 'https://theme.example.test/assets/css/admin/user-ban.css', ['aksrt-admin-native-ui'],
]);
assert.equal(assets.scripts.length, 1);
assert.deepEqual(assets.scripts[0].slice(0, 3), [
'aksrt-admin-user-ban', 'https://theme.example.test/assets/js/admin-user-ban.js', [],
]);
assert.equal(assets.scripts[0][4], true);
});
test('PHP actions reject blank notes and stale versions, and preserve filter roundtrips', () => {
const { actions } = phpFixture();
assert.equal(actions.blank_report.message, 'report_failed');
assert.match(actions.blank_report.args.aksrt_ban_error, /必须填写处理说明/);
assert.equal(actions.stale_report.message, 'report_failed');
assert.match(actions.stale_report.args.aksrt_ban_error, /已变化或已处理/);
assert.equal(actions.valid_report.message, 'report_updated');
assert.deepEqual({
type: actions.valid_report.args.type,
status: actions.valid_report.args.status,
s: actions.valid_report.args.s,
send_user: actions.valid_report.args.send_user,
orderby: actions.valid_report.args.orderby,
order: actions.valid_report.args.order,
paged: actions.valid_report.args.paged,
}, { type: 'report', status: 'pending', s: 'needle', send_user: 7, orderby: 'updated', order: 'ASC', paged: 2 });
assert.equal(actions.blank_appeal.message, 'appeal_failed');
assert.match(actions.blank_appeal.args.aksrt_ban_error, /必须填写拒绝原因/);
assert.equal(actions.valid_appeal.message, 'appeal_rejected');
assert.equal(actions.appeal_status, 'rejected');
assert.equal(actions.appeal_payload.admin_note, 'Missing evidence');
assert.equal(actions.approve_appeal.message, 'appeal_approved');
assert.equal(actions.approved_status, 'approved');
assert.equal(actions.restrictions_lifted, 1);
assert.deepEqual(actions.report_calls, [[9001, 'resolved', 'Reviewed']]);
assert.equal(actions.messages.length, 2);
});
test('ban admin contract keeps the single-dialog flow and separate native operations', () => {
const users = read('src/Admin/Native/UsersMenus.php');
const page = read('src/Admin/Native/UsersMenusBan.php');
const query = read('src/Admin/Native/UsersMenusBanQuery.php');
const actions = read('src/Admin/Native/UsersMenusBanActions.php');
const dialogs = read('src/Admin/Native/UsersMenusBanDialogs.php');
const script = read('assets/js/admin-user-ban.js');
const css = read('assets/css/admin/user-ban.css');
assert.match(users, /use UsersMenusBan;/);
assert.match(users, /admin_post_aksrt_users_update_report/);
assert.match(users, /admin_post_aksrt_users_process_appeal/);
assert.doesNotMatch(users, /use UsersMenusTickets;/);
assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTickets.php')));
assert.match(page, /if \(\$hook !== 'users_page_aksrt_user_ban'\) return/);
assert.match(page, /<dialog id="aksrt-ban-dialog"/);
assert.match(dialogs, /<template id="aksrt-ban-detail-/);
assert.doesNotMatch(page, /update_user_meta|update_status|do_action\(/);
assert.doesNotMatch(page, /data-aksrt-ticket-toggle|data-aksrt-ticket-cancel|data-aksrt-ban-toggle/);
assert.match(query, /BAN_PER_PAGE = 20/);
assert.match(query, /number'\s*=>\s*-1/);
assert.match(query, /array_slice\(\$filtered, \(\$page - 1\) \* self::BAN_PER_PAGE, self::BAN_PER_PAGE\)/);
assert.match(query, /'counts'\s*=>\s*\$counts/);
assert.match(query, /raw_status/);
assert.match(dialogs, /'action'\s*=>\s*\$action/);
assert.match(dialogs, /record_version/);
assert.match(dialogs, /wp_nonce_field\(\$action \. '_' \. \(int\) \$row\['id'\]\)/);
for (const name of ['filter_type', 'filter_status', 's', 'send_user', 'orderby', 'order', 'paged']) {
assert.ok(dialogs.includes(`'${name}'`), `missing hidden filter field ${name}`);
}
assert.match(dialogs, /esc_url\(\$part, \['http', 'https'\]\)/);
assert.match(dialogs, /esc_html\(\$value !== '' \? \$value : '—'\)/);
assert.match(dialogs, /target="_blank" rel="noopener noreferrer"/);
assert.match(actions, /check_admin_referer\('aksrt_users_update_report_'/);
assert.match(actions, /check_admin_referer\('aksrt_users_process_appeal_'/);
assert.match(actions, /hash_equals\(\$this->ban_record_version\(\$row\), \$submitted\)/);
assert.match(actions, /\$this->ban_request_is_current\(\$row, 'pending'\)/);
assert.match(actions, /\$decision === 'reject' && \$note === ''/);
assert.match(actions, /\$new_status === 'dismissed' && \$note === ''/);
assert.match(script, /dialog\.showModal\(\)/);
assert.match(script, /dialog\.replaceChildren\(\)/);
assert.match(script, /previousOpener\.focus\(\)/);
assert.match(script, /window\.addEventListener\('pageshow'/);
assert.match(css, /html\.aksrt-ban-modal-open \{ overflow: hidden/);
});
+383
View File
@@ -0,0 +1,383 @@
<?php
/**
* Exercise the native ban queue, detail templates, and post handlers with WordPress fixtures.
*
* @package AKSRT
*/
namespace AKSRT\Core {
final class DBMigrator {
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
}
}
namespace AKSRT\User {
final class Restriction {
public const TYPE_WARN = 'warn';
public const TYPE_MUTE = 'mute';
public const TYPE_SUSPEND = 'suspend';
public const TYPE_BAN = 'ban';
public const SCOPE_COMMENT = 'comment';
public static int $lifted = 0;
public static function get_instance(): self { return new self(); }
public function lift_user_restriction(int $user_id): int { self::$lifted++; return $user_id > 0 ? 1 : 0; }
public function get_user_restrictions(int $user_id, bool $active = false): array { unset($user_id, $active); return []; }
}
final class BanAppeal {
public const META_STATUS = '_aksrt_ban_appeal_status';
public const META_PAYLOAD = '_aksrt_ban_appeal_payload';
public const META_HISTORY = '_aksrt_ban_appeal_history';
public static function get_status(int $user_id): string {
return (string) ($GLOBALS['aksrt_test_user_meta'][$user_id][self::META_STATUS] ?? '');
}
public static function get_payload(int $user_id): array {
$value = $GLOBALS['aksrt_test_user_meta'][$user_id][self::META_PAYLOAD] ?? [];
return is_array($value) ? $value : [];
}
}
}
namespace AKSRT\Messages {
final class ReportCenter {
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
public function update_status(int $id, string $status, string $note): bool {
$GLOBALS['aksrt_test_reports'][$id]['status'] = $status;
$GLOBALS['aksrt_test_reports'][$id]['updated_at'] = '2026-10-30 13:00:00';
$GLOBALS['aksrt_test_report_actions'][] = [$id, $status, $note];
return true;
}
}
final class Message {
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
public function send(int $user_id, string $type, string $message, array $args = []): void {
$GLOBALS['aksrt_test_messages'][] = [$user_id, $type, $message, $args];
}
}
}
namespace AKSRT\Admin\Native {
final class AdminListPagination {
public static function render(string $slug, int $total, int $page, int $per_page, array $filters = [], string $parent = ''): void {
$GLOBALS['aksrt_test_pagination'][] = [$slug, $total, $page, $per_page, $filters, $parent];
echo '<nav data-test-pagination="' . esc_attr((string) $total) . ':' . esc_attr((string) $page) . '"></nav>';
}
}
}
namespace {
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
define('AKSRT_DIR', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
define('AKSRT_URI', 'https://theme.example.test');
define('ARRAY_A', 'ARRAY_A');
class WP_User {
public function __construct(public int $ID, public string $display_name) {}
}
final class TestRedirect extends \RuntimeException {
public function __construct(public string $page, public string $redirectMessage, public array $args) {
parent::__construct($redirectMessage);
}
}
final class TestWpdb {
public string $prefix = 'wp_';
public string $users = 'wp_users';
public string $last_error = '';
private array $args = [];
public function prepare(string $query, $args = []): string {
$this->args = is_array($args) ? $args : [$args];
return $query;
}
public function esc_like(string $text): string { return addcslashes($text, '_%\\'); }
public function get_results(string $query, $output = null): array {
if (str_contains($query, 'notes_json')) {
return array_map(static fn (int $id): array => [
'id' => $id,
'notes_json' => $GLOBALS['aksrt_test_reports'][$id]['notes_json'] ?? '[]',
], $this->args);
}
if (str_contains($query, 'aksrt_user_restriction')) {
return $GLOBALS['aksrt_test_restrictions'];
}
$reports = array_values($GLOBALS['aksrt_test_reports']);
$search = '';
$send_user = 0;
foreach ($this->args as $arg) {
if (is_string($arg) && str_starts_with($arg, '%') && str_ends_with($arg, '%')) $search = trim($arg, '%');
if (is_int($arg) && $arg > 0) $send_user = $arg;
}
return array_values(array_filter($reports, static function (array $row) use ($search, $send_user): bool {
if ($send_user > 0 && (int) $row['reporter_id'] !== $send_user) return false;
if ($search !== '' && stripos(implode(' ', [$row['reason'], $row['category'], $row['target_type'], $row['target_id']]), $search) === false) return false;
return true;
}));
}
public function get_row(string $query, $output = null) {
$id = (int) ($this->args[0] ?? 0);
return $GLOBALS['aksrt_test_reports'][$id] ?? null;
}
}
$GLOBALS['wpdb'] = new TestWpdb();
$GLOBALS['aksrt_test_reports'] = [];
$GLOBALS['aksrt_test_user_meta'] = [];
$GLOBALS['aksrt_test_restrictions'] = [[
'user_id' => 7,
'type' => 'ban',
'scope' => 'site',
'reason' => 'fixture restriction',
'created_at' => '2026-10-01 08:00:00',
'expires_at' => null,
]];
$GLOBALS['aksrt_test_assets'] = ['styles' => [], 'scripts' => []];
$GLOBALS['aksrt_test_pagination'] = [];
$GLOBALS['aksrt_test_report_actions'] = [];
$GLOBALS['aksrt_test_messages'] = [];
$GLOBALS['aksrt_test_nonce_checks'] = [];
$GLOBALS['aksrt_test_capability'] = true;
function __($text, $domain = null): string { return (string) $text; }
function esc_html__($text, $domain = null): string { return esc_html((string) $text); }
function esc_attr__($text, $domain = null): string { return esc_attr((string) $text); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html((string) $value); }
function esc_url($url, array $protocols = ['http', 'https']): string {
$url = trim((string) $url);
if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $url, $match) && !in_array(strtolower($match[1]), $protocols, true)) return '';
return esc_attr($url);
}
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_text_field($value): string { return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? ''); }
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
function wp_unslash($value) { return $value; }
function wp_slash($value) { return $value; }
function absint($value): int { return abs((int) $value); }
function admin_url(string $path = ''): string { return 'https://example.test/wp-admin/' . ltrim($path, '/'); }
function add_query_arg($key, $value = null, $url = ''): string {
if (is_array($key)) {
$args = $key;
$base = (string) $value;
} else {
$args = [(string) $key => $value];
$base = (string) $url;
}
$parts = parse_url($base);
$query = [];
if (!empty($parts['query'])) parse_str($parts['query'], $query);
$query = array_merge($query, $args);
$prefix = isset($parts['scheme']) ? $parts['scheme'] . '://' . ($parts['host'] ?? '') : '';
$prefix .= $parts['path'] ?? '';
return $prefix . '?' . http_build_query($query);
}
function current_user_can(string $capability): bool { return $capability === 'manage_options' && $GLOBALS['aksrt_test_capability']; }
function wp_die($message): void { throw new \RuntimeException((string) $message); }
function wp_nonce_field(string $action): void { echo '<input type="hidden" name="_wpnonce" value="' . esc_attr($action . '-nonce') . '">'; }
function check_admin_referer(string $action): void {
$GLOBALS['aksrt_test_nonce_checks'][] = $action;
if (($_POST['_wpnonce'] ?? '') !== $action . '-nonce') throw new \RuntimeException('nonce mismatch');
}
function wp_enqueue_style(string $handle, string $src, array $deps = [], string $version = ''): void {
$GLOBALS['aksrt_test_assets']['styles'][] = [$handle, $src, $deps, $version];
}
function wp_enqueue_script(string $handle, string $src, array $deps = [], string $version = '', bool $footer = false): void {
$GLOBALS['aksrt_test_assets']['scripts'][] = [$handle, $src, $deps, $version, $footer];
}
function get_userdata(int $user_id) { return $user_id > 0 ? new WP_User($user_id, $user_id === 7 ? 'Report <script>alert(1)</script>' : 'User ' . $user_id) : false; }
function get_current_user_id(): int { return 999; }
function get_user_meta(int $user_id, string $key, bool $single = false) { return $GLOBALS['aksrt_test_user_meta'][$user_id][$key] ?? ''; }
function update_user_meta(int $user_id, string $key, $value): bool { $GLOBALS['aksrt_test_user_meta'][$user_id][$key] = $value; return true; }
function delete_user_meta(int $user_id, string $key): bool { unset($GLOBALS['aksrt_test_user_meta'][$user_id][$key]); return true; }
function get_users(array $args = []): array {
$ids = array_keys($GLOBALS['aksrt_test_user_meta']);
if (isset($args['include'])) $ids = array_values(array_intersect($ids, $args['include']));
$ids = array_values(array_filter($ids, static fn (int $id): bool => \AKSRT\User\BanAppeal::get_status($id) !== ''));
return $args['fields'] === 'ID' ? $ids : array_map('get_userdata', $ids);
}
function update_meta_cache(string $type, array $ids): void { unset($type, $ids); }
function cache_users(array $ids): void { unset($ids); }
function _prime_post_caches(array $ids, bool $update_term_cache = true, bool $update_meta_cache = true): void { unset($ids, $update_term_cache, $update_meta_cache); }
function _prime_comment_caches(array $ids, bool $update_meta_cache = true): void { unset($ids, $update_meta_cache); }
function get_date_from_gmt(string $date, string $format): string { return $date; }
function current_time(string $type, bool $gmt = false): string { return '2026-10-30 12:00:00'; }
function number_format_i18n($number): string { return number_format((float) $number); }
function get_avatar_url(int $user_id, array $args = []): string { return 'https://example.test/avatar/' . $user_id . '.png'; }
function get_edit_user_link(int $user_id): string { return admin_url('user-edit.php?user_id=' . $user_id); }
function get_post(int $id) { return $id === 44 ? (object) ['ID' => $id] : false; }
function get_the_title(int $id): string { return $id === 44 ? '<img src=x onerror=alert(1)>' : 'Post ' . $id; }
function get_permalink(int $id): string { return 'https://content.example.test/post/' . $id; }
function get_comment(int $id) { return false; }
function get_comment_link(int $id): string { return 'https://content.example.test/comment/' . $id; }
function get_author_posts_url(int $user_id): string { return 'https://content.example.test/author/' . $user_id; }
function aksrt_get_user_home_url(int $user_id): string { return 'https://content.example.test/user/' . $user_id; }
function do_action(string $hook, ...$args): void { unset($hook, $args); }
function fixture_report(int $id, int $reporter, string $status, string $time, string $reason = ''): array {
return [
'id' => $id,
'reporter_id' => $reporter,
'target_type' => 'post',
'target_id' => 44,
'reason' => $reason !== '' ? $reason : 'report reason ' . $id,
'category' => 'other',
'status' => $status,
'created_at' => $time,
'updated_at' => $time,
'notes_json' => '[]',
];
}
for ($id = 1; $id <= 110; $id++) {
$status = $id === 2 ? 'resolved' : ($id === 3 ? 'dismissed' : ($id === 4 ? 'reviewing' : 'pending'));
$time = gmdate('Y-m-d H:i:s', strtotime('2026-10-01 00:00:00 UTC') + ($id * 60));
$reason = $id === 1
? 'Evidence <script>alert(1)</script> https://evidence.example.test/proof.jpg javascript:alert(2)'
: 'report reason ' . $id;
$time = $id === 1 ? '2026-10-30 12:00:00' : ($id === 2 ? '2026-10-29 11:00:00' : $time);
$GLOBALS['aksrt_test_reports'][$id] = fixture_report($id, $id === 1 ? 7 : 20 + $id, $status, $time, $reason);
}
$GLOBALS['aksrt_test_reports'][9001] = fixture_report(9001, 7, 'pending', '2026-09-01 09:00:00', 'needle action report');
$GLOBALS['aksrt_test_reports'][9002] = fixture_report(9002, 7, 'pending', '2026-09-02 09:00:00', 'needle stale report');
function fixture_appeal(int $user_id, string $status, string $time, array $payload = []): void {
$GLOBALS['aksrt_test_user_meta'][$user_id][\AKSRT\User\BanAppeal::META_STATUS] = $status;
$GLOBALS['aksrt_test_user_meta'][$user_id][\AKSRT\User\BanAppeal::META_PAYLOAD] = array_merge([
'time' => $time,
'reason_key' => 'ban reason',
'reason' => 'appeal explanation',
'data' => [],
], $payload);
}
fixture_appeal(501, 'pending', '2026-10-29 12:00:00', [
'reason_key' => 'reason <svg onload=alert(1)>',
'reason' => 'Appeal note <script>alert(2)</script> https://appeal.example.test/file.pdf javascript:alert(3)',
'data' => [
'Name <img src=x onerror=alert(4)>' => 'Applicant <b>value</b>',
'Evidence' => 'https://appeal.example.test/document.pdf',
'Ignored array' => ['nested'],
],
]);
fixture_appeal(502, 'approved', '2026-10-20 09:00:00', ['handled_at' => '2026-10-21 09:00:00', 'handled_by' => 999, 'decision' => 'approved']);
fixture_appeal(503, 'rejected', '2026-10-19 09:00:00', ['handled_at' => '2026-10-20 09:00:00', 'handled_by' => 999, 'decision' => 'rejected']);
for ($user_id = 600; $user_id < 615; $user_id++) fixture_appeal($user_id, 'pending', '2026-10-02 ' . sprintf('%02d', $user_id - 600) . ':00:00');
fixture_appeal(7001, 'pending', '2026-09-03 09:00:00', ['reason' => 'pending appeal action']);
fixture_appeal(7002, 'pending', '2026-09-04 09:00:00', ['reason' => 'pending appeal approve']);
require_once AKSRT_DIR . 'src/Admin/Native/NativeUi.php';
require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBanQuery.php';
require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBanDialogs.php';
require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBanActions.php';
require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBan.php';
final class BanFixtureMenu {
use \AKSRT\Admin\Native\UsersMenusBan;
public const CAPABILITY = 'manage_options';
public function ensure_cap(): void {}
public function flash(): void {}
public function redirect(string $page, string $message, array $args = []): void { throw new TestRedirect($page, $message, $args); }
public function record(string $kind, int $id): ?array { return $this->find_ban_request($kind, $id); }
public function filters(array $input): array { return $this->get_ban_filters($input); }
public function version(array $row): string { return $this->ban_record_version($row); }
public function query(array $filters, int $page): array {
return $this->query_ban_requests($this->get_ban_filters($filters), $page);
}
public function query_record(string $kind, int $id): ?array {
$filters = $this->get_ban_filters(['type' => $kind, 'status' => '']);
$firstPage = $this->query_ban_requests($filters, 1);
$pages = max(1, (int) ceil($firstPage['total'] / 20));
for ($page = 1; $page <= $pages; $page++) {
$result = $page === 1 ? $firstPage : $this->query_ban_requests($filters, $page);
foreach ($result['items'] as $row) if ($row['kind'] === $kind && (int) $row['id'] === $id) return $row;
}
return null;
}
}
function render_ban_page(array $get): string {
$_GET = $get;
ob_start();
(new BanFixtureMenu())->render_ban_page();
return (string) ob_get_clean();
}
function call_action(BanFixtureMenu $menu, string $method, string $nonce_action, array $post): array {
$_POST = array_merge($post, ['_wpnonce' => $nonce_action . '-nonce']);
try {
$menu->{$method}();
return ['redirected' => false];
} catch (TestRedirect $redirect) {
return ['redirected' => true, 'page' => $redirect->page, 'message' => $redirect->redirectMessage, 'args' => $redirect->args];
}
}
$queueMenu = new BanFixtureMenu();
$all = $queueMenu->query(['status' => ''], 2);
$overlargePage = $queueMenu->query(['status' => ''], 99);
$filters = $queueMenu->filters([
'type' => 'report', 'status' => '', 's' => 'needle', 'send_user' => '7',
'orderby' => 'updated', 'order' => 'ASC',
]);
$html = render_ban_page(['page' => 'aksrt_user_ban', 'status' => '', 'orderby' => 'time', 'order' => 'DESC']);
$filteredHtml = render_ban_page([
'page' => 'aksrt_user_ban', 'type' => 'report', 'status' => 'pending', 's' => 'needle',
'send_user' => '7', 'orderby' => 'updated', 'order' => 'ASC', 'paged' => '1',
]);
$GLOBALS['aksrt_test_assets'] = ['styles' => [], 'scripts' => []];
$assetMenu = new BanFixtureMenu();
$assetMenu->enqueue_ban_assets('users_page_aksrt_user_ban');
$assetMenu->enqueue_ban_assets('users_page_other');
$menu = new BanFixtureMenu();
$reportQueryRow = $menu->query_record('report', 9001);
$reportVersion = $menu->version($reportQueryRow);
$reportBase = [
'report_id' => '9001', 'new_status' => 'resolved', 'note' => 'Reviewed', 'record_version' => $reportVersion,
'filter_type' => 'report', 'filter_status' => 'pending', 's' => 'needle', 'send_user' => '7',
'orderby' => 'updated', 'order' => 'ASC', 'paged' => '2',
];
$blankReport = call_action($menu, 'handle_update_report', 'aksrt_users_update_report_9001', array_merge($reportBase, ['new_status' => 'dismissed', 'note' => ' ']));
$staleReport = call_action($menu, 'handle_update_report', 'aksrt_users_update_report_9002', array_merge($reportBase, ['report_id' => '9002', 'record_version' => 'stale-version']));
$validReport = call_action($menu, 'handle_update_report', 'aksrt_users_update_report_9001', $reportBase);
$appealRow = $menu->query_record('appeal', 7001);
$appealVersion = $menu->version($appealRow);
$appealBase = [
'user_id' => '7001', 'decision' => 'reject', 'note' => ' Missing evidence ', 'record_version' => $appealVersion,
'filter_type' => 'appeal', 'filter_status' => 'pending', 's' => 'needle', 'send_user' => '7001',
'orderby' => 'updated', 'order' => 'ASC', 'paged' => '3',
];
$blankAppeal = call_action($menu, 'handle_process_appeal', 'aksrt_users_process_appeal_7001', array_merge($appealBase, ['note' => ' ']));
$validAppeal = call_action($menu, 'handle_process_appeal', 'aksrt_users_process_appeal_7001', $appealBase);
$approveRow = $menu->query_record('appeal', 7002);
$approve = call_action($menu, 'handle_process_appeal', 'aksrt_users_process_appeal_7002', [
'user_id' => '7002', 'decision' => 'approve', 'note' => '', 'record_version' => $menu->version($approveRow),
]);
$filtersHtml = render_ban_page(['page' => 'aksrt_user_ban', 'type' => 'report', 'status' => 'pending', 's' => 'needle', 'send_user' => '7', 'orderby' => 'updated', 'order' => 'ASC']);
echo json_encode([
'queue' => [
'total' => $all['total'], 'page' => $all['page'], 'counts' => $all['counts'], 'items' => count($all['items']),
'overlarge_page' => $overlargePage['page'], 'overlarge_items' => count($overlargePage['items']),
],
'filters' => $filters,
'html' => $html,
'filtered_html' => $filteredHtml,
'assets' => $GLOBALS['aksrt_test_assets'],
'actions' => [
'blank_report' => $blankReport, 'stale_report' => $staleReport, 'valid_report' => $validReport,
'blank_appeal' => $blankAppeal, 'valid_appeal' => $validAppeal, 'approve_appeal' => $approve,
'report_calls' => $GLOBALS['aksrt_test_report_actions'], 'messages' => $GLOBALS['aksrt_test_messages'],
'appeal_status' => \AKSRT\User\BanAppeal::get_status(7001),
'appeal_payload' => \AKSRT\User\BanAppeal::get_payload(7001),
'approved_status' => \AKSRT\User\BanAppeal::get_status(7002),
'restrictions_lifted' => \AKSRT\User\Restriction::$lifted,
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
@@ -0,0 +1,260 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const rootDir = path.resolve(__dirname, '..', '..');
function runtime({ completed = false } = {}) {
const ids = new Map();
const doc = { activeElement: null };
class Element {
constructor(attrs = {}) {
this.attrs = { ...attrs };
this.listeners = {};
this.queries = new Map();
this.dataset = {};
this.disabled = false;
this.open = false;
this.required = false;
this.checked = Boolean(attrs.checked);
this.value = attrs.value || '';
this.textContent = attrs.textContent || '';
this.validity = '';
this.isConnected = true;
this.currentContent = null;
const names = new Set(String(attrs.class || '').split(/\s+/).filter(Boolean));
this.classList = {
add(name) { names.add(name); },
remove(name) { names.delete(name); },
toggle(name, force) {
const enabled = force === undefined ? !names.has(name) : Boolean(force);
if (enabled) names.add(name); else names.delete(name);
return enabled;
},
contains(name) { return names.has(name); },
};
}
getAttribute(name) { return this.attrs[name] ?? null; }
setAttribute(name, value) { this.attrs[name] = String(value); }
removeAttribute(name) { delete this.attrs[name]; }
addEventListener(type, callback) { (this.listeners[type] ||= []).push(callback); }
fire(type, values = {}) {
const event = {
target: this,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; },
...values,
};
(this.listeners[type] || []).forEach((callback) => callback(event));
return event;
}
querySelector(selector) {
const matches = this.querySelectorAll(selector);
return matches[0] || null;
}
querySelectorAll(selector) {
const mapped = this.queries.get(selector);
if (mapped) return Array.isArray(mapped) ? mapped : [mapped];
if (this.currentContent) return this.currentContent.querySelectorAll(selector);
return [];
}
map(selector, elements) { this.queries.set(selector, elements); return this; }
closest(selector) {
if (selector === '[data-aksrt-dialog-close]' && this.attrs['data-aksrt-dialog-close'] !== undefined) return this;
return null;
}
focus() { doc.activeElement = this; }
setCustomValidity(message) { this.validity = message; }
replaceChildren(content = null) { this.currentContent = content; }
getBoundingClientRect() { return { left: 100, top: 100, right: 500, bottom: 500 }; }
}
function fragment({ readOnly = false, initiallyChecked = false } = {}) {
const part = new Element();
const title = new Element({ id: 'aksrt-ban-dialog-title', autofocus: '' });
const closeIcon = new Element({ 'data-aksrt-dialog-close': '' });
const closeFooter = new Element({ 'data-aksrt-dialog-close': '' });
part.map('#aksrt-ban-dialog-title', title)
.map('[data-aksrt-dialog-close]', [closeIcon, closeFooter]);
const controls = [closeIcon, closeFooter];
let form = null;
let radios = [];
let note = null;
let submit = null;
if (!readOnly) {
radios = [
new Element({ type: 'radio', value: 'approve', required: '', checked: initiallyChecked }),
new Element({ type: 'radio', value: 'reject', required: '', checked: initiallyChecked }),
];
note = new Element({ name: 'note', value: 'old note', 'data-required-error': 'Please enter a nonblank rejection reason' });
const label = new Element({ 'data-aksrt-ban-note-label': '', 'data-optional': 'Optional note', 'data-required': 'Reject reason required' });
submit = new Element({ 'data-aksrt-ban-submit': '', 'data-idle-label': 'Choose decision', 'data-approve-label': 'Confirm approval', 'data-reject-label': 'Confirm rejection', 'data-busy-label': 'Submitting' });
controls.push(submit);
form = new Element();
form.dataset = {};
form.elements = { radios, note };
form.map('input[type="radio"]', radios)
.map('textarea[name="note"]', note)
.map('[data-aksrt-ban-note-label]', label)
.map('[data-aksrt-ban-submit]', submit);
form.reportValidity = () => {
const hasRequiredRadio = radios.some((radio) => radio.required);
const radioValid = !hasRequiredRadio || radios.some((radio) => radio.checked);
return radioValid && !note.validity && (!note.required || note.value.trim() !== '');
};
part.map('form[data-aksrt-ban-review]', form);
}
part.map('[data-aksrt-ban-submit], [data-aksrt-dialog-close]', controls);
return { part, form, radios, note, submit, title, closeIcon, closeFooter };
}
const root = new Element();
const window = new Element();
doc.documentElement = new Element();
doc.querySelector = (selector) => selector === '.aksrt-ban-page' ? root : null;
doc.getElementById = (id) => ids.get(id) || null;
const dialog = new Element();
const originalDialogQuery = dialog.querySelector.bind(dialog);
const originalDialogQueryAll = dialog.querySelectorAll.bind(dialog);
dialog.querySelector = (selector) => dialog.currentContent ? dialog.currentContent.querySelector(selector) : originalDialogQuery(selector);
dialog.querySelectorAll = (selector) => dialog.currentContent ? dialog.currentContent.querySelectorAll(selector) : originalDialogQueryAll(selector);
dialog.showModal = () => {
dialog.open = true;
const title = dialog.querySelector('#aksrt-ban-dialog-title');
if (title) title.focus();
};
dialog.close = () => {
if (!dialog.open) return;
dialog.open = false;
dialog.fire('close');
};
ids.set('aksrt-ban-dialog', dialog);
const opener = new Element({ 'data-aksrt-ban-view': 'report-1' });
const readonlyOpener = new Element({ 'data-aksrt-ban-view': 'report-2' });
const templates = new Map();
templates.set('aksrt-ban-detail-report-1', {
content: { cloneNode: () => fragment().part },
});
templates.set('aksrt-ban-detail-report-2', {
content: { cloneNode: () => fragment({ readOnly: true }).part },
});
ids.set('aksrt-ban-detail-report-1', templates.get('aksrt-ban-detail-report-1'));
ids.set('aksrt-ban-detail-report-2', templates.get('aksrt-ban-detail-report-2'));
root.map('[data-aksrt-ban-view]', [opener, readonlyOpener]);
vm.runInNewContext(fs.readFileSync(path.join(rootDir, 'assets/js/admin-user-ban.js'), 'utf8'), { document: doc, window });
return { doc, window, root, dialog, opener, readonlyOpener };
}
function openReview(runtimeState, button = runtimeState.opener) {
button.fire('click');
const frame = runtimeState.dialog.currentContent;
const form = frame.querySelector('form[data-aksrt-ban-review]');
return {
frame,
form,
radios: form.querySelectorAll('input[type="radio"]'),
note: form.querySelector('textarea[name="note"]'),
label: form.querySelector('[data-aksrt-ban-note-label]'),
submit: form.querySelector('[data-aksrt-ban-submit]'),
closeButtons: frame.querySelectorAll('[data-aksrt-ban-submit], [data-aksrt-dialog-close]').filter((button) => button.attrs['data-aksrt-dialog-close'] !== undefined),
};
}
test('opening review clears choices, focuses the dialog, and locks page scrolling', () => {
const r = runtime();
const review = openReview(r);
assert.equal(r.dialog.open, true);
assert.equal(r.doc.activeElement, review.frame.querySelector('#aksrt-ban-dialog-title'));
assert.deepEqual(review.radios.map((radio) => radio.checked), [false, false]);
assert.equal(review.note.value, '');
assert.equal(review.submit.disabled, true);
assert.equal(review.label.textContent, 'Optional note');
assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), true);
});
test('reject decisions require a nonblank trimmed note before submission', () => {
const r = runtime();
const review = openReview(r);
review.radios[1].checked = true;
review.form.fire('change');
assert.equal(review.note.required, true);
assert.equal(review.label.textContent, 'Reject reason required');
assert.equal(review.submit.disabled, false);
review.note.value = ' \n ';
review.form.fire('input');
assert.notEqual(review.note.validity, '');
const invalid = review.form.fire('submit');
assert.equal(invalid.defaultPrevented, true);
assert.equal(review.form.dataset.submitting, undefined);
review.note.value = 'Specific reason';
review.form.fire('input');
assert.equal(review.note.validity, '');
const valid = review.form.fire('submit');
assert.equal(valid.defaultPrevented, false);
assert.equal(review.form.dataset.submitting, '1');
assert.equal(review.submit.textContent, 'Submitting');
});
test('submission blocks duplicate posts and closing until pageshow restores state', () => {
const r = runtime();
const review = openReview(r);
review.radios[0].checked = true;
review.form.fire('change');
review.form.fire('submit');
assert.equal(review.submit.disabled, true);
assert.ok(review.closeButtons.every((button) => button.disabled));
assert.equal(r.dialog.open, true);
assert.equal(review.form.fire('submit').defaultPrevented, true);
r.dialog.fire('cancel');
r.dialog.fire('click', { target: review.closeButtons[0] });
assert.equal(r.dialog.open, true);
r.window.fire('pageshow');
assert.equal(review.form.dataset.submitting, undefined);
assert.equal(review.form.getAttribute('aria-busy'), null);
assert.equal(review.submit.disabled, false);
assert.ok(review.closeButtons.every((button) => !button.disabled));
assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), true);
r.dialog.fire('click', { target: review.closeButtons[0] });
assert.equal(r.dialog.open, false);
assert.equal(r.dialog.currentContent, null);
assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), false);
assert.equal(r.doc.activeElement, r.opener);
});
test('completed records open read-only without a form or submit controls', () => {
const r = runtime();
r.readonlyOpener.fire('click');
assert.equal(r.dialog.open, true);
assert.equal(r.dialog.querySelector('form[data-aksrt-ban-review]'), null);
assert.equal(r.dialog.querySelector('[data-aksrt-ban-submit]'), null);
assert.ok(r.dialog.querySelector('[data-aksrt-dialog-close]'));
r.dialog.fire('click', { target: r.dialog.querySelector('[data-aksrt-dialog-close]') });
assert.equal(r.dialog.open, false);
assert.equal(r.doc.activeElement, r.readonlyOpener);
});
test('backdrop closes only when the pointer starts and ends outside the dialog', () => {
const r = runtime();
openReview(r);
r.dialog.fire('pointerdown', { clientX: 120, clientY: 120 });
r.dialog.fire('click', { clientX: 0, clientY: 0 });
assert.equal(r.dialog.open, true);
r.dialog.fire('pointerdown', { clientX: 0, clientY: 0 });
r.dialog.fire('click', { clientX: 0, clientY: 0 });
assert.equal(r.dialog.open, false);
assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), false);
});
@@ -145,10 +145,13 @@ test('ban/report settings page mirrors the reference theme schema', () => {
})
test('ban appeal approval actually lifts restrictions', () => {
assert.match(usersMenus, /lift_user_restriction\(\$user_id\)/)
assert.ok(!usersMenus.includes("SET status = 'lifted'"), 'restriction table has no status column')
assert.ok(!usersMenus.includes('clear_user_bans'))
assert.ok(!usersMenus.includes('lift_ban'))
const banActions = read('src/Admin/Native/UsersMenusBanActions.php')
assert.match(banActions, /lift_user_restriction\(\$user_id\)/)
for (const source of [usersMenus, banActions]) {
assert.ok(!source.includes("SET status = 'lifted'"), 'restriction table has no status column')
assert.ok(!source.includes('clear_user_bans'))
assert.ok(!source.includes('lift_ban'))
}
})
test('ban identity badge occupies the public profile meta row', () => {
@@ -327,71 +330,67 @@ test('admin report notification links to the real page', () => {
assert.match(reportCenter, /users\.php\?page=aksrt_user_ban&type=report&status=pending/)
})
test('native ban page mirrors the reference theme layout', () => {
const tickets = read('src/Admin/Native/UsersMenusTickets.php')
test('native ban page uses the scoped template-and-dialog admin flow', () => {
const banPage = read('src/Admin/Native/UsersMenusBan.php');
const banQuery = read('src/Admin/Native/UsersMenusBanQuery.php');
const banActions = read('src/Admin/Native/UsersMenusBanActions.php');
const banDialogs = read('src/Admin/Native/UsersMenusBanDialogs.php');
const banJs = read('assets/js/admin-user-ban.js');
const banCss = read('assets/css/admin/user-ban.css');
// 菜单与页面标题对齐参考主题(page title=处理举报及禁封申诉,menu title=举报&禁封申诉)
assert.match(usersMenus, /__\('处理举报及禁封申诉', 'aksrt'\),\s*__\('举报&禁封申诉', 'aksrt'\)/, 'submenu titles should match the reference theme')
assert.ok(usersMenus.includes('用户禁封申诉及用户举报管理'), 'page heading should match the reference theme')
assert.match(usersMenus, /use UsersMenusBan;/);
assert.match(usersMenus, /admin_post_aksrt_users_update_report/);
assert.match(usersMenus, /admin_post_aksrt_users_process_appeal/);
assert.doesNotMatch(usersMenus, /use UsersMenusTickets;/, 'ban review must not use the deleted ticket renderer');
assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTickets.php')));
// 类型筛选按钮组 + 状态 subsubsub + 搜索
for (const label of [`__('禁封申诉', 'aksrt')`, `__('用户举报', 'aksrt')`, `__('待处理', 'aksrt')`, `__('已批准', 'aksrt')`, `__('已拒绝', 'aksrt')`, `__('搜索记录', 'aksrt')`]) {
assert.ok(tickets.includes(label), `missing reference-theme label: ${label}`)
for (const key of ['type', 'status', 's', 'send_user', 'orderby', 'order']) {
assert.ok(banQuery.includes(`'${key}'`), `missing ban filter: ${key}`);
}
assert.match(banQuery, /private const BAN_PER_PAGE = 20/);
assert.match(banQuery, /number'\s*=>\s*-1/);
assert.match(banQuery, /array_slice\(\$filtered, \(\$page - 1\) \* self::BAN_PER_PAGE, self::BAN_PER_PAGE\)/);
assert.match(banQuery, /'counts'\s*=>\s*\$counts/);
// 表头列与参考主题一致:状态/类型/提交用户/详情/提交时间/更新时间(带排序)
for (const head of ['状态', '类型', '提交用户', '详情', '提交时间', '更新时间']) {
assert.ok(tickets.includes(`__('${head}', 'aksrt')`), `missing table head: ${head}`)
assert.match(banPage, /if \(\$hook !== 'users_page_aksrt_user_ban'\) return/);
assert.match(banPage, /wp_enqueue_style\('aksrt-admin-user-ban'.*NativeUi::STYLE_HANDLE/);
assert.match(banPage, /wp_enqueue_script\('aksrt-admin-user-ban'/);
assert.match(banPage, /<dialog id="aksrt-ban-dialog"/);
assert.match(banDialogs, /<template id="aksrt-ban-detail-/);
assert.match(banPage, /data-aksrt-ban-view=/);
assert.doesNotMatch(banPage, /update_user_meta|update_status|do_action\(/);
assert.doesNotMatch(banPage, /data-aksrt-ticket-toggle|data-aksrt-ticket-cancel|data-aksrt-ban-toggle/);
assert.match(banDialogs, /if \(\$pending\) \{[\s\S]*?<form method="post"/);
assert.match(banDialogs, /else \{[\s\S]*?<div class="aksrt-ban-dialog__frame">/);
assert.match(banDialogs, /'action'\s*=>\s*\$action/);
assert.match(banDialogs, /record_version/);
assert.match(banDialogs, /wp_nonce_field\(\$action \. '_' \. \(int\) \$row\['id'\]\)/);
for (const name of ['filter_type', 'filter_status', 's', 'send_user', 'orderby', 'order', 'paged']) {
assert.ok(banDialogs.includes(`'${name}'`), `review form must preserve ${name}`);
}
assert.match(tickets, /orderby-but/)
assert.match(tickets, /dashicons-arrow-up/)
assert.match(banDialogs, /esc_url\(\$part, \['http', 'https'\]\)/);
assert.match(banDialogs, /esc_html\(\$value !== '' \? \$value : '—'\)/);
assert.match(banDialogs, /target="_blank" rel="noopener noreferrer"/);
assert.match(banDialogs, /data-required-error/);
// 行渲染与处理表单对齐参考主题
for (const label of [`__('立即处理', 'aksrt')`, `__('正在处理', 'aksrt')`, `__('被举报用户', 'aksrt')`, `__('被举报用户禁封信息', 'aksrt')`, `__('申诉用户', 'aksrt')`, `__('禁封信息', 'aksrt')`, `__('申诉信息', 'aksrt')`, `__('处理方式', 'aksrt')`, `__('处理留言', 'aksrt')`, `__('申诉通过(解封该用户)', 'aksrt')`, `__('申诉拒绝(保持原禁封状态)', 'aksrt')`, `__('此处内容会反馈给举报人', 'aksrt')`, `__('确认提交', 'aksrt')`, `__('用户状态正常', 'aksrt')`, `__('当前用户已解封', 'aksrt')`, `__('此用户已解封', 'aksrt')`, `__('暂无申请记录', 'aksrt')`]) {
assert.ok(tickets.includes(label), `missing process form label: ${label}`)
for (const method of ['handle_update_report', 'handle_process_appeal']) {
assert.match(banActions, new RegExp(`public function ${method}\\(\\): void`));
}
// 处理表单必须携带隐藏 action 字段:admin-post.php 靠它触发
// admin_post_{action} 钩子,缺失时 WordPress 不输出任何内容(白屏)。
// value 按工单类型分发(report → update_report,appeal → process_appeal)。
assert.match(tickets, /name="action" value="' \. esc_attr\(\$kind === 'report' \? 'aksrt_users_update_report' : 'aksrt_users_process_appeal'\)/)
assert.match(usersMenus, /admin_post_aksrt_users_update_report/)
assert.match(usersMenus, /admin_post_aksrt_users_process_appeal/)
// 内联处理视图(参考主题方向):表单以合法的 colspan=6 单元格随待处理行
// 预渲染;展开/取消走 JS 行↔表单互换(无整页刷新),信息只出现一次,
// 不再裸嵌 tbody 被 foster-parent 错位。
assert.match(tickets, /<td colspan="6">';\s*\$this->render_ticket_process_form/)
assert.match(tickets, /data-aksrt-ticket-toggle="/)
assert.match(tickets, /data-aksrt-ticket-cancel="/)
assert.match(tickets, /data-aksrt-ticket-row="/)
assert.match(tickets, /e\.preventDefault\(\);/)
// 展开的处理表单提供“取消”按钮(确认提交右侧)。
assert.match(tickets, /class="button button-primary process-submit"/)
assert.match(tickets, /<a class="button" data-aksrt-ticket-cancel="' \. esc_attr\(\$row\['kind'\] \. '-' \. \(string\) \$row\['id'\]\) . '" href="' \. esc_url\(remove_query_arg\(\['action', 'id'\]\)\) . '">' \. esc_html__\('取消', 'aksrt'\)/)
// 分页对齐参考主题 zibpay_admin_pagenavi(每页 20 条)
assert.match(tickets, /TICKETS_PER_PAGE = 20/)
assert.match(tickets, /ticket_pagenavi\(int \$total_count, int \$per_page\)/)
for (const label of ['前往第一页', '前往上一页', '前往下一页', '前往最后一页', 'displaying-num', 'paging-input', 'total-pages']) {
assert.ok(tickets.includes(label), `missing pagenavi part: ${label}`)
}
// 旧的三 tab 结构已收敛;举报处理表单不包含处罚下拉(对齐参考主题)
assert.ok(!usersMenus.includes('tab=reports'), 'old reports tab is gone')
assert.ok(!usersMenus.includes('tab=appeals'), 'old appeals tab is gone')
// 禁封限制 Tab 已移除(参考主题无此后台 Tab):禁封改走前台封禁弹窗。
assert.ok(!usersMenus.includes('tab=restrictions'), 'restrictions tab is gone')
assert.ok(!usersMenus.includes('render_restrictions_tab'), 'restrictions tab renderer is gone')
assert.ok(!usersMenus.includes('handle_add_restriction'), 'backend ban form handler is gone')
assert.ok(!usersMenus.includes('handle_remove_restriction'), 'backend unban form handler is gone')
assert.ok(!usersMenus.includes('render_reports_tab'))
assert.ok(!usersMenus.includes('render_appeals_tab'))
assert.match(usersMenus, /use UsersMenusTickets;/)
assert.ok(!tickets.includes('name="action_taken"'), 'report process form should be status-only like the reference theme')
// 工单处理页「点击此处」跳转前台用户公开主页(封面三点菜单 → 封禁弹窗)。
assert.match(tickets, /aksrt_get_user_home_url\(\$target_id\)/)
assert.ok(!tickets.includes('tab=restrictions'), 'ticket ban link must not point at the removed backend tab')
})
assert.match(banActions, /check_admin_referer\('aksrt_users_update_report_'/);
assert.match(banActions, /check_admin_referer\('aksrt_users_process_appeal_'/);
assert.match(banActions, /hash_equals\(\$this->ban_record_version\(\$row\), \$submitted\)/);
assert.match(banActions, /\$this->ban_request_is_current\(\$row, 'pending'\)/);
assert.match(banActions, /\$new_status === 'dismissed' && \$note === ''/);
assert.match(banActions, /\$decision === 'reject' && \$note === ''/);
assert.match(banActions, /lift_user_restriction\(\$user_id\)/);
assert.match(banActions, /'paged'\s*=>/);
assert.match(banJs, /window\.addEventListener\('pageshow'/);
assert.match(banJs, /previousOpener\.focus\(\)/);
assert.match(banJs, /if \(!submitting\) dialog\.close\(\)/);
assert.match(banCss, /html\.aksrt-ban-modal-open \{ overflow: hidden/);
});
test('report modal mirrors the reference theme: preset reasons, image evidence, notice, geometry', () => {
const scripts = read('src/Core/ScriptsPart1c.php')
// 预置原因真实数据源是 ban_preset_reason(UserRuntime::ban_settings),