Files
wordpress-theme-aksrt/tests/admin/ticket-native-render-harness.php
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

167 lines
9.8 KiB
PHP

<?php
/** Runs the real native renderers and query builder without a WordPress installation. */
namespace {
define('ABSPATH', __DIR__);
define('ARRAY_A', 'ARRAY_A');
$GLOBALS['allowed'] = true;
$GLOBALS['valid_nonce'] = true;
function __($text, $domain = '') { return $text; }
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); }
function esc_attr($text) { return esc_html($text); }
function esc_html__($text, $domain = '') { return esc_html($text); }
function esc_attr__($text, $domain = '') { return esc_attr($text); }
function esc_url($url) { return preg_match('~^(https?://|#)~', (string) $url) ? esc_attr($url) : ''; }
function wpautop($text) { return '<p>' . str_replace("\n\n", '</p><p>', $text) . '</p>'; }
function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $text)); }
function sanitize_text_field($text) { return trim(strip_tags((string) $text)); }
function wp_unslash($text) { return stripslashes((string) $text); }
function absint($value) { return abs((int) $value); }
function selected($a, $b, $echo = true) { return (string) $a === (string) $b ? ' selected="selected"' : ''; }
function number_format_i18n($number) { return number_format($number); }
function admin_url($path) { return 'https://example.test/wp-admin/' . $path; }
function rest_url($path) { return 'https://example.test/wp-json/' . $path; }
function get_current_user_id() { return 7; }
function wp_get_current_user() { return (object) ['ID' => 7, 'display_name' => 'Operator']; }
function get_users($args) { return [(object) ['ID' => 7, 'display_name' => 'Operator'], (object) ['ID' => 8, 'display_name' => 'Second']]; }
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . $id); }
function aksrt_get_avatar_url($id, $size) { return 'https://example.test/avatar/' . $id; }
function current_user_can($cap) { return $GLOBALS['allowed']; }
function check_ajax_referer($action, $key, $die) { return $action === 'aksrt_ticket_view' && $key === 'nonce' && $GLOBALS['valid_nonce']; }
function add_query_arg($args, $url, $base = null) {
if (!is_array($args)) { $args = [$args => $url]; $url = $base; }
$parts = parse_url($url);
parse_str($parts['query'] ?? '', $query);
return $parts['scheme'] . '://' . $parts['host'] . ($parts['path'] ?? '') . '?' . http_build_query(array_merge($query, $args));
}
class JsonReply extends \Exception {
public function __construct(public array $payload, public int $status) { parent::__construct(); }
}
function wp_send_json_success($data) { throw new JsonReply(['success' => true, 'data' => $data], 200); }
function wp_send_json_error($data, $status) { throw new JsonReply(['success' => false, 'data' => $data], $status); }
}
namespace AKSRT\Ticket {
class TicketSettings {
public static bool $orders = true;
public static function title_max() { return 100; }
public static function content_max() { return 1000; }
public static function reply_max() { return 500; }
public static function order_link_enabled() { return self::$orders; }
}
class TicketTypeService {
public static bool $empty = false;
public static function all() { return self::$empty ? [] : [['id' => 1, 'name' => 'Support <script>']]; }
}
class TicketStatusService {
public const KEY_PENDING = 'pending';
public const KEY_USER_REPLIED = 'user_replied';
public static function all() {
return [
['status_key' => 'pending', 'name' => '待接单', 'is_closed' => 0, 'color' => '#f0a020'],
['status_key' => 'handling', 'name' => '处理中', 'is_closed' => 0, 'color' => '#18a058'],
['status_key' => 'closed', 'name' => '已关闭', 'is_closed' => 1, 'color' => '#999999'],
['status_key' => 'archived', 'name' => '已归档', 'is_closed' => 1, 'color' => '#999999'],
];
}
public static function map() { return array_column(self::all(), null, 'status_key'); }
public static function can_transition($from, $to) { return $from !== 'closed' && !($from === 'pending' && $to === 'closed'); }
}
class TicketPrereplyService {
public static function all() { return [['content' => 'Preset <script>alert(1)</script>']]; }
}
class TicketNoteService {
public static function for_ticket($id) { return [['admin_name' => 'Admin <img>', 'created_at' => '2026-10-31', 'content' => 'Secret <script>alert(1)</script>']]; }
}
class TicketRepository {
public static array $queries = [];
public static array $counts = [];
public static int $reads = 0;
public static int $replies_read = 0;
public static function get_instance() { static $repo; return $repo ??= new self(); }
public function sample($id = 21) {
return [
'id' => $id, 'ticket_num' => 'TK-000' . $id, 'title' => 'Question <svg onload=alert(1)>',
'user_id' => 12, 'user_name' => 'User <b>x</b>', 'admin_id' => 7, 'admin_name' => 'Operator',
'ticket_type_id' => 1, 'ticket_type_name' => 'Support <script>',
'status' => $id === 99 ? 'closed' : 'handling', 'status_label' => $id === 99 ? '已关闭' : '处理中', 'status_color' => '#18a058', 'is_closed' => $id === 99,
'content' => 'Description <script>alert(1)</script>', 'attachment' => ['https://example.test/image.jpg'],
'post_time' => '2026-10-30 12:00:00', 'last_reply_time' => '', 'post_admin_id' => 7,
'order_number' => 'ORDER-1', 'urge_count' => 1, 'last_urge_time' => '2026-10-31 11:00:00',
];
}
public function find($id) { self::$reads++; return $id === 404 ? null : $this->sample($id); }
public function paginate($args, $admin) {
self::$queries[] = $args;
return ['items' => $args['offset'] > 20 ? [] : [$this->sample()], 'total' => 21];
}
public function statistic($args, $admin) { self::$counts[] = $args; return ['pending' => 2, 'handling' => 4, 'closed' => 6, 'archived' => 3]; }
public function replies($id) {
self::$replies_read++;
return [
['author_type' => 'system', 'content' => '催单', 'created_at' => '2026-10-31'],
['author_type' => 'user', 'author_id' => 12, 'author_name' => 'User', 'content' => 'Reply <script>', 'created_at' => '2026-10-31', 'attachment' => [], 'quote' => ['is_deleted' => true, 'content' => 'Deleted']],
];
}
}
}
namespace AKSRT\Core {
class DBMigrator { public static function table($name) { return 'wp_aksrt_' . $name; } }
}
namespace {
$root = dirname(__DIR__, 2);
foreach (['NativeUi', 'AdminListPagination', 'UsersMenusTicketsList', 'UsersMenusTicketsDialogs', 'UsersMenusTicketsDetail', 'UsersMenusTicketsCenter'] as $file) {
require $root . '/src/Admin/Native/' . $file . '.php';
}
require $root . '/src/Ticket/TicketQuery.php';
class TicketPage {
use \AKSRT\Admin\Native\UsersMenusTicketsCenter;
public const CAPABILITY = 'manage_options';
private function ensure_cap() {}
}
class QueryProbe {
use \AKSRT\Ticket\TicketQuery;
public function where($args) { return $this->ticket_where($args, true); }
public function counts($args) { return $this->ticket_statistic($args, true); }
}
class FakeDb {
public array $params = [];
public string $sql = '';
public function esc_like($text) { return addcslashes($text, '_%\\'); }
public function prepare($sql, $params) { $this->sql = $sql; $this->params = $params; return $sql; }
public function get_results($sql, $format) { return [['status' => 'closed', 'total' => 2]]; }
}
$page = new TicketPage();
$_GET = ['status' => 'handling', 'ticket_type_id' => 1, 's' => 'needle', 'paged' => 99, 'id' => 99];
ob_start();
$page->render_tickets_page();
$html = ob_get_clean();
$initial = ['queries' => \AKSRT\Ticket\TicketRepository::$queries, 'counts' => \AKSRT\Ticket\TicketRepository::$counts, 'detail_reads' => \AKSRT\Ticket\TicketRepository::$reads, 'reply_reads' => \AKSRT\Ticket\TicketRepository::$replies_read];
function view($page, array $args) {
$_GET = $args;
try { $page->ajax_ticket_view(); } catch (JsonReply $reply) { return ['status' => $reply->status] + $reply->payload; }
throw new \RuntimeException('Expected JSON response');
}
$closed = view($page, ['id' => 99, 'status' => 'pending', 's' => 'not in list', 'refresh_list' => 1, 'paged' => 99]);
$open = view($page, ['id' => 21]);
$missing = view($page, ['id' => 404]);
$GLOBALS['allowed'] = false;
$denied = view($page, ['id' => 21]);
$GLOBALS['allowed'] = true;
$GLOBALS['valid_nonce'] = false;
$nonce = view($page, ['id' => 21]);
$GLOBALS['valid_nonce'] = true;
\AKSRT\Ticket\TicketTypeService::$empty = true;
ob_start();
$page->render_tickets_page();
$no_types = ob_get_clean();
\AKSRT\Ticket\TicketTypeService::$empty = false;
\AKSRT\Ticket\TicketSettings::$orders = false;
ob_start();
$page->render_tickets_page();
$no_orders = ob_get_clean();
$GLOBALS['wpdb'] = new FakeDb();
$query = new QueryProbe();
$where = $query->where([]);
$statistic = $query->counts(['status' => ['pending', 'handling', 'closed', 'archived'], 'ticket_type_id' => 1, 'keywords' => 'needle']);
echo json_encode(compact('html', 'initial', 'closed', 'open', 'missing', 'denied', 'nonce', 'no_types', 'no_orders', 'where', 'statistic') + ['count_sql' => $GLOBALS['wpdb']->sql, 'count_params' => $GLOBALS['wpdb']->params], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}