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

145 lines
6.8 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';
/**
* 工单 REST 权限契约:
* 1) 每条路由都带 permission_callback,没有裸路由;
* 2) 用户侧只能读写自己的工单(find_for_user 命中 user_id);
* 3) 关联订单必须属于本人且为已支付;
* 4) 管理侧全部要求 manage_options;
* 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');
/** 从 register_routes() 源码里抽出所有 register_rest_route 调用的路由路径。 */
function routes(source) {
const out = [];
const re = /register_rest_route\(\s*\$namespace\s*,\s*([^,]+),\s*\[/g;
let m;
while ((m = re.exec(source)) !== null) out.push(m[1].trim());
return out;
}
test('every user-side ticket route declares a permission callback', () => {
const rest = read('src/Ticket/TicketRest.php');
const body = rest.slice(rest.indexOf('public function register_routes()'), rest.indexOf('private function list_args()'));
const paths = routes(body);
assert.ok(paths.length >= 9, `expected at least 9 routes, found ${paths.length}`);
// 每个 register_rest_route 调用体内都必须出现 permission_callback。
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
assert.equal(calls.length, paths.length, 'route call count mismatch');
for (const call of calls) {
assert.ok(call.includes('permission_callback'), `route without permission_callback:\n${call.slice(0, 160)}`);
}
// 用户侧统一走 require_user(登录 + nonce)。
assert.match(body, /'permission_callback' => \[\$this, 'require_user'\]/);
assert.match(rest, /is_user_logged_in\(\)/);
assert.match(rest, /wp_verify_nonce\(\$nonce, 'wp_rest'\)/);
// 附件上传用的是工单专属回调,不是 require_user。
assert.match(rest, /'permission_callback' => \[\$this, 'require_attachment_upload'\]/);
});
test('attachment upload permission is gated on the ticket attachment switch', () => {
const rest = read('src/Ticket/TicketRest.php');
const body = rest.slice(rest.indexOf('public function require_attachment_upload'));
assert.match(body, /TicketSettings::enabled\(\)/);
assert.match(body, /TicketSettings::attachment_enabled\(\)/);
// 复用评论图片上传 handler,不自建上传链路。
assert.match(rest, /callback' => \[\\AKSRT\\Admin\\UserController::class, 'rest_upload_comment_image'\]/);
// 上传路由挂在 /ticket/upload-attachment(与 /report/upload-image 同构)。
assert.match(rest, /'\/ticket\/upload-attachment'/);
});
test('user reads and writes are scoped to the owner', () => {
const rest = read('src/Ticket/TicketRest.php');
const repo = read('src/Ticket/TicketRepository.php');
// 详情 / 回复 / 催单 / 关闭全部经过 find_for_user。
assert.match(repo, /public function find_for_user\(int \$id, int \$user_id\): \?array/);
assert.match(repo, /if \(\$row === null \|\| \(int\) \$row\['user_id'\] !== \$user_id\) \{\s*\n\s*return null;/);
for (const method of ['get_ticket', 'reply', 'urge', 'close']) {
const body = rest.slice(rest.indexOf(`public function ${method}(`));
const next = body.indexOf('public function', 10);
const slice = next === -1 ? body : body.slice(0, next);
assert.match(slice, /find_for_user\(/, `${method} must scope by owner`);
}
// 列表与统计也带 user_id。
assert.match(rest, /'user_id' => get_current_user_id\(\),/);
});
test('linked orders must belong to the caller and be paid', () => {
const rest = read('src/Ticket/TicketRest.php');
const body = rest.slice(rest.indexOf('private function owns_paid_order'));
assert.match(body, /get_user_order\(get_current_user_id\(\), \$order_number\)/);
assert.match(body, /ORDER_STATUS_PAID/);
// 新建时关联订单非法必须拒掉。
assert.match(rest, /if \(\$order_number !== '' && !\$this->owns_paid_order\(\$order_number\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_order'/);
});
test('admin routes all require manage_options', () => {
const admin = read('src/Ticket/TicketRestAdmin.php');
const body = admin.slice(admin.indexOf('public function register_routes()'));
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
assert.ok(calls.length >= 20, `expected a full admin surface, found ${calls.length}`);
for (const call of calls) {
assert.ok(call.includes("'permission_callback' => [\$this, 'require_admin']"),
`admin route without require_admin:\n${call.slice(0, 160)}`);
}
assert.match(admin, /current_user_can\('manage_options'\)/);
// 管理侧命名空间与用户侧隔离。
assert.match(body, /\$namespace = 'aksrt\/v1\/admin';/);
});
test('closed tickets refuse replies and cannot be re-closed', () => {
const rest = read('src/Ticket/TicketRest.php');
const admin = read('src/Ticket/TicketRestAdmin.php');
// 用户侧
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed', __\('工单已关闭,无法回复'/);
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed', __\('工单已关闭,无法催单'/);
// 管理侧
assert.match(admin, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed'/);
// close_ticket 自身也挡已完结(双保险,避免前端绕过 UI 直接 POST)。
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
assert.match(status, /if \(TicketStatusService::is_closed\(\(string\) \(\$ticket\['status'\] \?\? ''\)\)\) \{\s*\n\s*return false;/);
});
test('urge has a cooldown driven by settings', () => {
const rest = read('src/Ticket/TicketRest.php');
const body = rest.slice(rest.indexOf('public function urge('));
assert.match(body, /get_transient\('aksrt_ticket_urge_' \. \$id\)/);
assert.match(body, /set_transient\('aksrt_ticket_urge_' \. \$id, time\(\)/);
assert.match(body, /TicketSettings::urge_interval\(\) \* MINUTE_IN_SECONDS/);
});
test('quoted replies must belong to the same ticket', () => {
for (const file of ['src/Ticket/TicketRest.php', 'src/Ticket/TicketRestAdmin.php']) {
const src = read(file);
assert.match(src, /if \(\$quoted === null \|\| \(int\) \$quoted\['ticket_id'\] !== \$id\) \{\s*\n\s*\$quote_id = 0;/,
`${file} must drop cross-ticket quotes`);
}
const repo = read('src/Ticket/TicketRepository.php');
// 自引用同样要挡掉,否则时间线会出现循环引用。
assert.match(repo, /if \(\$quote_id === \$self_id\) \{\s*\n\s*return null;/);
// 被引用的回复已删除时返回 is_deleted 标记,界面显示占位而不是空白。
assert.match(repo, /'is_deleted' => true,/);
});