fix(reports): separate readable categories from report content

Store readable category snapshots separately from details and evidence, validate report categories, and preserve context in admin displays and moderation actions. Widen report categories to TEXT and bump the schema to 3.2.0 without legacy data conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-31 00:57:13 +08:00
1 parent b2811cc248
commit b45fd161f0
16 files changed
+797 -75

No files matched your search

@@ -82,6 +82,23 @@ test('PHP templates escape all appeal data and report evidence while preserving
assert.doesNotMatch(html, /<script>alert\(/i);
});
test('report details display readable categories separately from optional content and evidence', () => {
const { html } = phpFixture();
const detail = html.match(/<template id="aksrt-ban-detail-report-1">([\s\S]*?)<\/template>/)?.[1] || '';
const category = detail.match(/<h3>举报分类<\/h3><div class="aksrt-ban-copy">([\s\S]*?)<\/div>/)?.[1];
const content = detail.match(/<h3>举报内容与举证<\/h3><div class="aksrt-ban-copy">([\s\S]*?)<\/div>/)?.[1] || '';
assert.equal(category, '版权投诉');
assert.match(content, /Evidence/);
assert.match(content, /https:\/\/evidence\.example\.test\/proof\.jpg/);
assert.doesNotMatch(content, /版权投诉/);
assert.match(html, /class="aksrt-ban-summary">版权投诉<\/div>/);
const categoryOnly = html.match(/<template id="aksrt-ban-detail-report-2">([\s\S]*?)<\/template>/)?.[1] || '';
assert.match(categoryOnly, /<h3>举报分类<\/h3><div class="aksrt-ban-copy">人身攻击<\/div>/);
assert.match(categoryOnly, /<h3>举报内容与举证<\/h3><div class="aksrt-ban-copy">未填写<\/div>/);
assert.match(html, /class="aksrt-ban-summary">人身攻击<\/div>/);
});
test('dedicated ban assets enqueue only on the matching native page', () => {
const { assets } = phpFixture();
+4 -1
View File
@@ -223,7 +223,7 @@ namespace {
'target_type' => 'post',
'target_id' => 44,
'reason' => $reason !== '' ? $reason : 'report reason ' . $id,
'category' => 'other',
'category' => '垃圾信息',
'status' => $status,
'created_at' => $time,
'updated_at' => $time,
@@ -240,6 +240,9 @@ namespace {
$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'][1]['category'] = '版权投诉';
$GLOBALS['aksrt_test_reports'][2]['category'] = '人身攻击';
$GLOBALS['aksrt_test_reports'][2]['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');
@@ -54,6 +54,14 @@ test('report REST surface is canonical: submit + users/reports only', () => {
assert.ok(!reportOps.includes('public function get_report_counts'), 'dead get_report_counts method')
})
test('report submit validates required category text and optional content before sanitizing', () => {
const start = userController.indexOf("'/report/submit'")
const route = userController.slice(start, userController.indexOf(']);', start) + 3)
assert.match(route, /'category'\s*=>\s*\[\s*'type' => 'string', 'required' => true/)
assert.match(route, /'reason'\s*=>\s*\[\s*'type' => 'string', 'default' => ''/)
assert.equal((route.match(/'validate_callback' => 'rest_validate_request_arg'/g) || []).length, 2)
})
test('report center has no legacy ajax handlers or option migration', () => {
assert.ok(!reportCenter.includes('ajax_update_status'))
assert.ok(!reportCenter.includes('ajax_list'))
@@ -124,7 +132,6 @@ test('ban/report settings page mirrors the reference theme schema', () => {
assert.match(schemaJs, /添加原因/)
assert.match(schemaJs, /添加申诉资料/)
// 运行时读取兼容 group 数组 [{t}] 并保留旧格式回退
const runtime = read('src/Settings/UserRuntime.php')
assert.match(runtime, /ban_settings\(\): array/)
assert.match(runtime, /report_settings\(\): array/)
@@ -132,10 +139,8 @@ test('ban/report settings page mirrors the reference theme schema', () => {
// 缺键回退 schema 默认值(参考主题 _pz 语义):运行时经 read_domain 读取,
// 裸 get_all() 会让未保存过分区的站点拿不到 ban_preset_reason 默认值。
assert.match(runtime, /read_domain\('users', 'report'\)/)
// 预置原因 slug 不得由 sanitize_key(标签) 直接派生:中文标签会被清洗成
// 空串,前端按 slug 非空过滤后整张原因列表会被丢弃。ASCII 键优先,
// 否则用标签内容的稳定 md5 短哈希。
assert.match(runtime, /md5\(\$text\)/)
assert.match(runtime, /\$reasons\[\] = \['name' => \$text\]/)
assert.doesNotMatch(runtime, /reason_slug|md5\(\$text\)/)
// 死链清理:user_report_categories 从不是真实存储键,解析器必须删除。
assert.ok(!runtime.includes('parse_report_categories'), 'dead parse_report_categories must stay removed')
assert.ok(!runtime.includes('user_report_categories'), 'dead user_report_categories key must stay removed')
@@ -404,7 +409,11 @@ test('report modal uses accessible reasons, automatic target association and opt
}
assert.doesNotMatch(reportJs, /report_url|违规链接|data-aksrt-report-reason=|bindAutoHeight/)
for (const source of [reportJs, commentItem, authorHero]) assert.doesNotMatch(source, /target_avatar/)
assert.match(reportJs, /description\) reason \+= '\\n详细描述:\\n' \+ description/)
assert.match(reportJs, /description\) content\.push\(description\)/)
assert.doesNotMatch(reportJs, /category\.slug|reason = category/)
assert.doesNotMatch(reportOps, /sanitize_key\(\(string\) (?:\$request->get_param\('category'\)|\$row\['category'\])/)
assert.match(reportCenter, /'category'\s*=>\s*\$category/)
assert.doesNotMatch(reportCenter, /sanitize_key\(\$category\)/)
assert.match(reportJs, /report\/upload-image/)
assert.match(reportJs, /window\.AksrtModalRuntime\.getActive\(\) === shell/)
assert.match(userController, /'\/report\/upload-image'/)
@@ -65,8 +65,8 @@ class MiniFormData {
}
function createRuntime({ imgEnabled = true, categories = [
{ slug: 'spam', name: '垃圾信息' },
{ slug: 'abuse', name: '人身攻击' },
{ name: '垃圾信息' },
{ name: '人身攻击' },
] } = {}) {
const document = new ReportDocument();
const modals = [];
@@ -192,7 +192,7 @@ async function flushPromises() {
}
test('opens from an article button, associates the target, omits URL input, and escapes rendered names', () => {
const runtime = createRuntime({ categories: [{ slug: 'unsafe', name: '<svg onload="run()">reason</svg>' }] });
const runtime = createRuntime({ categories: [{ name: '<svg onload="run()">reason</svg>' }] });
const targetName = '<img src=x onerror="run()"> &';
const modal = runtime.openReport({ target_type: 'post', target_id: 73, target_name: targetName });
const target = modal.shell.querySelector('.aksrt-report-target__name');
@@ -249,8 +249,8 @@ test('requires a radio reason and other text, then sends canonical fields with d
assert.deepEqual(payload, {
target_type: 'post',
target_id: 42,
category: '',
reason: '自定义原因\n详细描述:\n补充背景',
category: '其他原因',
reason: '自定义原因\n\n补充背景',
});
call.resolve({ success: true, message: '已提交' });
await flushPromises();
@@ -273,8 +273,34 @@ test('preset reason ignores hidden custom text while including description in re
assert.deepEqual(payload, {
target_type: 'post',
target_id: 42,
category: 'abuse',
reason: '人身攻击\n详细描述:\nMore context',
category: '人身攻击',
reason: 'More context',
});
});
test('allows a preset-only report without copying the category into content', async () => {
const runtime = createRuntime();
const modal = runtime.openReport();
selectReason(modal, 0);
submit(modal);
const request = callsFor(runtime, '/report/submit')[0];
assert.deepEqual(payloadFor(request), {
target_type: 'post', target_id: 42, category: '垃圾信息', reason: '',
});
request.resolve({ success: true });
await flushPromises();
});
test('keeps a single other option and preserves explicitly entered category words in content', () => {
const runtime = createRuntime({ categories: [{ name: '其他原因' }, { name: '垃圾信息' }] });
const modal = runtime.openReport();
const radios = modal.shell.querySelectorAll('[name="report_reason"]');
assert.equal(radios.length, 2);
selectReason(modal, 0);
modal.shell.querySelector('[name="report_desc"]').value = '垃圾信息中包含了违规内容';
submit(modal);
assert.deepEqual(payloadFor(callsFor(runtime, '/report/submit')[0]), {
target_type: 'post', target_id: 42, category: '垃圾信息', reason: '垃圾信息中包含了违规内容',
});
});
@@ -381,7 +407,7 @@ test('server failure preserves values, reenables controls, and allows a retry',
submit(modal);
const retry = callsFor(runtime, '/report/submit')[1];
assert.equal(payloadFor(retry).reason, 'A custom report reason\n详细描述:\nAdditional context');
assert.equal(payloadFor(retry).reason, 'A custom report reason\n\nAdditional context');
retry.resolve({ success: true });
await flushPromises();
});
@@ -0,0 +1,471 @@
<?php
/**
* Exercise report submission, storage, notifications, and admin filtering without WordPress.
*
* @package AKSRT
*/
namespace AKSRT\Core {
final class DBMigrator {
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
}
}
namespace AKSRT\Settings {
final class Settings {
public static array $values = [];
public static function get_all(): array { return self::$values; }
public static function read_domain(string $domain, string $section): array {
unset($domain, $section);
return self::$values;
}
}
}
namespace AKSRT\Messages {
final class Message {
public static function get_instance(): self { return new self(); }
public function send(int $user_id, string $type, string $content, array $args = []): void {
$GLOBALS['aksrt_test_messages'][] = compact('user_id', 'type', 'content', 'args');
}
}
}
namespace AKSRT\Attachment {
final class PendingUploads {
public static function claim_text(string $text, int $user_id): void {
$GLOBALS['aksrt_test_claims'][] = [$text, $user_id];
}
}
}
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 TYPES = [self::TYPE_WARN, self::TYPE_MUTE, self::TYPE_SUSPEND, self::TYPE_BAN];
public const SCOPE_GLOBAL = 'global';
public static array $calls = [];
public static function get_instance(): self { return new self(); }
public function restrict(int $target_id, string $type, string $reason, string $scope, string $expires_at, int $actor_id): array {
self::$calls[] = compact('target_id', 'type', 'reason', 'scope', 'expires_at', 'actor_id');
return ['success' => true];
}
}
}
namespace {
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
define('ARRAY_A', 'ARRAY_A');
class WP_User {
public function __construct(public int $ID, public string $display_name) {}
}
class WP_User_Query {}
class WP_Post {
public string $post_status = 'publish';
public function __construct(public int $ID) {}
}
class WP_REST_Request {
public function __construct(private array $params = []) {}
public function get_json_params(): array { return $this->params; }
public function get_param(string $key) { return $this->params[$key] ?? null; }
}
class WP_REST_Response {
public function __construct(private array $data = [], private int $status = 200) {}
public function get_data(): array { return $this->data; }
public function get_status(): int { return $this->status; }
}
require_once ABSPATH . 'src/Messages/ReportCenter.php';
require_once ABSPATH . 'src/Settings/UserRuntime.php';
require_once ABSPATH . 'src/Admin/UserControllerReportOperations.php';
final class TestWpdb {
public string $prefix = 'wp_';
public int $insert_id = 0;
public string $last_error = '';
public bool $fail_insert = false;
public array $rows = [];
public array $insert_calls = [];
private int $next_id = 1;
private array $last_args = [];
public function prepare(string $query, ...$args): string {
if (count($args) === 1 && is_array($args[0])) {
$args = $args[0];
}
$this->last_args = $args;
return $query;
}
public function insert(string $table, array $data, array $format = []) {
$this->insert_calls[] = [$table, $data, $format];
if ($this->fail_insert) {
return false;
}
$id = $this->next_id++;
$this->insert_id = $id;
$this->rows[] = array_merge(['id' => $id], $data, ['notes_json' => '[]']);
return 1;
}
public function get_var(string $query) {
if (str_contains($query, 'reporter_id = %d')) {
[$reporter_id, $target_type, $target_id] = array_pad($this->last_args, 3, null);
foreach ($this->rows as $row) {
if ((int) $row['reporter_id'] === (int) $reporter_id
&& (string) $row['target_type'] === (string) $target_type
&& (int) $row['target_id'] === (int) $target_id
&& (string) $row['status'] === 'pending'
) {
return (int) $row['id'];
}
}
return null;
}
if (str_contains($query, 'COUNT(*)') && str_contains($query, 'target_type = %s')) {
[$target_type, $target_id] = array_pad($this->last_args, 2, null);
return count(array_filter($this->rows, static fn (array $row): bool =>
(string) $row['target_type'] === (string) $target_type
&& (int) $row['target_id'] === (int) $target_id
));
}
return 0;
}
public function get_results(string $query, $output = null): array {
if (str_contains($query, 'GROUP BY status')) {
$counts = [];
foreach ($this->rows as $row) {
$status = (string) ($row['status'] ?? '');
$counts[$status] = ($counts[$status] ?? 0) + 1;
}
$results = [];
foreach ($counts as $status => $count) {
$results[] = ['status' => $status, 'cnt' => $count];
}
return $results;
}
$rows = $this->rows;
$index = 0;
foreach (['status', 'category', 'target_type'] as $field) {
if (str_contains($query, $field . ' = %s')) {
$value = (string) ($this->last_args[$index++] ?? '');
$rows = array_values(array_filter($rows, static fn (array $row): bool =>
(string) ($row[$field] ?? '') === $value
));
}
}
return $rows;
}
public function get_row(string $query, $output = null) {
$id = (int) ($this->last_args[0] ?? 0);
foreach ($this->rows as $row) {
if ((int) $row['id'] === $id) {
return $output === ARRAY_A ? $row : (object) $row;
}
}
return null;
}
public function update(string $table, array $data, array $where, array $format = [], array $where_format = []) {
unset($table, $format, $where_format);
foreach ($this->rows as &$row) {
if ((int) ($row['id'] ?? 0) === (int) ($where['id'] ?? 0)) {
$row = array_merge($row, $data);
unset($row);
return 1;
}
}
unset($row);
return false;
}
}
final class ReportFixtureController {
use \AKSRT\Admin\UserControllerReportOperations;
protected function success($data = [], int $status = 200): WP_REST_Response {
return new WP_REST_Response(array_merge(['success' => true], $data), $status);
}
protected function error(string $message, int $status = 400): WP_REST_Response {
return new WP_REST_Response(['success' => false, 'message' => $message], $status);
}
protected function get_json_params(WP_REST_Request $request): array {
return $request->get_json_params();
}
private function build_admin_user_summary(int $user_id, $user): array {
return [
'id' => $user_id,
'display_name' => $user ? $user->display_name : '',
'email' => '',
];
}
}
function __(string $text, string $domain = ''): string { return $text; }
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 esc_url_raw($url): string { return (string) $url; }
function wp_kses_post($value): string { return strip_tags((string) $value); }
function current_time(string $type): string { unset($type); return '2026-10-30 12:00:00'; }
function wp_json_encode($value): string { return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '[]'; }
function get_current_user_id(): int { return (int) ($GLOBALS['aksrt_test_user_id'] ?? 0); }
function get_userdata(int $user_id) { return $user_id > 0 ? new WP_User($user_id, 'Reporter ' . $user_id) : false; }
function get_users(array $args = []): array { unset($args); return [901]; }
function admin_url(string $path = ''): string { return 'https://example.test/wp-admin/' . ltrim($path, '/'); }
function get_post(int $id) { return $id > 0 ? new WP_Post($id) : false; }
function get_the_title($post): string { return 'Article #' . (is_object($post) ? $post->ID : (int) $post); }
function get_permalink($post): string { return 'https://example.test/post/' . (is_object($post) ? $post->ID : (int) $post); }
function do_action(string $hook, ...$args): void {
if ($hook === 'aksrt_report_submitted') {
$GLOBALS['aksrt_test_actions'][] = $args;
}
}
if (!function_exists('mb_substr')) {
function mb_substr(string $text, int $start, ?int $length = null): string {
return $length === null ? substr($text, $start) : substr($text, $start, $length);
}
}
function reset_report_fixture(array $preset_names = ['垃圾信息', '人身攻击']): void {
$GLOBALS['wpdb'] = new TestWpdb();
$GLOBALS['aksrt_test_user_id'] = 42;
$GLOBALS['aksrt_test_actions'] = [];
$GLOBALS['aksrt_test_messages'] = [];
$GLOBALS['aksrt_test_claims'] = [];
\AKSRT\User\Restriction::$calls = [];
\AKSRT\Settings\Settings::$values = [
'ban_preset_reason' => array_map(static fn (string $name): array => ['t' => $name], $preset_names),
'user_report_img_s' => true,
];
}
function submit_report_case(array $payload): WP_REST_Response {
return (new ReportFixtureController())->submit_report(new WP_REST_Request($payload));
}
function snapshot_response(WP_REST_Response $response): array {
$wpdb = $GLOBALS['wpdb'];
return [
'response' => $response->get_data(),
'status' => $response->get_status(),
'rows' => $wpdb->rows,
'insert_calls' => $wpdb->insert_calls,
'actions' => $GLOBALS['aksrt_test_actions'],
'messages' => $GLOBALS['aksrt_test_messages'],
'claims' => $GLOBALS['aksrt_test_claims'],
];
}
$results = [];
reset_report_fixture(['垃圾信息', '人身攻击']);
$results['runtime_reasons'] = \AKSRT\Settings\UserRuntime::ban_settings()['reasons'];
reset_report_fixture();
$results['preset_with_evidence'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 73,
'category' => '垃圾信息',
'reason' => '补充详情 https://example.test/uploads/proof.png',
]));
reset_report_fixture();
$results['preset_label_only'] = snapshot_response(submit_report_case([
'target_type' => 'comment',
'target_id' => 88,
'category' => '人身攻击',
'reason' => '',
]));
reset_report_fixture(['0']);
$results['falsey_text'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 90,
'category' => '0',
'reason' => '0',
]));
$long_label = '非常长的预置举报分类名称超过数据库原先四十字符限制并且应完整保留在工单及后台列表当中';
reset_report_fixture([$long_label]);
$results['long_preset_label'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 19,
'category' => $long_label,
'reason' => '',
]));
reset_report_fixture();
$results['other_with_details'] = snapshot_response(submit_report_case([
'target_type' => 'comment',
'target_id' => 99,
'category' => '其他原因',
'reason' => '请核查相关资料',
]));
reset_report_fixture();
$results['sanitized_snapshot'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 12,
'category' => ' <b>垃圾信息</b> ',
'reason' => '<strong>补充详情</strong>',
]));
reset_report_fixture();
$results['blank_category'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '', 'reason' => '详情',
]));
reset_report_fixture();
$results['missing_other_details'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '其他原因', 'reason' => ' ',
]));
reset_report_fixture();
$results['unknown_category'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '未配置的分类', 'reason' => '详情',
]));
reset_report_fixture();
$results['hash_category'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => 'ab73c91f054e', 'reason' => '详情',
]));
reset_report_fixture();
$GLOBALS['aksrt_test_user_id'] = 0;
$results['unauthenticated'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '垃圾信息', 'reason' => '',
]));
reset_report_fixture();
$results['invalid_target'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 0, 'category' => '垃圾信息', 'reason' => '',
]));
reset_report_fixture();
$GLOBALS['wpdb']->fail_insert = true;
$GLOBALS['wpdb']->insert_id = 777;
$results['insert_failure'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '垃圾信息', 'reason' => '含有举证',
]));
reset_report_fixture();
$GLOBALS['wpdb']->rows[] = [
'id' => 51,
'reporter_id' => 42,
'target_type' => 'post',
'target_id' => 73,
'reason' => '既有详情',
'category' => '垃圾信息',
'status' => 'pending',
'created_at' => '2026-10-30 11:00:00',
'updated_at' => '',
'notes_json' => '[]',
];
$results['duplicate'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 73, 'category' => '垃圾信息', 'reason' => '重复提交',
]));
reset_report_fixture(['涉嫌侵权']);
$admin_controller = new ReportFixtureController();
$submit_response = submit_report_case([
'target_type' => 'post', 'target_id' => 31, 'category' => '涉嫌侵权', 'reason' => '',
]);
$list_response = $admin_controller->get_report_list(new WP_REST_Request(['category' => '涉嫌侵权']));
$list_data = $list_response->get_data()['data'] ?? [];
$detail_response = $admin_controller->get_report_detail(new WP_REST_Request(['id' => 1]));
$detail_data = $detail_response->get_data()['data'] ?? [];
$results['admin_category'] = [
'submit_success' => !empty($submit_response->get_data()['success']),
'list_items' => $list_data['items'] ?? [],
'list_options' => $list_data['options']['categories'] ?? [],
'detail_report' => $detail_data['report'] ?? [],
];
reset_report_fixture();
$service_id = \AKSRT\Messages\ReportCenter::get_instance()->submit(42, 'post', 6, '详情', '');
$results['service_blank_category'] = [
'id' => $service_id,
'rows' => $GLOBALS['wpdb']->rows,
'insert_calls' => $GLOBALS['wpdb']->insert_calls,
];
reset_report_fixture(['提交时的分类', '待删除的分类']);
$first_snapshot_submit = submit_report_case([
'target_type' => 'post', 'target_id' => 61, 'category' => '提交时的分类', 'reason' => '',
]);
$second_snapshot_submit = submit_report_case([
'target_type' => 'post', 'target_id' => 62, 'category' => '待删除的分类', 'reason' => '',
]);
$admin_controller = new ReportFixtureController();
\AKSRT\Settings\Settings::$values['ban_preset_reason'] = [['t' => '当前新分类']];
$changed_list = $admin_controller->get_report_list(new WP_REST_Request())->get_data()['data'] ?? [];
\AKSRT\Settings\Settings::$values['ban_preset_reason'] = [];
$removed_list = $admin_controller->get_report_list(new WP_REST_Request())->get_data()['data'] ?? [];
$first_detail = $admin_controller->get_report_detail(new WP_REST_Request(['id' => 1]))->get_data()['data']['report'] ?? [];
$second_detail = $admin_controller->get_report_detail(new WP_REST_Request(['id' => 2]))->get_data()['data']['report'] ?? [];
$results['category_snapshot'] = [
'submit_success' => !empty($first_snapshot_submit->get_data()['success']) && !empty($second_snapshot_submit->get_data()['success']),
'changed_list_categories' => array_column($changed_list['items'] ?? [], 'category'),
'changed_list_options' => array_column($changed_list['options']['categories'] ?? [], 'value'),
'removed_list_categories' => array_column($removed_list['items'] ?? [], 'category'),
'details' => [
(string) ($first_detail['category'] ?? ''),
(string) ($second_detail['category'] ?? ''),
],
];
reset_report_fixture(['人身攻击']);
$GLOBALS['wpdb']->rows[] = [
'id' => 101, 'reporter_id' => 42, 'target_type' => 'user', 'target_id' => 701,
'reason' => '', 'category' => '人身攻击', 'status' => 'pending',
'created_at' => '2026-10-30 11:00:00', 'updated_at' => null, 'notes_json' => '[]',
];
$preset_process = (new ReportFixtureController())->process_report(new WP_REST_Request([
'id' => 101, 'status' => 'resolved', 'action_taken' => 'warn', 'note' => '',
]));
$preset_row = $GLOBALS['wpdb']->rows[0];
$results['process_preset_only'] = [
'success' => !empty($preset_process->get_data()['success']),
'restriction' => \AKSRT\User\Restriction::$calls[0] ?? [],
'report_reason' => $preset_row['reason'],
'report_category' => $preset_row['category'],
];
reset_report_fixture();
$custom_reason = '自定义说明不能丢';
$GLOBALS['wpdb']->rows[] = [
'id' => 102, 'reporter_id' => 42, 'target_type' => 'user', 'target_id' => 702,
'reason' => $custom_reason, 'category' => '其他原因', 'status' => 'pending',
'created_at' => '2026-10-30 11:00:00', 'updated_at' => null, 'notes_json' => '[]',
];
$other_process = (new ReportFixtureController())->process_report(new WP_REST_Request([
'id' => 102, 'status' => 'resolved', 'action_taken' => 'warn', 'note' => '',
]));
$other_row = $GLOBALS['wpdb']->rows[0];
$results['process_other_with_details'] = [
'success' => !empty($other_process->get_data()['success']),
'restriction' => \AKSRT\User\Restriction::$calls[0] ?? [],
'report_reason' => $other_row['reason'],
'report_category' => $other_row['category'],
];
echo json_encode($results, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
@@ -0,0 +1,182 @@
'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 harness = path.join(__dirname, 'report-submit-harness.php');
let cachedResults;
function results() {
if (cachedResults) return cachedResults;
const run = spawnSync(process.env.PHP_BINARY || 'php', [harness], {
cwd: root,
encoding: 'utf8',
});
assert.ifError(run.error);
assert.equal(run.status, 0, `PHP report harness failed:\n${run.stderr}\n${run.stdout}`);
cachedResults = JSON.parse(run.stdout);
return cachedResults;
}
test('stores readable preset category separately from details and evidence', () => {
const result = results().preset_with_evidence;
const reason = '补充详情 https://example.test/uploads/proof.png';
assert.equal(result.response.success, true);
assert.equal(result.rows.length, 1);
assert.equal(result.rows[0].category, '垃圾信息');
assert.equal(result.rows[0].reason, reason);
assert.doesNotMatch(result.rows[0].reason, /^垃圾信息/);
assert.deepEqual(result.claims, [[reason, 42]]);
const hook = result.actions[0][1];
assert.equal(hook.category, '垃圾信息');
assert.equal(hook.reason, reason);
});
test('accepts preset-only reports and includes their category in admin notifications', () => {
const result = results().preset_label_only;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '人身攻击');
assert.equal(result.rows[0].reason, '');
assert.equal(result.messages.length, 1);
assert.match(result.messages[0].content, /人身攻击/);
});
test('accepts the reserved other category only when details are present', () => {
const result = results().other_with_details;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '其他原因');
assert.equal(result.rows[0].reason, '请核查相关资料');
});
test('preserves long configured category labels without truncating them', () => {
const result = results().long_preset_label;
const label = '非常长的预置举报分类名称超过数据库原先四十字符限制并且应完整保留在工单及后台列表当中';
assert.ok(label.length > 40);
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, label);
});
test('uses sanitized category and reason consistently in storage and the submit hook', () => {
const result = results().sanitized_snapshot;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '垃圾信息');
assert.equal(result.rows[0].reason, '补充详情');
assert.deepEqual(result.actions[0][1], {
reporter_id: 42,
target_type: 'post',
target_id: 12,
reason: '补充详情',
category: '垃圾信息',
});
});
test('rejects blank, unknown, and hash-like categories before inserting', () => {
for (const name of ['blank_category', 'unknown_category', 'hash_category']) {
const result = results()[name];
assert.equal(result.response.success, false, name);
assert.equal(result.insert_calls.length, 0, name);
assert.equal(result.rows.length, 0, name);
}
});
test('requires details for other, while authentication and target validation still apply', () => {
const missingOtherDetails = results().missing_other_details;
assert.equal(missingOtherDetails.response.success, false);
assert.equal(missingOtherDetails.insert_calls.length, 0);
const unauthenticated = results().unauthenticated;
assert.equal(unauthenticated.response.success, false);
assert.equal(unauthenticated.status, 401);
assert.equal(unauthenticated.insert_calls.length, 0);
const invalidTarget = results().invalid_target;
assert.equal(invalidTarget.response.success, false);
assert.equal(invalidTarget.insert_calls.length, 0);
});
test('prevents duplicate pending reports without claiming evidence again', () => {
const result = results().duplicate;
assert.equal(result.response.success, false);
assert.equal(result.status, 409);
assert.equal(result.rows.length, 1);
assert.equal(result.insert_calls.length, 0);
assert.deepEqual(result.claims, []);
});
test('does not notify, fire hooks, or claim evidence after an insert failure with stale insert_id', () => {
const result = results().insert_failure;
assert.equal(result.response.success, false);
assert.equal(result.insert_calls.length, 1);
assert.equal(result.rows.length, 0);
assert.deepEqual(result.messages, []);
assert.deepEqual(result.actions, []);
assert.deepEqual(result.claims, []);
});
test('rejects a blank category at the storage service boundary', () => {
const result = results().service_blank_category;
assert.equal(result.id, 0);
assert.equal(result.rows.length, 0);
assert.equal(result.insert_calls.length, 0);
});
test('preserves readable labels through the admin category filter, list, and detail', () => {
const result = results().admin_category;
assert.equal(result.submit_success, true);
assert.equal(result.list_items.length, 1);
assert.equal(result.list_items[0].category, '涉嫌侵权');
assert.ok(result.list_options.some((option) => option.label === '涉嫌侵权' && option.value === '涉嫌侵权'));
assert.equal(result.detail_report.category, '涉嫌侵权');
});
test('stores report categories as text and uses schema version 3.2.0', () => {
const migrator = fs.readFileSync(path.join(root, 'src/Core/DBMigrator.php'), 'utf8');
const tableDefinitions = fs.readFileSync(path.join(root, 'src/Core/DBMigratorPart2.php'), 'utf8');
const reportTable = tableDefinitions.match(/\$sql_report\s*=\s*"([\s\S]*?)";/);
assert.match(migrator, /const SCHEMA_VERSION\s*=\s*'3\.2\.0';/);
assert.ok(reportTable, 'report table definition should exist');
assert.match(reportTable[1], /\bcategory\s+TEXT\s+NOT NULL\b/);
});
test('preserves submitted category snapshots after preset settings change or disappear', () => {
const result = results().category_snapshot;
const expected = ['提交时的分类', '待删除的分类'];
assert.equal(result.submit_success, true);
assert.deepEqual([...result.changed_list_categories].sort(), [...expected].sort());
assert.deepEqual([...result.changed_list_options].sort(), [...expected].sort());
assert.deepEqual([...result.removed_list_categories].sort(), [...expected].sort());
assert.deepEqual(result.details, expected);
});
test('preserves literal zero text and exposes preset names without slugs', () => {
const data = results();
const result = data.falsey_text;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '0');
assert.equal(result.rows[0].reason, '0');
assert.deepEqual(data.runtime_reasons, [{ name: '垃圾信息' }, { name: '人身攻击' }]);
});
test('passes category and details to restriction actions without rewriting report content', () => {
const data = results();
const preset = data.process_preset_only;
assert.equal(preset.success, true);
assert.equal(preset.restriction.type, 'warn');
assert.equal(preset.restriction.reason, '人身攻击');
assert.equal(preset.report_category, '人身攻击');
assert.equal(preset.report_reason, '');
const other = data.process_other_with_details;
assert.equal(other.success, true);
assert.equal(other.restriction.type, 'warn');
assert.equal(other.restriction.reason, '其他原因\n\n自定义说明不能丢');
assert.equal(other.report_category, '其他原因');
assert.equal(other.report_reason, '自定义说明不能丢');
assert.doesNotMatch(other.report_reason, /^其他原因/);
});
@@ -3,7 +3,7 @@
/**
* 工单表结构契约:
* 1) 6 张 aksrt_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
* 2) SCHEMA_VERSION 已升到 3.1.0(dbDelta 只在版本变化时重跑);
* 2) SCHEMA_VERSION 已升到 3.2.0(dbDelta 只在版本变化时重跑);
* 3) aksrt_order_aftersale 已从 schema 与表名映射中彻底移除;
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
*/
@@ -27,7 +27,7 @@ const TABLES = [
test('schema version is bumped so dbDelta re-runs', () => {
const migrator = read('src/Core/DBMigrator.php');
assert.match(migrator, /const SCHEMA_VERSION = '3\.1\.0';/, 'SCHEMA_VERSION must be 3.1.0');
assert.match(migrator, /const SCHEMA_VERSION = '3\.2\.0';/, 'SCHEMA_VERSION must be 3.2.0');
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
// 版本注释要说明每次升版做了什么,否则后人看不懂升版原因。
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);