feat(admin): upgrade invite code management with dialogs
Replace standalone add and export views with accessible dialogs, refine list interactions, and remove obsolete paths and unused counts. Fix filtered totals and reject oversized imports without truncation, with regression coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cd19434d1
commit
a6776ffe0a
8 files changed
+884
-400
No files matched your search
@@ -0,0 +1,198 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8');
|
||||
let rendered;
|
||||
function harness() {
|
||||
if (!rendered) {
|
||||
const result = spawnSync('php', [path.join(__dirname, 'invite-admin-harness.php')], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, `${result.stderr}${result.stdout}`);
|
||||
rendered = JSON.parse(result.stdout);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
function dialog(html, id) {
|
||||
const match = html.match(new RegExp(`<dialog\\b[^>]*\\bid="${id}"[^>]*>[\\s\\S]*?<\\/dialog>`));
|
||||
assert.ok(match, `missing ${id} dialog`);
|
||||
return match[0];
|
||||
}
|
||||
function input(html, name) {
|
||||
const match = html.match(new RegExp(`<input\\b[^>]*\\bname="${name}"[^>]*>`));
|
||||
assert.ok(match, `missing input ${name}`);
|
||||
return match[0];
|
||||
}
|
||||
function fieldset(html, dependency) {
|
||||
const tags = [...html.matchAll(/<fieldset\b[^>]*data-aksrt-inv-dep="([^"]+)"[^>]*>/g)];
|
||||
const found = tags.find(([, value]) => value === dependency);
|
||||
assert.ok(found, `missing conditional fieldset ${dependency}`);
|
||||
return found[0];
|
||||
}
|
||||
function attr(tag, name, value) {
|
||||
assert.match(tag, new RegExp(`\\b${name}="${value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}"`));
|
||||
}
|
||||
function hasBooleanAttr(tag, name) {
|
||||
assert.match(tag, new RegExp(`(?:\\s|^)${name}(?:\\s|>|$)`));
|
||||
}
|
||||
|
||||
test('page renders add and export dialogs beside, not inside, the list form', () => {
|
||||
const { html } = harness();
|
||||
const listMarker = html.indexOf('data-aksrt-inv-list');
|
||||
assert.ok(listMarker > 0, 'list form is rendered');
|
||||
const listStart = html.lastIndexOf('<form', listMarker);
|
||||
const listEnd = html.indexOf('</form>', listMarker);
|
||||
const addStart = html.indexOf('<dialog', html.indexOf('id="aksrt-inv-add"') - 100);
|
||||
const addDialog = dialog(html, 'aksrt-inv-add');
|
||||
const exportDialog = dialog(html, 'aksrt-inv-export');
|
||||
const addIndex = html.indexOf(addDialog);
|
||||
const exportIndex = html.indexOf(exportDialog);
|
||||
assert.ok(listStart >= 0 && listEnd > listStart);
|
||||
assert.ok(listEnd < addIndex, 'the list form closes before the add dialog');
|
||||
assert.ok(addIndex < exportIndex, 'add and export dialogs are rendered as adjacent page siblings');
|
||||
assert.ok(html.indexOf('</dialog>', addIndex) < exportIndex);
|
||||
assert.ok(addStart < 0 || addStart === addIndex, 'dialog extraction begins at its opening tag');
|
||||
assert.doesNotMatch(addDialog, /<form[^>]*>[^]*<dialog/);
|
||||
});
|
||||
|
||||
test('all admin forms carry the expected method, action, and escaped nonce', () => {
|
||||
const { html } = harness();
|
||||
const list = html.slice(html.lastIndexOf('<form', html.indexOf('data-aksrt-inv-list')), html.indexOf('</form>', html.indexOf('data-aksrt-inv-list')) + 7);
|
||||
const add = dialog(html, 'aksrt-inv-add');
|
||||
const exportForm = dialog(html, 'aksrt-inv-export');
|
||||
assert.match(list, /<form method="post" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
|
||||
assert.match(list, /name="action" value="aksrt_invite_delete"/);
|
||||
for (const form of [list, add, exportForm]) {
|
||||
assert.match(form, /name="aksrt_nonce" value="nonce"<script>&"/);
|
||||
}
|
||||
assert.match(add, /<form method="post" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
|
||||
assert.match(add, /name="action" value="aksrt_invite_add"/);
|
||||
assert.match(exportForm, /<form method="get" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
|
||||
assert.match(exportForm, /name="action" value="aksrt_invite_export"/);
|
||||
});
|
||||
|
||||
test('rendered code, notice, user, date, and row values are escaped', () => {
|
||||
const { html } = harness();
|
||||
assert.match(html, /X&Y"<img src=x onerror=alert\(1\)>/);
|
||||
assert.match(html, /<script>date<\/script>/);
|
||||
assert.match(html, /Admin"><script>alert\(1\)<\/script>/);
|
||||
assert.match(html, /owner"><img src=x onerror=alert\(1\)>/);
|
||||
assert.match(html, /Completed/);
|
||||
assert.doesNotMatch(html, /<(?:script|img)\b|javascript:/i);
|
||||
assert.match(html, /<br>/, 'the reward cell preserves the permitted line break');
|
||||
});
|
||||
|
||||
test('conditional form sections start hidden and disabled where inactive', () => {
|
||||
const { html } = harness();
|
||||
const auto = fieldset(html, 'add_type=auto');
|
||||
const importing = fieldset(html, 'add_type=import');
|
||||
const codeLength = fieldset(html, 'auto_top_s=1');
|
||||
const vipTime = fieldset(html, 'vip=1|2');
|
||||
const textOptions = fieldset(html, 'export_format=text');
|
||||
assert.doesNotMatch(auto, /\bhidden\b|\bdisabled\b/);
|
||||
for (const pane of [importing, codeLength, vipTime, textOptions]) {
|
||||
hasBooleanAttr(pane, 'hidden');
|
||||
hasBooleanAttr(pane, 'disabled');
|
||||
}
|
||||
});
|
||||
|
||||
test('numeric fields expose browser constraints and VIP days apply to both levels', () => {
|
||||
const { html } = harness();
|
||||
const quantity = input(html, 'auto_num');
|
||||
attr(quantity, 'min', '1'); attr(quantity, 'max', '1000'); attr(quantity, 'step', '1');
|
||||
hasBooleanAttr(quantity, 'required');
|
||||
const length = input(html, 'auto_rand_password_limit');
|
||||
attr(length, 'min', '4'); attr(length, 'max', '50'); attr(length, 'step', '1');
|
||||
const balance = input(html, 'balance');
|
||||
attr(balance, 'min', '0'); attr(balance, 'step', '0\.01');
|
||||
const points = input(html, 'points');
|
||||
attr(points, 'min', '0'); attr(points, 'step', '1');
|
||||
const days = input(html, 'vip_time');
|
||||
attr(days, 'min', '1'); attr(days, 'step', '1');
|
||||
hasBooleanAttr(days, 'required');
|
||||
assert.match(html, /name="vip" value="1"/);
|
||||
assert.match(html, /name="vip" value="2"/);
|
||||
const vipPane = fieldset(html, 'vip=1|2');
|
||||
const vipPaneStart = html.indexOf(vipPane);
|
||||
assert.match(html.slice(vipPaneStart, html.indexOf('</fieldset>', vipPaneStart)), /name="vip_time"/);
|
||||
});
|
||||
|
||||
test('export UI and service consistently name the spreadsheet format CSV', () => {
|
||||
const { html } = harness();
|
||||
const exportForm = dialog(html, 'aksrt-inv-export');
|
||||
assert.match(exportForm, /name="export_format" value="csv" checked/);
|
||||
assert.match(exportForm, /Excel 表格(\.csv)/);
|
||||
assert.match(exportForm, /文本文档(\.txt)/);
|
||||
const service = read('src', 'Payment', 'InviteCode.php');
|
||||
assert.match(service, /\$_GET\['export_format'\] \?\? 'csv'\) === 'text' \? 'text' : 'csv'/);
|
||||
assert.match(service, /\[__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\), __\('创建时间', 'aksrt'\), __\('更新时间', 'aksrt'\), __\('状态', 'aksrt'\), __\('标识', 'aksrt'\)\]/);
|
||||
assert.match(service, /aksrt-invite-codes\.csv/);
|
||||
assert.doesNotMatch(service, /\bxls\b|export_format[^\n]*xls/);
|
||||
});
|
||||
|
||||
test('search form and filter links preserve sanitized keyword and remark context', () => {
|
||||
const { html } = harness();
|
||||
const marker = html.indexOf('class="aksrt-native-filters"');
|
||||
const start = html.lastIndexOf('<form', marker);
|
||||
const end = html.indexOf('</form>', marker);
|
||||
const form = html.slice(start, end + 7);
|
||||
assert.match(form, /<input type="hidden" name="status" value="used">/);
|
||||
assert.match(form, /<input type="hidden" name="orderby" value="code">/);
|
||||
assert.match(form, /<input type="hidden" name="desc" value="ASC">/);
|
||||
assert.match(form, /<input type="search"[^>]*name="s" value="search alert\(2\) & phrase"/);
|
||||
const statusMatch = html.match(/<a href="([^"]+)"[^>]*>全部<\/a>/);
|
||||
assert.ok(statusMatch, 'status filter link is rendered');
|
||||
const statusUrl = new URL(statusMatch[1].replaceAll('&', '&'));
|
||||
assert.equal(statusUrl.pathname, '/wp-admin/users.php');
|
||||
assert.equal(statusUrl.searchParams.get('status'), 'all');
|
||||
assert.equal(statusUrl.searchParams.get('s'), 'search alert(2) & phrase');
|
||||
assert.equal(statusUrl.searchParams.get('remark'), 'campaign & east');
|
||||
const sortMatch = html.match(/<a class="orderby-but is-active" href="([^"]+)"/);
|
||||
assert.ok(sortMatch, 'active sort link is rendered');
|
||||
const sortUrl = new URL(sortMatch[1].replaceAll('&', '&'));
|
||||
assert.equal(sortUrl.searchParams.get('s'), 'search alert(2) & phrase');
|
||||
assert.equal(sortUrl.searchParams.get('remark'), 'campaign & east');
|
||||
assert.equal(sortUrl.searchParams.get('orderby'), 'code');
|
||||
});
|
||||
|
||||
test('import rejects more than 1000 nonempty rows without truncation', () => {
|
||||
const { over_limit_is_error, leading_blank_imported, import_insert_count } = harness();
|
||||
assert.equal(over_limit_is_error, true);
|
||||
assert.equal(leading_blank_imported, 1000);
|
||||
assert.equal(import_insert_count, 1000, 'the over-limit request inserts nothing; leading blank rows do not count');
|
||||
});
|
||||
|
||||
test('page uses no list/add/export tab route and service returns to the canonical list', () => {
|
||||
const { html, return_url } = harness();
|
||||
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php');
|
||||
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php');
|
||||
assert.doesNotMatch(page + forms + html, /page-title-action|[?&](?:tab|view)=(?:list|add|export)|tab=(?:list|add|export)/);
|
||||
assert.equal(return_url, 'https://example.test/wp-admin/users.php?page=aksrt_invite_codes');
|
||||
assert.doesNotMatch(return_url, /(?:tab|view|add)=/);
|
||||
});
|
||||
|
||||
test('status-only list totals keep their filter and no longer return unused count buckets', () => {
|
||||
const { list_result, row_queries } = harness();
|
||||
assert.equal(Object.hasOwn(list_result, 'counts'), false);
|
||||
const totalQuery = row_queries.find((sql) => /SELECT COUNT\(\*\) AS all_count/.test(sql));
|
||||
assert.ok(totalQuery, 'the service issues a single total-count query');
|
||||
assert.match(totalQuery, /WHERE used_count > 0/);
|
||||
assert.doesNotMatch(totalQuery, /COALESCE|unused_count|used_count AS/);
|
||||
assert.doesNotMatch(read('src', 'Payment', 'InviteCode.php'), /'counts'\s*=>/);
|
||||
});
|
||||
|
||||
test('invite CSS and JS are enqueued only for the invite submenu hook', () => {
|
||||
const { wrong_hook_assets, page_assets } = harness();
|
||||
assert.deepEqual(wrong_hook_assets, []);
|
||||
assert.equal(page_assets.length, 2);
|
||||
assert.deepEqual(page_assets[0].slice(0, 4), [
|
||||
'style', 'aksrt-admin-invite-codes', 'https://example.test/theme/assets/css/admin/invite-codes.css', ['aksrt-admin-native-ui'],
|
||||
]);
|
||||
assert.deepEqual(page_assets[1].slice(0, 4), [
|
||||
'script', 'aksrt-admin-invite-codes', 'https://example.test/theme/assets/js/admin-invite-codes.js', [],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/** WordPress-free render harness for the native invite admin page. */
|
||||
namespace AKSRT\Core {
|
||||
final class DBMigrator {
|
||||
public static function table($key) { return 'wp_aksrt_' . $key; }
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__);
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
$root = dirname(__DIR__, 2);
|
||||
define('AKSRT_DIR', $root);
|
||||
define('AKSRT_URI', 'https://example.test/theme');
|
||||
|
||||
class WP_Error {
|
||||
public $code;
|
||||
public $message;
|
||||
public function __construct($code, $message) { $this->code = $code; $this->message = $message; }
|
||||
public function get_error_message() { return $this->message; }
|
||||
}
|
||||
function is_wp_error($value) { return $value instanceof WP_Error; }
|
||||
function wp_json_encode($value, $options = 0) { return json_encode($value, $options); }
|
||||
function get_current_user_id() { return 7; }
|
||||
function wp_generate_password($length, $special_chars = true, $extra_special_chars = false) { return str_repeat('A', (int) $length); }
|
||||
function __($text, $domain = '') { return $text; }
|
||||
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES | ENT_SUBSTITUTE, '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('/^\s*javascript:/i', (string) $url) ? '' : esc_attr($url); }
|
||||
function admin_url($path = '') { return 'https://example.test/wp-admin/' . ltrim((string) $path, '/'); }
|
||||
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . (int) $id); }
|
||||
function current_user_can($capability) { return $capability === 'edit_users'; }
|
||||
function wp_die($message = '') { throw new RuntimeException((string) $message); }
|
||||
function wp_create_nonce($action) { return 'nonce"<script>&'; }
|
||||
function check_admin_referer($action, $query_arg = '_wpnonce') { $GLOBALS['checked_nonces'][] = [$action, $query_arg]; }
|
||||
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 $text; }
|
||||
function current_time($format) { return $format === 'mysql' ? '2026-10-30 12:00:00' : '20261030120000'; }
|
||||
function number_format_i18n($number) { return number_format((float) $number); }
|
||||
function checked($actual, $expected, $echo = true) { return (string) $actual === (string) $expected ? ' checked="checked"' : ''; }
|
||||
function add_query_arg($key, $value = '', $url = '') {
|
||||
if (is_array($key)) {
|
||||
$args = $key;
|
||||
$url = (string) $value;
|
||||
} else {
|
||||
$args = [$key => $value];
|
||||
if ($url === '') {
|
||||
$url = $_SERVER['REQUEST_URI'] ?? '';
|
||||
}
|
||||
}
|
||||
$parts = parse_url((string) $url) ?: [];
|
||||
parse_str($parts['query'] ?? '', $query);
|
||||
foreach ($args as $name => $item) {
|
||||
$query[$name] = $item;
|
||||
}
|
||||
$base = '';
|
||||
if (isset($parts['scheme'])) $base .= $parts['scheme'] . '://';
|
||||
if (isset($parts['host'])) $base .= $parts['host'];
|
||||
if (isset($parts['port'])) $base .= ':' . $parts['port'];
|
||||
$base .= $parts['path'] ?? '';
|
||||
return $base . ($query ? '?' . http_build_query($query) : '') . (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
|
||||
}
|
||||
function wp_kses($html, $allowed_html) {
|
||||
return preg_replace_callback('/<\/?([a-z][a-z0-9]*)\b[^>]*>/i', static function ($match) {
|
||||
return strtolower($match[1]) === 'br' ? '<br>' : '';
|
||||
}, (string) $html);
|
||||
}
|
||||
function paginate_links($args) { return '<span class="pagination-probe">' . esc_html((string) $args['current']) . '</span>'; }
|
||||
function wp_enqueue_style(...$args) { $GLOBALS['assets'][] = ['style', ...$args]; }
|
||||
function wp_enqueue_script(...$args) { $GLOBALS['assets'][] = ['script', ...$args]; }
|
||||
|
||||
final class FakeWpdb {
|
||||
public $users = 'wp_users';
|
||||
public $prepared = [];
|
||||
public $row_queries = [];
|
||||
public $inserts = 0;
|
||||
public function prepare($sql, ...$params) {
|
||||
$params = count($params) === 1 && is_array($params[0]) ? $params[0] : $params;
|
||||
$this->prepared[] = ['sql' => $sql, 'params' => $params];
|
||||
return $sql;
|
||||
}
|
||||
public function get_var($sql) { return null; }
|
||||
public function insert($table, $data, $formats = null) { $this->inserts++; return 1; }
|
||||
public function get_row($sql, $format = null) {
|
||||
$this->row_queries[] = $sql;
|
||||
return ['all_count' => 2, 'unused_count' => 1, 'used_count' => 1];
|
||||
}
|
||||
public function get_results($sql, $format = null) {
|
||||
if (str_contains($sql, 'FROM wp_aksrt_invite_code_usage u')) {
|
||||
return [['code_id' => 41, 'user_id' => 7, 'used_at' => '2026-10-30 11:00:00', 'display_name' => 'Admin"><script>alert(1)</script>']];
|
||||
}
|
||||
if (str_contains($sql, 'SELECT * FROM wp_aksrt_invite_code')) {
|
||||
return [
|
||||
[
|
||||
'id' => 41,
|
||||
'code' => 'X&Y"<img src=x onerror=alert(1)>',
|
||||
'batch_id' => 'B1',
|
||||
'remark' => 'owner"><img src=x onerror=alert(1)>',
|
||||
'rewards' => '{"points":5,"vip_level":1,"vip_days":7}',
|
||||
'max_uses' => 3,
|
||||
'used_count' => 1,
|
||||
'status' => 'active',
|
||||
'expires_at' => null,
|
||||
'created_at' => '<script>date</script>',
|
||||
'updated_at' => '2026-10-30 11:00:00',
|
||||
],
|
||||
[
|
||||
'id' => 42,
|
||||
'code' => 'CLEAN-CODE',
|
||||
'batch_id' => 'B2',
|
||||
'remark' => '',
|
||||
'rewards' => '{"vip_level":2,"vip_days":5}',
|
||||
'max_uses' => 1,
|
||||
'used_count' => 0,
|
||||
'status' => 'active',
|
||||
'expires_at' => null,
|
||||
'created_at' => '2026-10-29 10:00:00',
|
||||
'updated_at' => '2026-10-29 10:00:00',
|
||||
],
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
public function esc_like($text) { return addcslashes((string) $text, '_%\\'); }
|
||||
}
|
||||
|
||||
$wpdb = new FakeWpdb();
|
||||
require $root . '/src/Admin/Native/NativeUi.php';
|
||||
require $root . '/src/Payment/InviteCode.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusInviteForms.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusInviteCode.php';
|
||||
|
||||
final class InviteAdminProbe {
|
||||
use \AKSRT\Admin\Native\UsersMenusInviteCode;
|
||||
}
|
||||
|
||||
$_GET = [
|
||||
'page' => 'aksrt_invite_codes',
|
||||
'status' => 'used',
|
||||
's' => 'search <script>alert(2)</script> & phrase',
|
||||
'remark' => 'campaign & east',
|
||||
'orderby' => 'code',
|
||||
'desc' => 'ASC',
|
||||
'aksrt_inv_title' => '<script>alert(1)</script>Completed',
|
||||
'aksrt_inv_msg' => '<img src=x onerror=alert(1)>notice',
|
||||
];
|
||||
$probe = new InviteAdminProbe();
|
||||
ob_start();
|
||||
$probe->render_invite_codes_page();
|
||||
$page = ob_get_clean();
|
||||
|
||||
$GLOBALS['assets'] = [];
|
||||
$probe->enqueue_invite_assets('users_page_aksrt_messages');
|
||||
$wrong_hook_assets = $GLOBALS['assets'];
|
||||
$probe->enqueue_invite_assets('users_page_aksrt_invite_codes');
|
||||
$page_assets = $GLOBALS['assets'];
|
||||
|
||||
$service = \AKSRT\Payment\InviteCode::get_instance();
|
||||
$guard = new \ReflectionMethod($service, 'guard_and_redirect');
|
||||
$list_result = $service->list_codes(['status' => 'used']);
|
||||
$import_lines = [];
|
||||
for ($i = 1; $i <= 1000; $i++) {
|
||||
$import_lines[] = sprintf('CODE%05d 无', $i);
|
||||
}
|
||||
$over_limit_result = $service->import_codes(['data' => implode("\n", array_merge($import_lines, ['OVERLIMIT 无']))]);
|
||||
$blank_plus_limit_result = $service->import_codes(['data' => "\n" . implode("\n", $import_lines)]);
|
||||
$return_url = $guard->invoke($service);
|
||||
|
||||
echo json_encode([
|
||||
'html' => $page,
|
||||
'wrong_hook_assets' => $wrong_hook_assets,
|
||||
'page_assets' => $page_assets,
|
||||
'return_url' => $return_url,
|
||||
'list_result' => $list_result,
|
||||
'row_queries' => $wpdb->row_queries,
|
||||
'over_limit_is_error' => is_wp_error($over_limit_result),
|
||||
'leading_blank_imported' => is_wp_error($blank_plus_limit_result) ? null : $blank_plus_limit_result['imported'],
|
||||
'import_insert_count' => $wpdb->inserts,
|
||||
'checked_nonces' => $GLOBALS['checked_nonces'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -59,20 +59,25 @@ test('invite-code surfaces: form field, admin page, menus and both schemas', ()
|
||||
assert.match(template, /name="invite_code"/)
|
||||
assert.match(template, /get_invite_code_registration_settings/)
|
||||
|
||||
// 管理页为服务端渲染三视图(列表/添加/导出),通过 page-title-action 切换。
|
||||
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
|
||||
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php')
|
||||
assert.match(page, /render_invite_codes_page/)
|
||||
assert.match(page, /page-title-action/)
|
||||
assert.match(page, /use UsersMenusInviteForms;/)
|
||||
assert.match(page, /render_invite_codes_list\(\$service, \$this_url, \$nonce\)/)
|
||||
assert.match(page, /render_invite_codes_add\(\$nonce\)/)
|
||||
assert.match(page, /render_invite_codes_export\(\$nonce\)/)
|
||||
assert.match(page, /subsubsub/)
|
||||
assert.match(page, /data-clipboard-text/)
|
||||
assert.match(page, /orderby-but/)
|
||||
assert.match(page, /bulk_action/)
|
||||
assert.match(page, /aksrt_invite_delete/)
|
||||
assert.match(page, /aksrt_invite_add/)
|
||||
assert.match(page, /aksrt_invite_export/)
|
||||
assert.match(page, /paginate_links/)
|
||||
assert.match(page, /aksrt_invite_delete/)
|
||||
assert.match(forms, /aksrt_invite_add/)
|
||||
assert.match(forms, /aksrt_invite_export/)
|
||||
// 列表内不再有列表/添加/导出三视图路由;状态筛选仍是独立的列表筛选器。
|
||||
assert.doesNotMatch(page + forms, /page-title-action|[?&](?:tab|view)=(?:add|export)|tab=(?:list|add|export)/)
|
||||
// 管理页不再依赖 REST 与 wp_rest nonce。
|
||||
assert.doesNotMatch(page, /wp_rest|\$\{endpoint\}/)
|
||||
assert.doesNotMatch(page + forms, /wp_rest|\$\{endpoint\}/)
|
||||
|
||||
const menus = read('src', 'Admin', 'Native', 'UsersMenus.php')
|
||||
assert.match(menus, /use UsersMenusInviteCode/)
|
||||
@@ -121,15 +126,19 @@ test('import accepts Zibll-compatible lines with per-code rewards', () => {
|
||||
assert.match(service, /level_integral/)
|
||||
assert.match(service, /vip_time/)
|
||||
assert.match(service, /in_array\(\$reward, \['无', 'none', '0'\]/)
|
||||
// 单次导入上限与重复码不计入成功。
|
||||
assert.match(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
|
||||
// 空行先剔除,超过 1000 个非空行时拒绝,而不是静默截断。
|
||||
assert.match(service, /array_filter\(array_map\('trim', \$lines\), static fn\(\$line\) => \$line !== ''\)/)
|
||||
assert.match(service, /count\(\$lines\) > 1000/)
|
||||
assert.doesNotMatch(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
|
||||
const adminScript = read('assets', 'js', 'admin-invite-codes.js')
|
||||
assert.match(adminScript, /split\(\/\\r\\n\|\\r\|\\n\/\)\.filter\(function \(line\) \{ return line\.trim\(\) !== ''; \}\)/)
|
||||
assert.match(service, /\$exists !== null/)
|
||||
assert.match(service, /\$failed_codes\[\] = \$code/)
|
||||
// 导入走添加视图表单提交(add_type=import),textarea + 分隔符。
|
||||
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
|
||||
assert.match(page, /name="import_data"/)
|
||||
assert.match(page, /name="import_division"/)
|
||||
assert.match(page, /name="add_type"/)
|
||||
// 导入表单由独立 Forms trait 渲染,并走添加操作的 add_type=import。
|
||||
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php')
|
||||
assert.match(forms, /name="import_data"/)
|
||||
assert.match(forms, /name="import_division"/)
|
||||
assert.match(forms, /name="add_type"/)
|
||||
})
|
||||
|
||||
test('per-code remark persists through generate/import and both exports round-trip', () => {
|
||||
@@ -151,10 +160,11 @@ test('per-code remark persists through generate/import and both exports round-tr
|
||||
assert.match(service, /__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\)/)
|
||||
assert.match(service, /\$used > 0 \? __\('已使用', 'aksrt'\) : __\('未使用', 'aksrt'\)/)
|
||||
|
||||
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php')
|
||||
// 自动生成标识字段默认 invita_YmdHis;列表标识列可点击定位。
|
||||
assert.match(forms, /name="auto_remarks"/)
|
||||
assert.match(forms, /invita_' \. current_time\('YmdHis'\)/)
|
||||
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
|
||||
// 添加表单标识字段(默认 invita_YmdHis);列表标识列可点击定位。
|
||||
assert.match(page, /name="auto_remarks"/)
|
||||
assert.match(page, /invita_' \. current_time\('YmdHis'\)/)
|
||||
assert.match(page, /add_query_arg\('remark', \$item\['remark'\], \$this_url\)/)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user