'use strict'; /** * 工单后台(users.php → aksrt_tickets)契约: * 1) 子菜单已注册,trait 已组合,写操作走 admin_post 且每个都带 nonce; * 2) 分页用共享 AdminListPagination,不写死 limit; * 3) 默认状态与「仍有工单」两类删除被拒; * 4) SPA 深链 redirect 指向 users.php?page=aksrt_tickets; * 5) 前端 JS 与 PHP 的 data-* 挂载点对齐。 */ 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'); // 每个动作分支都要有对应处理器。 for (const action of ['status', 'reply', 'type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete', 'note', 'create']) { assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`); } // 表单里 nonce 以 action + id 为单位,删除类操作独立小表单 + 二次确认。 assert.match(center, /wp_nonce_field\('aksrt_ticket_' \. \$action \. '_' \. \$id, '_wpnonce', true, false\)/); assert.match(center, /onsubmit="return confirm\(/); }); 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('config tab edits departments, statuses and prereplies', () => { const config = read('src/Admin/Native/UsersMenusTicketsConfig.php'); assert.match(config, /render_ticket_types_panel/); assert.match(config, /render_ticket_statuses_panel/); assert.match(config, /render_ticket_prereplies_panel/); // 默认状态行不给编辑/删除入口。 assert.match(config, /empty\(\$row\['is_default'\]\)/); // 删除按钮带 confirm,且走独立小表单。 assert.match(config, /ticket_delete_button\('status_delete', 'status_id'/); assert.match(config, /ticket_delete_button\('type_delete', 'type_id'/); assert.match(config, /ticket_delete_button\('prereply_delete', 'prereply_id'/); // 状态徽章颜色来自状态表,不写死。 assert.match(config, /--aksrt-ticket-color:%s/); }); 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'); });