feat(admin): review reports and ban appeals in dialogs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
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, /<script>alert\(1\)<\/script>/);
|
||||
assert.match(html, /<img src=x onerror=alert\(1\)>/);
|
||||
assert.match(html, /Name <img src=x onerror=alert\(4\)>/);
|
||||
assert.match(html, /Applicant <b>value<\/b>/);
|
||||
assert.match(html, /reason <svg onload=alert\(1\)>/);
|
||||
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/);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user