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 Jio\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 Jio\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 { \Jio\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], ]; \Jio\Ticket\TicketRepository::$creates = []; \Jio\Ticket\TicketRepository::$updates = []; \Jio\Ticket\TicketNotifier::$closed = []; \Jio\Ticket\TicketNotifier::$user_replied = []; \Jio\Ticket\TicketNoteService::$created = []; \Jio\Ticket\TicketSettings::$order_link_enabled = false; $GLOBALS['ticket_rest_user_can_calls'] = []; }; $admin = new \Jio\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 = \Jio\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) === 'jio_ticket_type'); $check('非法部门不写入任何字段', \Jio\Ticket\TicketRepository::$updates === []); // Existing assignees may lose admin capability; retaining the same ID isn't a new assignment. $reset(); \Jio\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 = \Jio\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) === 'jio_ticket_admin'); $check('管理员校验失败时部门也不被部分写入', \Jio\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) === 'jio_ticket_admin'); $check('不存在的管理员不会触发写入', \Jio\Ticket\TicketRepository::$updates === []); $reset(); \Jio\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) && (\Jio\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(\Jio\Ticket\TicketNotifier::$closed) === 1); // Order association follows the feature switch. $reset(); \Jio\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('关闭订单关联时忽略订单号', (\Jio\Ticket\TicketRepository::$creates[0]['order_number'] ?? null) === ''); \Jio\Ticket\TicketSettings::$order_link_enabled = true; $admin->create_ticket(new \WP_REST_Request([ 'user_id' => 10, 'title' => '代客提交标题', 'ticket_type_id' => 3, 'content' => '这是问题描述内容', 'order_number' => 'ORD-2', ])); $check('开启订单关联时保存已消毒订单号', (\Jio\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 = \Jio\Ticket\TicketNoteService::$created; $check('备注工单不存在时返回 404', $error_code($missing) === 'jio_ticket_missing'); $check('备注拒绝空内容', $error_code($empty) === 'jio_ticket_note_content'); $check('备注拒绝超长内容', $error_code($long) === 'jio_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"; }