Add message details, per-row actions, selection-aware bulk controls and confirmation dialogs. Merge filters into the list card and link overview statistics to the matching views with site-timezone today filtering. Improve the compose dialog with Unicode character counters, recipient mode segments, role counts and explicit broadcast confirmation verified against the resolved role and recipient count on submission. Split rendering into focused sibling traits and cover rendering, handlers and UI interactions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
7.4 KiB
JavaScript
152 lines
7.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('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('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/);
|
||
});
|