Files
wordpress-theme-aksrt/tests/admin/ticket-admin-rest-harness.php
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

282 lines
12 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 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' => '<b>ORD-2</b>',
]));
$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";
}