Files
wordpress-theme-aksrt/tests/widget-system/ticket-lifecycle-contract.test.cjs
T
kate522andClaude Opus 5 d32bdc0581 fix(ticket): register the ticket schema inside the users sections array
/users/ticket loaded in the SPA but the form never did: the console showed
"未知的设置分组: ticket" from /settings/schema/users/ticket. The ticket section
was composed one array level too high in SchemaUsers::build() — as a sibling of
'sections' rather than inside it — so SchemaProvider::get_sections(), which reads
only $schema['sections'], never saw it and the REST handler 404'd.

Every other axis was wired correctly: the route, the menu entry, users/ticket.js
and SchemaUsersTicket itself. That is why only this page failed while sibling
/users/* pages worked. Move the entry inside sections.

The contract test could not catch this because it asserted only that the compose
call appears somewhere in the file. Replace it with a bracket-balanced scan that
locates the sections array bounds and asserts the compose call falls inside, so
nesting mistakes are visible.

Also update the two ticket contract tests that still read the trait from
src/Ticket/TicketRepository-traits/, its pre-flattening path. They assert on the
trait's contents, so they were silently skipping the very file the earlier fatal
was about.

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

124 lines
6.7 KiB
JavaScript

'use strict';
/**
* 工单状态机契约:
* 1) 迁移规则集中在 TicketStatusService::can_transition(),上层不各自判合法性;
* 2) 回复会把工单推到「已回复 / 待回复」,管理员「接收」只能从待接单出发;
* 3) 已完结状态不可再流转,也不可从未接单一步跳到关闭;
* 4) 催单冷却用 transient,关闭走通知;
* 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');
test('transition rules live in one place', () => {
const service = read('src/Ticket/TicketStatusService.php');
const body = service.slice(service.indexOf('public static function can_transition'));
// 目标必须存在;同状态幂等;已完结不可流转;待接单不可直接关闭。
assert.match(body, /if \(\$to === '' \|\| !self::exists\(\$to\)\) \{\s*\n\s*return false;/);
assert.match(body, /if \(\$from === \$to\) \{\s*\n\s*return true;/);
assert.match(body, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
assert.match(body, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
// 上层只调用 can_transition,不复制合法性判断。
for (const file of ['src/Ticket/TicketStatus.php', 'src/Ticket/TicketRestAdmin.php', 'src/Admin/Native/UsersMenusTicketsCenter.php']) {
const src = read(file);
assert.ok(src.includes('can_transition'), `${file} must delegate to can_transition`);
}
});
test('reply and receive drive the documented state changes', () => {
const status = read('src/Ticket/TicketStatus.php');
// 管理员回复 → 已回复;用户回复 → 待回复。
assert.match(status, /\$target = \$author_type === 'admin'\s*\n\s*\? TicketStatusService::KEY_ADMIN_REPLIED\s*\n\s*: TicketStatusService::KEY_USER_REPLIED;/);
// 同状态不重复迁移;已完结工单上的回复不改变状态。
assert.match(status, /if \(\$from === \$target\) \{\s*\n\s*return true;/);
assert.match(status, /if \(TicketStatusService::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
// 「接收」只允许从未接单转处理中。
assert.match(status, /if \(\(string\) \(\$ticket\['status'\] \?\? ''\) !== TicketStatusService::KEY_PENDING\) \{\s*\n\s*return false;/);
assert.match(status, /return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_HANDLING, 'admin', get_current_user_id\(\)\);/);
// 「已解决」落到已关闭。
assert.match(status, /private function resolve_ticket\(int \$ticket_id, array \$ticket\): bool \{\s*\n\s*return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_CLOSED, 'admin', get_current_user_id\(\)\);/);
});
test('closing notifies the counterparty exactly once', () => {
const status = read('src/Ticket/TicketStatus.php');
assert.match(status, /if \(\$to === TicketStatusService::KEY_CLOSED\) \{\s*\n\s*TicketNotifier::closed\(\$ticket, \$actor\);/);
const notifier = read('src/Ticket/TicketNotifier.php');
// 关单双分支:管理员关 → 通知用户;用户关 → 通知跟进人。
assert.match(notifier, /if \(\$closer === 'admin'\) \{/);
assert.match(notifier, /self::send\(\$user_id, 'ticket_closed'/);
assert.match(notifier, /self::send\(\$admin_id, 'ticket_closed'/);
});
test('default status cannot be edited or deleted in the admin surface', () => {
// REST 侧的状态 CRUD 在 TicketConfigRest trait 里,由 TicketRestAdmin 组合。
const config = read('src/Ticket/TicketConfigRest.php');
assert.equal((config.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'both update and delete must honour is_default');
assert.match(config, /'aksrt_ticket_status_default'/);
// 原生后台页同样要挡。
const native = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.equal((native.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'native admin must honour is_default in both paths');
// 删除前还要确认没有工单挂在该状态/部门下(守卫在 TicketConfigRest trait 中)。
assert.match(config, /count_by_status\(\(string\) \$row\['status_key'\]\) > 0/);
assert.match(config, /count_by_type\(\$id\) > 0/);
assert.match(config, /'aksrt_ticket_status_in_use'/);
assert.match(config, /'aksrt_ticket_type_in_use'/);
const repo = read('src/Ticket/TicketRepository.php');
assert.match(repo, /public function count_by_status\(string \$status_key\): int/);
assert.match(repo, /public function count_by_type\(int \$type_id\): int/);
});
test('prereply reorder requires the full id set', () => {
const service = read('src/Ticket/TicketPrereplyService.php');
const body = service.slice(service.indexOf('public static function reorder'));
// 数量不符或含未知 ID 一律拒绝,避免漏排导致顺序错乱。
assert.match(body, /count\(\$ids\) !== count\(\$existing\) \|\| array_diff\(\$ids, \$existing\) !== \[\]/);
assert.match(body, /return false;/);
// 新建时追加到末尾。
assert.match(service, /return \(int\) \$max \+ 1;/);
});
test('ticket numbers are unique and settings-driven', () => {
const number = read('src/Ticket/TicketNumber.php');
assert.match(number, /TicketSettings::number_prefix\(\)/);
assert.match(number, /current_time\('Ymd'\)/);
assert.match(number, /wp_rand\(100000, 999999\)/);
assert.match(number, /if \(!self::exists\(\$candidate\)\) \{\s*\n\s*return \$candidate;/);
// 冲突重试有上限,极端情况追加随机后缀兜底。
assert.match(number, /private const MAX_ATTEMPTS = 12;/);
const settings = read('src/Ticket/TicketSettings.php');
assert.match(settings, /strtoupper\(\$raw\)/);
assert.match(settings, /sanitize_key/);
});
test('notifications go through the message inbox with deep links', () => {
const notifier = read('src/Ticket/TicketNotifier.php');
// 所有通知都走 Message::send(),持久化到 aksrt_notification。
assert.match(notifier, /\\AKSRT\\Messages\\Message::get_instance\(\)->send\(/);
assert.match(notifier, /class_exists\('\\AKSRT\\Messages\\Message'\)/);
// 新建工单通知全体管理员,催单只通知跟进人。
assert.match(notifier, /get_users\(\['role' => 'administrator', 'fields' => 'ID', 'number' => 20\]\)/);
assert.match(notifier, /\$admin_id = \(int\) \(\$ticket\['admin_id'\] \?\? 0\);/);
// 深链分别指向后台工单页与用户中心工单位。
assert.match(notifier, /admin_url\('users\.php\?page=aksrt_tickets'\)/);
assert.match(notifier, /aksrt_get_user_center_url\('tickets'\)/);
});