Files
wordpress-theme-aksrt/tests/widget-system/ticket-schema-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

161 lines
8.7 KiB
JavaScript

'use strict';
/**
* 「工单中心」配置契约:
* 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐;
* 2) SPA 页面已注册(/users/ticket 可达),且挂的是 schemaPage 而非自定义页;
* 3) 运行时 TicketSettings 缺键回退 schema 默认,数值边界被夹取;
* 4) 前台入口三处均已注册:Center.php 面板定义 + sidebar-nav.php 侧栏项 +
* aksrt-user-center.php 的 built_in_tabs 与 view map。
*/
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');
const FIELD_ORDER = [
'ticket_s',
'ticket_num_prefix',
'ticket_notice_open',
'ticket_notice_text',
'ticket_attachment_s',
'ticket_attachment_max',
'ticket_order_link_s',
'ticket_urge_interval',
];
const GROUPS = [
{ key: 'main', title: '工单功能' },
{ key: 'form', title: '提交设置' },
];
test('ticket schema keys match between PHP and SPA JS', () => {
const php = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
const js = read('assets/admin/src/schema/sections/users/ticket.js');
for (const key of FIELD_ORDER) {
assert.ok(php.includes(`'key' => '${key}'`), `PHP schema missing ${key}`);
assert.ok(js.includes(`key: '${key}'`), `JS schema missing ${key}`);
}
// 顺序:两组各自的字段顺序必须一致(前端分组渲染依赖它)。
for (const group of GROUPS) {
const phpGroup = php.slice(php.indexOf(`'key' => '${group.key}'`));
const jsGroup = js.slice(js.indexOf(`key: '${group.key}'`));
const phpKeys = [...phpGroup.matchAll(/'key' => '([a-z_]+)'/g)].map((m) => m[1]);
const jsKeys = [...jsGroup.matchAll(/key: '([a-z_]+)'/g)].map((m) => m[1]);
assert.deepEqual(jsKeys, phpKeys, `group ${group.key} field order drifted`);
}
// 类型逐字段对齐。PHP 里 textarea 带 sanitize_type,取值要跳过它,
// 因此显式匹配 'type' => '...'(前后都是引号边界,不会撞上 sanitize_type)。
for (const key of FIELD_ORDER) {
const phpType = new RegExp(`'key' => '${key}'[^\\]]*'type' => '([a-z]+)'`).exec(php);
const jsType = new RegExp(`key: '${key}'[^}]*?\\btype: '([a-z]+)'`).exec(js);
assert.ok(phpType && jsType, `type not found for ${key}`);
assert.equal(jsType[1], phpType[1], `${key} type drifted`);
}
// sanitize_type 也必须一致:公告内容允许 HTML,两侧都要声明。
assert.ok(php.includes("'key' => 'ticket_notice_text', 'label' => '公告内容', 'type' => 'textarea', 'sanitize_type' => 'html'"),
'PHP notice text must declare sanitize_type html');
assert.ok(js.includes("key: 'ticket_notice_text', label: '公告内容', type: 'textarea', sanitize_type: 'html'"),
'JS notice text must declare sanitize_type html');
});
test('ticket schema is registered as a schema page under users', () => {
const js = read('assets/admin/src/schema/sections/users/ticket.js');
assert.equal(js.includes("key: 'users.ticket'"), true, 'schema key must be users.ticket');
const index = read('assets/admin/src/schema/sections/users/index.js');
assert.ok(index.includes("import { ticketSchema } from './ticket.js'"), 'schema not imported');
assert.ok(/ticket:\s*ticketSchema/.test(index), 'schema not exported in usersSchemas');
const constants = read('assets/admin/src/constants/sections/users.js');
assert.ok(constants.includes("key: 'users-ticket'"), 'menu page key missing');
assert.ok(constants.includes("route: '/users/ticket'"), 'menu route missing');
assert.ok(constants.includes("section: 'ticket'"), 'menu section missing');
assert.ok(constants.includes("canonicalDomain: 'users'"), 'menu canonical domain missing');
// PHP 侧:分区由 SchemaUsers 组装,SchemaUsersTicket 提供内容。
const users = read('src/Admin/Schema/SchemaUsers.php');
assert.ok(users.includes("'ticket' => Users\\SchemaUsersTicket::build()"), 'PHP section not composed');
const ticket = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
assert.ok(ticket.includes("'key' => 'ticket'"), 'PHP section key missing');
assert.ok(ticket.includes("'option_key' => 'aksrt_settings'"), 'PHP option key missing');
});
test('TicketSettings falls back to schema defaults and clamps numerics', () => {
const settings = read('src/Ticket/TicketSettings.php');
// 只取方法体,避免 docblock 与注释里的同形文字参与断言。
const body = settings.slice(settings.indexOf('public static function all()'));
// 缺键回退:enabled 只在显式关闭时为 false(与 schema default true 对齐)。
assert.match(body, /!isset\(\$s\['ticket_s'\]\) \|\| !empty\(\$s\['ticket_s'\]\)/);
assert.match(body, /self::all\(\)\['ticket_num_prefix'\] \?\? 'T'/);
assert.match(body, /self::all\(\)\['ticket_notice_text'\] \?\? ''\)/);
assert.match(body, /self::all\(\)\['ticket_attachment_max'\] \?\? 5\)/);
assert.match(body, /self::all\(\)\['ticket_urge_interval'\] \?\? 15\)/);
// 数值夹取:附件上限 1..9,催单冷却 1..1440。
assert.match(body, /max\(1, min\(9, \(int\)/);
assert.match(body, /max\(1, min\(1440, \(int\)/);
// 公告文案走 kses(后台允许 HTML)。
assert.match(body, /wp_kses_post/);
// 分区名与 schema key 一致(常量在 all() 之前声明,故对整份文件断言)。
assert.match(settings, /const SECTION = 'ticket'/);
assert.match(body, /read_domain\('users', self::SECTION\)/);
});
test('ticket panel is registered across the user-center wiring', () => {
const center = read('src/User/Center.php');
assert.ok(center.includes("'tickets' => ["), 'panel definition missing');
assert.ok(center.includes("'label' => __('工单中心', 'aksrt')"), 'panel label missing');
assert.match(center, /'tickets'\s*=>\s*\[[\s\S]*?'priority' => 49/, 'panel priority should sit next to orders (48)');
// 开关关闭时整块移除。
assert.match(center, /\$ticket_on = class_exists\('\\AKSRT\\Ticket\\TicketSettings'\)/);
assert.match(center, /if \(!\$ticket_on\) \{\s*\n\s*\$order = array_values\(array_filter\(\$order, static fn\(string \$key\): bool => \$key !== 'tickets'\)\);/);
assert.match(center, /if \(\$key === 'tickets' && !\$ticket_on\) \{\s*\n\s*continue;/);
const sidebar = read('inc/helpers/user-center-renderers/sidebar-nav.php');
assert.ok(sidebar.includes("'label' => __('工单中心', 'aksrt')"), 'sidebar label missing');
assert.ok(sidebar.includes("'icon' => 'manual-service'"), 'sidebar icon should reuse the existing headset sprite');
assert.ok(sidebar.includes("'tab' => 'tickets'"), 'sidebar tab key missing');
const helper = read('inc/helpers/aksrt-user-center.php');
assert.ok(helper.includes("'tickets'"), 'built_in_tabs missing tickets');
assert.match(helper, /'tickets'\s*=> 'tickets',/, 'view map missing tickets => tickets');
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
assert.ok(renderer.includes('data-aksrt-ticket-center'), 'panel root hook missing');
assert.ok(renderer.includes('data-aksrt-ticket-create'), 'create button hook missing');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_create')"), 'create captcha mount missing');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_reply')"), 'reply captcha mount missing');
});
test('ticket frontend assets are enqueued only when the panel exists', () => {
const scripts = read('src/Core/ScriptsPart1b.php');
assert.match(scripts, /\$has_tickets_panel = false;/, 'panel flag not initialised');
assert.match(scripts, /\$has_tickets_panel = isset\(\$dashboard_tabs\['tickets'\]\);/, 'panel flag not derived');
assert.ok(scripts.includes("'aksrt-tickets'"), 'ticket CSS handle missing');
assert.ok(scripts.includes("'aksrt-ticket-center'"), 'ticket JS handle missing');
assert.ok(scripts.includes('/assets/js/ticket-center.js'), 'ticket JS path missing');
assert.ok(scripts.includes('/assets/css/routes/tickets.css'), 'ticket CSS path missing');
// 两个入队都必须在 $has_tickets_panel 条件内。
const cssIndex = scripts.indexOf("'aksrt-tickets'");
const jsIndex = scripts.indexOf("'aksrt-ticket-center'");
assert.ok(scripts.lastIndexOf('if ($has_tickets_panel) {', cssIndex) > scripts.indexOf("'aksrt-orders'"),
'ticket CSS must be gated on its own panel flag');
assert.ok(jsIndex > cssIndex, 'ticket JS should be enqueued alongside its CSS');
const localize = read('src/Core/ScriptsPart1c.php');
assert.ok(localize.includes("'ticket' => (function () {"), 'localize key missing');
assert.ok(!localize.includes("'afterSale'"), 'stale afterSale localize must be gone');
});