Files
wordpress-theme-aksrt/tests/widget-system/ticket-native-admin-contract.test.cjs
T
kate522andClaude Opus 5 dbb7de41e9 feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 23:29:51 +08:00

160 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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');
});