Files
wordpress-theme-aksrt/tests/widget-system/ticket-lifecycle-contract.test.cjs
T
kate522andClaude Opus 5 264015b7e9 feat(admin): streamline ticket management with dialogs
Move on-behalf ticket creation into a header dialog and load ticket handling on demand. Reuse the admin REST write path, preserve drafts and filters, and cover dialog and server boundaries with regression tests.

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

126 lines
6.8 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/UsersMenusTicketsDetail.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 组合。
// 配置入口已从原生后台迁至 SPA「工单中心」,兜底只在这一处。
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'/);
// 原生后台不再维护状态定义,因此不再出现 is_default 判断。
const native = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.ok(!native.includes("!empty(\$row['is_default'])"),
'status definition guards belong to TicketConfigRest only');
// 删除前还要确认没有工单挂在该状态/部门下(守卫在 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'\)/);
});