Drop the legacy credits_added message label per the hard-cut rule, switch
three remaining Font Awesome 4 icons to FA5 solid, and align two widget
contracts with the badge rule grouping and renderer list from fc04bf65.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
188 lines
9.4 KiB
JavaScript
188 lines
9.4 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 消息管理后台(users.php → aksrt_messages)契约:
|
||
* 1) 页面由独立 trait 承载,UsersMenus 只做组合与路由注册;
|
||
* 2) 写操作走 admin_post + nonce + 能力校验;
|
||
* 3) SQL 全部在 NotificationAdminQuery 内且经 prepare;
|
||
* 4) 分页用共享 AdminListPagination;
|
||
* 5) 旧实现与死代码已删除。
|
||
*/
|
||
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const test = require('node:test');
|
||
|
||
const root = path.resolve(__dirname, '../..');
|
||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||
|
||
test('messages page is composed from its own trait', () => {
|
||
const menus = read('src/Admin/Native/UsersMenus.php');
|
||
assert.ok(menus.includes('use UsersMenusMessages;'));
|
||
assert.ok(menus.includes("'aksrt_messages'"));
|
||
assert.ok(menus.includes("add_action('admin_post_aksrt_users_send_message', [$this, 'handle_send_message'])"));
|
||
assert.ok(menus.includes("add_action('admin_post_aksrt_users_bulk_messages', [$this, 'handle_bulk_messages'])"));
|
||
assert.ok(menus.includes("add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets'])"));
|
||
// 旧实现不得残留在壳类中。
|
||
assert.doesNotMatch(menus, /function render_messages_page|function handle_send_message|message_sent|message_failed/);
|
||
assert.doesNotMatch(menus, /aksrt_notification/);
|
||
});
|
||
|
||
test('write handlers check capability and nonce', () => {
|
||
const page = read('src/Admin/Native/UsersMenusMessages.php');
|
||
assert.match(page, /function handle_send_message\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_send_message'\);/);
|
||
assert.match(page, /function handle_bulk_messages\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_bulk_messages'\);/);
|
||
const compose = read('src/Admin/Native/UsersMenusMessagesCompose.php');
|
||
const list = read('src/Admin/Native/UsersMenusMessagesList.php');
|
||
assert.ok(page.includes('use UsersMenusMessagesCompose;'));
|
||
assert.ok(page.includes('use UsersMenusMessagesList;'));
|
||
assert.ok(compose.includes("wp_nonce_field('aksrt_users_send_message')"));
|
||
assert.ok(list.includes("wp_nonce_field('aksrt_users_bulk_messages')"));
|
||
// 页面层不直接碰数据库,分页走共享组件。
|
||
assert.doesNotMatch(page + compose + list, /\$wpdb/);
|
||
assert.ok(page.includes('AdminListPagination::render('));
|
||
});
|
||
|
||
test('admin query keeps SQL prepared and bulk size capped', () => {
|
||
const query = read('src/Messages/NotificationAdminQuery.php');
|
||
assert.match(query, /DELETE FROM \{\$table\} WHERE id IN \(\{\$in\}\)", \$ids\)/);
|
||
assert.ok(query.includes("array_fill(0, count($ids), '%d')"));
|
||
assert.ok(query.includes('array_slice($ids, 0, self::MAX_BULK)'));
|
||
assert.ok(query.includes('$wpdb->esc_like($s)'));
|
||
});
|
||
|
||
test('dead notification helpers are removed', () => {
|
||
assert.doesNotMatch(read('inc/modules/user-notifications.php'), /aksrt_notification_type_label/);
|
||
assert.doesNotMatch(read('src/Messages/Message.php'), /get_all_notifications/);
|
||
assert.ok(fs.existsSync(path.join(root, 'assets/css/admin/messages.css')));
|
||
});
|
||
|
||
let rendered;
|
||
function harness() {
|
||
if (!rendered) {
|
||
const result = require('node:child_process').spawnSync('php', [path.join(__dirname, 'message-admin-harness.php')], { encoding: 'utf8' });
|
||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||
rendered = JSON.parse(result.stdout);
|
||
}
|
||
return rendered;
|
||
}
|
||
|
||
test('list toolbar is merged into the table card and inline confirmation is removed', () => {
|
||
const page = read('src/Admin/Native/UsersMenusMessages.php');
|
||
const list = read('src/Admin/Native/UsersMenusMessagesList.php');
|
||
assert.ok(page.indexOf("NativeUi::card_open(__('站内通知'") < page.indexOf('$this->render_message_filter_form'));
|
||
assert.doesNotMatch(page + list, /onclick=|confirm\(|<details|aksrt-native-toolbar/);
|
||
const { table, empty } = harness();
|
||
assert.match(table, /data-aksrt-msg-action="delete" disabled/);
|
||
assert.match(table, /data-aksrt-msg-selection/);
|
||
assert.match(table, /name="period" value="today"/);
|
||
assert.match(empty, /当前筛选条件下没有消息/);
|
||
});
|
||
|
||
test('message row actions live only in a dedicated rightmost operations column', () => {
|
||
const { table, empty } = harness();
|
||
assert.match(table, /<th scope="col" class="column-time">发送时间<\/th><th scope="col" class="column-actions">操作<\/th><\/tr>/);
|
||
const rows = [...table.matchAll(/<tr class="is-(?:unread|read)">([\s\S]*?)<\/tr>/g)];
|
||
assert.equal(rows.length, 2);
|
||
rows.forEach(([, row]) => {
|
||
const cells = [...row.matchAll(/<(?:th|td)\b[^>]*>[\s\S]*?<\/(?:th|td)>/g)].map(([cell]) => cell);
|
||
assert.equal(cells.length, 7);
|
||
assert.match(cells[1], /class="column-title"/);
|
||
assert.doesNotMatch(cells[1], /<button|data-aksrt-msg-view|data-aksrt-msg-action/);
|
||
assert.match(cells[5], /class="column-time"/);
|
||
assert.match(cells[6], /class="column-actions"/);
|
||
assert.match(cells[6], /data-aksrt-msg-view=/);
|
||
assert.match(cells[6], /data-aksrt-msg-action="delete"/);
|
||
});
|
||
assert.match(empty, /colspan="7"/);
|
||
const css = read('assets/css/admin/messages.css');
|
||
assert.match(css, /\.aksrt-messages-admin \.aksrt-messages-admin__table \.column-actions\s*\{[^}]*padding-left:\s*20px;[^}]*padding-right:\s*20px;[^}]*text-align:\s*center;/);
|
||
assert.match(css, /\.aksrt-msg-row-actions\s*\{[^}]*justify-content:\s*center;/);
|
||
});
|
||
|
||
test('details preserve full content, escape HTML and unsafe links, and never mark read', () => {
|
||
const { table, cache_calls } = harness();
|
||
assert.match(table, /<template id="aksrt-msg-detail-8">/);
|
||
assert.match(table, /完整内容(?:完整内容){99}/);
|
||
assert.match(table, /<script>x<\/script>/);
|
||
assert.doesNotMatch(table, /<script>|href="javascript:/);
|
||
assert.match(table, /2026-10-30 11:30/);
|
||
assert.match(table, /查看详情不会改变收件人的已读状态/);
|
||
assert.equal((table.match(/data-aksrt-msg-action="mark_read" data-id=/g) || []).length, 1);
|
||
assert.deepEqual(cache_calls, [[7]]);
|
||
});
|
||
|
||
test('role choices display counts and disable only empty or oversized roles', () => {
|
||
const { compose } = harness();
|
||
assert.match(compose, /value="subscriber" data-count="3"[^>]*>订阅者 · 3 人/);
|
||
assert.match(compose, /value="editor" data-count="501"[^>]* disabled/);
|
||
assert.match(compose, /value="author" data-count="0"[^>]* disabled/);
|
||
assert.match(compose, /value="contributor" data-count="500"[^>]*>贡献者 · 500 人/);
|
||
assert.doesNotMatch(compose, /value="contributor"[^>]* disabled/);
|
||
assert.match(compose, /data-count-limit="191"/);
|
||
assert.match(compose, /data-count-limit="2000"/);
|
||
assert.match(compose, /aria-describedby="aksrt-msg-content-count"/);
|
||
assert.match(compose, /data-aksrt-msg-pane="role" hidden/);
|
||
});
|
||
|
||
test('overview links, status tabs, filter forms and mutations preserve period filtering', () => {
|
||
const data = harness();
|
||
assert.equal(data.filters.period, 'today');
|
||
assert.equal(data.bad_period.period, '');
|
||
assert.equal(data.query_filters.period, 'today');
|
||
assert.match(data.overview, /period=today/);
|
||
assert.match(data.overview, /status=unread/);
|
||
assert.match(data.filter_form, /value="today" selected/);
|
||
assert.match(data.views, /period=today/);
|
||
assert.equal(data.single_delete.redirect.period, 'today');
|
||
assert.equal(data.single_delete.redirect.status, 'unread');
|
||
assert.equal(data.single_delete.redirect.paged, '3');
|
||
});
|
||
|
||
test('today counts and filtered lists share prepared site-timezone bounds including DST', () => {
|
||
const { sql, dst_sql } = harness();
|
||
assert.equal(sql.length, 4);
|
||
sql.forEach(({ sql, params }) => {
|
||
assert.match(sql, /created_at >= %s AND created_at < %s/);
|
||
assert.ok(params.includes('2026-10-29 16:00:00'));
|
||
assert.ok(params.includes('2026-10-30 16:00:00'));
|
||
});
|
||
assert.doesNotMatch(sql[2].sql, /is_read = 0 AND/);
|
||
assert.ok(sql[0].params.includes('%a\\%\\_%'));
|
||
assert.deepEqual(dst_sql[0].params, ['2026-11-01 04:00:00', '2026-11-02 05:00:00']);
|
||
});
|
||
|
||
test('role broadcasts require confirmation of the current role and count on the server', () => {
|
||
const { send, cap_checked, nonce_checked } = harness();
|
||
['unconfirmed', 'changed_count', 'changed_role', 'too_large', 'long_title'].forEach((key) => {
|
||
assert.equal(send[key].sent, 0, key);
|
||
assert.equal(send[key].redirect.aksrt_msg, 'send_failed', key);
|
||
});
|
||
assert.equal(send.confirmed.sent, 3);
|
||
assert.equal(send.users.sent, 1);
|
||
assert.equal(cap_checked, 8);
|
||
assert.equal(nonce_checked.length, 8);
|
||
});
|
||
|
||
test('asset types use Chinese labels in filters, row badges and details', () => {
|
||
const { asset_types, filter_form, asset_table } = harness();
|
||
assert.deepEqual(asset_types, {
|
||
asset_balance_increase: ['余额增加', 'success'],
|
||
asset_balance_decrease: ['余额减少', 'danger'],
|
||
asset_points_increase: ['积分增加', 'success'],
|
||
asset_points_decrease: ['积分减少', 'danger'],
|
||
});
|
||
for (const [type, [label]] of Object.entries(asset_types)) {
|
||
assert.ok(filter_form.includes(`<option value="${type}">${label}</option>`));
|
||
}
|
||
assert.match(asset_table, /class="aksrt-msg-badge is-success"[^>]*>余额增加<\/a>/);
|
||
assert.match(asset_table, /<dt>消息类型<\/dt><dd>余额增加<\/dd>/);
|
||
});
|
||
|
||
test('single row actions cannot accidentally operate on the checked bulk selection', () => {
|
||
const { single_delete } = harness();
|
||
assert.deepEqual(single_delete.sql[0].params, [8]);
|
||
assert.match(single_delete.sql[0].sql, /DELETE FROM .* WHERE id IN/);
|
||
});
|