'use strict'; /** * 工单后台(users.php → aksrt_tickets)契约: * 1) 子菜单已注册,trait 已组合,写操作走 admin_post 且每个都带 nonce; * 2) 分页用共享 AdminListPagination,不写死 limit; * 3) 默认状态与「仍有工单」两类删除被拒(兜底在 TicketConfigRest,SPA 工单中心消费); * 4) SPA 深链 redirect 指向 users.php?page=aksrt_tickets; * 5) 前端 JS 与 PHP 的 data-* 挂载点对齐。 * 部门 / 状态 / 预设回复的配置已迁至 SPA「工单中心」,本页只剩工单列表。 */ 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('ticket submenu is registered under users.php', () => { const menus = read('src/Admin/Native/UsersMenus.php'); assert.ok(menus.includes("use UsersMenusTicketsCenter;"), 'trait not composed'); assert.ok(menus.includes("'aksrt_tickets'"), 'submenu slug missing'); assert.ok(menus.includes("'users.php'"), 'submenu must hang off users.php'); assert.ok(menus.includes('[$this, \'render_tickets_page\']'), 'render callback missing'); assert.ok(menus.includes("add_action('admin_post_aksrt_ticket_update', [\$this, 'handle_ticket_update'])"), 'write handler not registered'); // 后台待接单提示。 assert.match(menus, /count_pending_tickets\(\)/); assert.match(menus, /TicketSettings::enabled\(\)/); }); test('write actions are nonce-protected and dispatched by action name', () => { const center = read('src/Admin/Native/UsersMenusTicketsCenter.php'); assert.match(center, /check_admin_referer\('aksrt_ticket_' \. \$action \. '_' \. \$id\);/); assert.ok(center.includes('current_user_can(self::CAPABILITY)'), 'capability check missing'); // 部门 / 状态 / 预设回复已迁至 SPA 工单中心,本页只剩工单自身的四个动作。 for (const action of ['status', 'reply', 'note', 'create']) { assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`); } // nonce 以 action + id 为单位,行内表单各自带自己的 nonce(渲染在 List trait 中)。 // 删除类操作已迁至 SPA(n-dialog 二次确认),本页不再有 onsubmit=confirm 的小表单。 const list = read('src/Admin/Native/UsersMenusTicketsList.php'); for (const hook of [ 'aksrt_ticket_reply_', 'aksrt_ticket_status_', 'aksrt_ticket_note_', ]) { assert.ok(list.includes(hook), `nonce prefix ${hook} missing`); } // Tab 已移除,三个行内表单不再输出 ticket_tab 隐藏域。 assert.ok(!list.includes('ticket_tab'), 'ticket_tab hidden input must be gone'); assert.ok(!center.includes('onsubmit="return confirm('), 'config delete forms must be gone'); }); test('the native page carries no config tab and deep-links the SPA instead', () => { const center = read('src/Admin/Native/UsersMenusTicketsCenter.php'); // Tab 结构已移除:不再有 nav-tab-wrapper,也不读 ?tab=。 assert.ok(!center.includes('nav-tab-wrapper'), 'tab wrapper must be gone'); assert.ok(!center.includes("'config'"), 'config tab must be gone'); assert.ok(!/\$_GET\['tab'\]/.test(center), 'tab param must not be read'); // 配置 Tab 的渲染 trait 与删除按钮 helper 一并移除。 assert.ok(!center.includes('UsersMenusTicketsConfig'), 'config trait must not be composed'); assert.ok(!center.includes('render_ticket_config_tab'), 'config renderer must be gone'); assert.ok(!center.includes('ticket_delete_button'), 'config-only delete helper must be gone'); // 六类配置写操作全部迁至 TicketConfigRest,本页不再分发。 for (const action of ['type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete']) { assert.ok(!center.includes(`case '${action}':`), `config action ${action} must be gone`); } // 页面顶部提供跳往 SPA「工单中心」的快捷入口。 assert.match(center, /admin_url\('admin\.php\?page=aksrt-settings#\/users\/ticket'\)/); assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTicketsConfig.php')), 'the config trait file must be deleted'); }); test('the ticket list paginates through the shared helper', () => { const list = read('src/Admin/Native/UsersMenusTicketsList.php'); assert.ok(list.includes('AdminListPagination::render('), 'shared pagination helper not used'); assert.ok(!/limit => 100/.test(list), 'hard-coded limit must not come back'); // 分页条必须晚于空态分支。 assert.ok( list.indexOf('AdminListPagination::render(') > list.indexOf('暂无工单'), 'pagination must render after the empty-state branch' ); // 每页条数来自常量(常量定义在父 trait UsersMenusTicketsCenter 中)。 const center = read('src/Admin/Native/UsersMenusTicketsCenter.php'); assert.match(center, /private const TICKET_PER_PAGE = 20;/); assert.match(list, /'limit' => self::TICKET_PER_PAGE,/); }); test('detail panel renders replies, quote blocks and internal notes', () => { const list = read('src/Admin/Native/UsersMenusTicketsList.php'); const body = list.slice(list.indexOf('private function render_ticket_detail_panel')); assert.match(body, /TicketNoteService::for_ticket/); assert.match(body, /repo->replies\(\$id\)/); // 引用块:原回复已删除时显示占位。 assert.match(body, /!empty\(\$quote\['is_deleted'\]\) \? __\('原回复已删除', 'aksrt'\)/); // 回复与备注各自独立表单 + nonce。 assert.match(body, /wp_nonce_field\('aksrt_ticket_reply_' \. \$id, '_wpnonce', true, false\)/); assert.match(body, /wp_nonce_field\('aksrt_ticket_note_' \. \$id, '_wpnonce', true, false\)/); // 已关闭工单仍可查看,但不再展示迁移表单之外的写入口由前端按钮控制。 assert.match(body, /aksrt-ticket-detail__closed/); }); test('SPA deep-links the native ticket page instead of hosting it', () => { const router = read('assets/admin/src/router/index.js'); assert.ok(router.includes("{ path: '/users/tickets', title: '工单管理', nativeAdminPath: 'users.php?page=aksrt_tickets' }"), 'NATIVE_OPS_REDIRECTS must point at the native ticket page'); }); test('frontend hooks match the renderer and the JS module', () => { const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php'); const js = read('assets/js/ticket-center.js'); for (const hook of [ 'data-aksrt-ticket-center', 'data-aksrt-ticket-create', 'data-aksrt-ticket-stats', 'data-aksrt-ticket-stat=', 'data-aksrt-ticket-list', 'data-aksrt-ticket-pagination', 'data-aksrt-tickets-search', 'data-aksrt-ticket-filter=', 'data-aksrt-ticket-config', ]) { assert.ok(renderer.includes(hook), `renderer missing ${hook}`); } // JS 侧的挂载点与 REST 路径必须和 PHP 对齐。 for (const hook of [ 'data-aksrt-ticket-center', 'data-aksrt-ticket-create', 'data-aksrt-ticket-stat="', 'data-aksrt-ticket-list', 'data-aksrt-ticket-pagination', 'data-aksrt-tickets-search', '[data-aksrt-ticket-filter]', 'data-aksrt-ticket-view=', 'data-aksrt-ticket-page=', 'data-aksrt-ticket-quote=', 'data-aksrt-ticket-images', ]) { assert.ok(js.includes(hook), `ticket-center.js missing ${hook}`); } assert.ok(js.includes("'/tickets'"), 'list/create path missing'); assert.ok(js.includes("'/tickets/statistic'"), 'statistic path missing'); assert.ok(js.includes("'/tickets/meta'"), 'meta path missing'); assert.ok(js.includes("'/tickets/' + ticketId"), 'detail path missing'); assert.ok(js.includes("'/tickets/' + ticket.id + '/reply'"), 'reply path missing'); assert.ok(js.includes("'/tickets/' + ticket.id + '/urge'"), 'urge path missing'); assert.ok(js.includes("'/tickets/' + ticket.id + '/close'"), 'close path missing'); assert.ok(js.includes("'/ticket/upload-attachment'"), 'upload path missing'); // 人机验证在提交与回复前各过一次。 assert.match(js, /runCaptcha\('ticket_create'\)/); assert.match(js, /runCaptcha\('ticket_reply'\)/); }); test('ticket CSS ships as its own route bundle', () => { const css = read('assets/css/routes/tickets.css'); assert.ok(css.includes('.aksrt-tickets-page'), 'page root rule missing'); assert.ok(css.includes('.aksrt-ticket-badge'), 'status badge rule missing'); assert.ok(css.includes('.aksrt-ticket-detail'), 'detail rule missing'); // 徽章颜色由状态行内变量驱动,不写死。 assert.ok(css.includes('--aksrt-ticket-color'), 'badge color variable missing'); });