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>
282 lines
13 KiB
PHP
282 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* 工单管理 REST harness:直接驱动真实 TicketRestAdmin 回调,无 WordPress / 数据库依赖。
|
|
*
|
|
* Usage: php tests/admin/ticket-admin-rest-harness.php
|
|
*/
|
|
|
|
namespace {
|
|
define('ABSPATH', __DIR__ . '/');
|
|
|
|
final class WP_Error {
|
|
public function __construct(
|
|
public string $code = '',
|
|
public string $message = '',
|
|
public array $data = []
|
|
) {}
|
|
}
|
|
|
|
final class WP_REST_Request {
|
|
public function __construct(private array $params = []) {}
|
|
public function get_param(string $key) { return $this->params[$key] ?? null; }
|
|
public function has_param(string $key): bool { return array_key_exists($key, $this->params); }
|
|
}
|
|
|
|
function __($text, $domain = null): string { return (string) $text; }
|
|
function is_wp_error($value): bool { return $value instanceof WP_Error; }
|
|
function rest_ensure_response($data) { return $data; }
|
|
function sanitize_key($value): string { return strtolower(preg_replace('/[^a-z0-9_\-]/', '', (string) $value)); }
|
|
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
|
|
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
|
|
function absint($value): int { return abs((int) $value); }
|
|
function current_time($type = 'mysql'): string { return '2026-10-31 12:34:56'; }
|
|
function get_current_user_id(): int { return 99; }
|
|
function current_user_can($capability): bool { return $capability === 'manage_options'; }
|
|
function get_userdata($user_id) {
|
|
$user_id = (int) $user_id;
|
|
return in_array($user_id, [10, 11, 12, 99], true) ? (object) ['ID' => $user_id] : false;
|
|
}
|
|
function user_can($user, $capability): bool {
|
|
$GLOBALS['ticket_rest_user_can_calls'][] = [(int) $user, (string) $capability];
|
|
return (int) $user === 11 && $capability === 'manage_options';
|
|
}
|
|
}
|
|
|
|
namespace AKSRT\Ticket {
|
|
final class TicketSettings {
|
|
public static bool $order_link_enabled = false;
|
|
public static function order_link_enabled(): bool { return self::$order_link_enabled; }
|
|
public static function title_max(): int { return 80; }
|
|
public static function content_max(): int { return 1000; }
|
|
public static function reply_max(): int { return 5; }
|
|
}
|
|
|
|
final class TicketStatusService {
|
|
public const KEY_CLOSED = 'closed';
|
|
public const KEY_ADMIN_REPLIED = 'admin_replied';
|
|
private const STATUSES = [
|
|
'pending' => false,
|
|
'handling' => false,
|
|
'admin_replied' => false,
|
|
'closed' => true,
|
|
];
|
|
public static function exists(string $status): bool { return array_key_exists($status, self::STATUSES); }
|
|
public static function is_closed(string $status): bool { return !empty(self::STATUSES[$status]); }
|
|
public static function can_transition(string $from, string $to): bool {
|
|
if (!self::exists($to)) {
|
|
return false;
|
|
}
|
|
if ($from === $to) {
|
|
return true;
|
|
}
|
|
if (self::is_closed($from) || ($from === 'pending' && $to === self::KEY_CLOSED)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
final class TicketTypeService {
|
|
public static function exists(int $id): bool { return in_array($id, [3, 4], true); }
|
|
}
|
|
|
|
final class TicketRepository {
|
|
private static ?self $instance = null;
|
|
public static array $tickets = [];
|
|
public static array $creates = [];
|
|
public static array $updates = [];
|
|
private static int $next_id = 100;
|
|
|
|
public static function get_instance(): self { return self::$instance ??= new self(); }
|
|
public function find(int $id): ?array { return self::$tickets[$id] ?? null; }
|
|
public function update_fields(int $id, array $fields): bool {
|
|
self::$updates[] = ['id' => $id, 'fields' => $fields];
|
|
if (!isset(self::$tickets[$id])) {
|
|
return false;
|
|
}
|
|
self::$tickets[$id] = array_merge(self::$tickets[$id], $fields);
|
|
return true;
|
|
}
|
|
public function create(array $fields): int {
|
|
self::$creates[] = $fields;
|
|
$id = self::$next_id++;
|
|
self::$tickets[$id] = array_merge(['id' => $id], $fields);
|
|
return $id;
|
|
}
|
|
}
|
|
|
|
final class TicketNotifier {
|
|
public static array $closed = [];
|
|
public static array $user_replied = [];
|
|
public static function closed(array $ticket, string $actor): void { self::$closed[] = [$ticket, $actor]; }
|
|
public static function user_replied(array $ticket): void { self::$user_replied[] = $ticket; }
|
|
}
|
|
|
|
final class TicketNumber {
|
|
public static function generate(): string { return 'T20261031123456'; }
|
|
}
|
|
|
|
final class TicketAttachment {
|
|
public static function sanitize($value): array { return is_array($value) ? $value : []; }
|
|
public static function encode(array $value): string { return (string) json_encode($value); }
|
|
}
|
|
|
|
final class TicketNoteService {
|
|
public static array $created = [];
|
|
public static function for_ticket(int $ticket_id): array { return []; }
|
|
public static function create(int $ticket_id, string $content, int $admin_id): int {
|
|
self::$created[] = compact('ticket_id', 'content', 'admin_id');
|
|
return count(self::$created);
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace AKSRT\Attachment {
|
|
final class PendingUploads {
|
|
public static function claim_urls(array $attachment, int $user_id): void {}
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
require_once dirname(__DIR__, 2) . '/src/Ticket/TicketConfigRest.php';
|
|
require_once dirname(__DIR__, 2) . '/src/Ticket/TicketRestAdmin.php';
|
|
|
|
$failures = [];
|
|
$check = static function (string $label, bool $condition) use (&$failures): void {
|
|
if (!$condition) {
|
|
$failures[] = $label;
|
|
}
|
|
};
|
|
$reset = static function (): void {
|
|
\AKSRT\Ticket\TicketRepository::$tickets = [
|
|
1 => ['id' => 1, 'status' => 'handling', 'ticket_type_id' => 3, 'admin_id' => 11],
|
|
2 => ['id' => 2, 'status' => 'closed', 'ticket_type_id' => 3, 'admin_id' => 11],
|
|
3 => ['id' => 3, 'status' => 'handling', 'ticket_type_id' => 3, 'admin_id' => 11],
|
|
];
|
|
\AKSRT\Ticket\TicketRepository::$creates = [];
|
|
\AKSRT\Ticket\TicketRepository::$updates = [];
|
|
\AKSRT\Ticket\TicketNotifier::$closed = [];
|
|
\AKSRT\Ticket\TicketNotifier::$user_replied = [];
|
|
\AKSRT\Ticket\TicketNoteService::$created = [];
|
|
\AKSRT\Ticket\TicketSettings::$order_link_enabled = false;
|
|
$GLOBALS['ticket_rest_user_can_calls'] = [];
|
|
};
|
|
$admin = new \AKSRT\Ticket\TicketRestAdmin();
|
|
$error_code = static fn($value): string => $value instanceof \WP_Error ? $value->code : '';
|
|
|
|
// Explicit admin_id=0 clears assignment and status updates always touch updated_at.
|
|
$reset();
|
|
$response = $admin->set_status(new \WP_REST_Request(['id' => 1, 'status' => 'handling', 'admin_id' => 0]));
|
|
$saved = \AKSRT\Ticket\TicketRepository::$tickets[1] ?? [];
|
|
$check('解除指派接受 admin_id=0', !\is_wp_error($response) && ($saved['admin_id'] ?? null) === 0);
|
|
$check('状态更新写入 updated_at', ($saved['updated_at'] ?? '') === '2026-10-31 12:34:56');
|
|
|
|
// Invalid department returns an explicit error and performs no write.
|
|
$reset();
|
|
$response = $admin->set_status(new \WP_REST_Request(['id' => 1, 'status' => 'handling', 'ticket_type_id' => 999]));
|
|
$check('非法部门返回明确错误', $error_code($response) === 'aksrt_ticket_type');
|
|
$check('非法部门不写入任何字段', \AKSRT\Ticket\TicketRepository::$updates === []);
|
|
|
|
// Existing assignees may lose admin capability; retaining the same ID isn't a new assignment.
|
|
$reset();
|
|
\AKSRT\Ticket\TicketRepository::$tickets[1]['admin_id'] = 12;
|
|
$response = $admin->set_status(new \WP_REST_Request([
|
|
'id' => 1,
|
|
'status' => 'admin_replied',
|
|
'ticket_type_id' => 4,
|
|
'admin_id' => 12,
|
|
]));
|
|
$saved = \AKSRT\Ticket\TicketRepository::$tickets[1] ?? [];
|
|
$check('保留已失去管理员权限的原跟进人仍可更新状态和部门', !\is_wp_error($response)
|
|
&& ($saved['status'] ?? null) === 'admin_replied'
|
|
&& ($saved['ticket_type_id'] ?? null) === 4
|
|
&& ($saved['admin_id'] ?? null) === 12);
|
|
$check('保留原跟进人不视为新指派', $GLOBALS['ticket_rest_user_can_calls'] === []);
|
|
|
|
// A valid department must not be partially persisted when assignment validation fails.
|
|
$reset();
|
|
$response = $admin->set_status(new \WP_REST_Request([
|
|
'id' => 1,
|
|
'status' => 'handling',
|
|
'ticket_type_id' => 4,
|
|
'admin_id' => 12,
|
|
]));
|
|
$check('无 manage_options 的用户不能被指派', $error_code($response) === 'aksrt_ticket_admin');
|
|
$check('管理员校验失败时部门也不被部分写入', \AKSRT\Ticket\TicketRepository::$updates === []);
|
|
$check('目标管理员必须通过 manage_options 校验', in_array([12, 'manage_options'], $GLOBALS['ticket_rest_user_can_calls'], true));
|
|
|
|
$reset();
|
|
$response = $admin->set_status(new \WP_REST_Request([
|
|
'id' => 1,
|
|
'status' => 'handling',
|
|
'admin_id' => 777,
|
|
]));
|
|
$check('不存在的管理员返回明确错误', $error_code($response) === 'aksrt_ticket_admin');
|
|
|
|
|
|
$check('不存在的管理员不会触发写入', \AKSRT\Ticket\TicketRepository::$updates === []);
|
|
|
|
$reset();
|
|
\AKSRT\Ticket\TicketRepository::$tickets[1]['admin_id'] = 12;
|
|
$response = $admin->set_status(new \WP_REST_Request([
|
|
'id' => 1,
|
|
'status' => 'handling',
|
|
'admin_id' => 11,
|
|
]));
|
|
$check('具备 manage_options 的管理员可被指派', !\is_wp_error($response)
|
|
&& in_array([11, 'manage_options'], $GLOBALS['ticket_rest_user_can_calls'], true)
|
|
&& (\AKSRT\Ticket\TicketRepository::$tickets[1]['admin_id'] ?? null) === 11);
|
|
|
|
// Only the first open -> closed transition sends a close notification.
|
|
$reset();
|
|
$first_close = $admin->set_status(new \WP_REST_Request(['id' => 3, 'status' => 'closed']));
|
|
$second_close = $admin->set_status(new \WP_REST_Request(['id' => 3, 'status' => 'closed']));
|
|
$check('首次关闭成功', !\is_wp_error($first_close));
|
|
$check('同状态再次提交不重发关闭通知', count(\AKSRT\Ticket\TicketNotifier::$closed) === 1);
|
|
|
|
// Order association follows the feature switch.
|
|
$reset();
|
|
\AKSRT\Ticket\TicketSettings::$order_link_enabled = false;
|
|
$admin->create_ticket(new \WP_REST_Request([
|
|
'user_id' => 10,
|
|
'title' => '代客提交标题',
|
|
'ticket_type_id' => 3,
|
|
'content' => '这是问题描述内容',
|
|
'order_number' => 'ORD-1',
|
|
]));
|
|
$check('关闭订单关联时忽略订单号', (\AKSRT\Ticket\TicketRepository::$creates[0]['order_number'] ?? null) === '');
|
|
|
|
\AKSRT\Ticket\TicketSettings::$order_link_enabled = true;
|
|
$admin->create_ticket(new \WP_REST_Request([
|
|
'user_id' => 10,
|
|
'title' => '代客提交标题',
|
|
'ticket_type_id' => 3,
|
|
'content' => '这是问题描述内容',
|
|
'order_number' => '<b>ORD-2</b>',
|
|
]));
|
|
$check('开启订单关联时保存已消毒订单号', (\AKSRT\Ticket\TicketRepository::$creates[1]['order_number'] ?? null) === 'ORD-2');
|
|
|
|
// Internal note writes enforce existence, trim, non-empty and configured maximum length.
|
|
$reset();
|
|
$missing = $admin->create_note(new \WP_REST_Request(['id' => 777, 'content' => 'valid']));
|
|
$empty = $admin->create_note(new \WP_REST_Request(['id' => 1, 'content' => " \n\t "]));
|
|
$long = $admin->create_note(new \WP_REST_Request(['id' => 1, 'content' => '123456']));
|
|
$valid = $admin->create_note(new \WP_REST_Request(['id' => 1, 'content' => ' memo ']));
|
|
$created = \AKSRT\Ticket\TicketNoteService::$created;
|
|
$check('备注工单不存在时返回 404', $error_code($missing) === 'aksrt_ticket_missing');
|
|
$check('备注拒绝空内容', $error_code($empty) === 'aksrt_ticket_note_content');
|
|
$check('备注拒绝超长内容', $error_code($long) === 'aksrt_ticket_note_content');
|
|
$check('备注 trim 后保存', !\is_wp_error($valid) && ($created[0]['content'] ?? null) === 'memo');
|
|
$check('无效备注未调用创建服务', count($created) === 1);
|
|
|
|
if ($failures) {
|
|
echo "Ticket admin REST harness FAILED\n";
|
|
foreach ($failures as $failure) {
|
|
echo ' - ' . $failure . "\n";
|
|
}
|
|
exit(1);
|
|
}
|
|
|
|
echo "Ticket admin REST harness passed\n";
|
|
}
|