Files
wordpress-theme-aksrt/tests/widget-system/balance-charge-modal.test.cjs
T
kate522 0d617d88c9 fix(pay): 余额充值预设档位不再被 custom_price 参数校验拦截
选中任一预设充值档位点「立即支付」,必然弹「无效参数:custom_price」。这是 WP
REST 的参数校验层拦截,请求根本没进业务回调。

原因是 WP REST 的 default 只在「键完全缺失」时生效,键存在但值为空串时 default
不参与,空串会直接送进 pattern 校验:

  'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$']

而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为
''。更隐蔽的变体:后台关掉「自定义充值金额」后模板不再渲染该输入框,customEl 为
null、custom 仍是 '',也就是只放开预设档位的配置下 100% 复现。

该字段只在 package=custom 时被消费,而金额格式与区间的业务裁决早已完整存在于
create_charge_order()(AssetAmount::balance 严格正则 + 溢出检查,再按 custom_limit
校验区间并返回本地化错误)。参数声明上的 pattern 是同一规则的第二份实现,两套口径
不同步即是本次缺陷来源。故本次按「单一口径」收口,不再采用「放宽 pattern + 回调归一化
空串 + 前端改送 '0'」的三处并行写法:

- BalanceServiceRest:custom_price 参数去掉 pattern、default 改为 ''、显式声明
  sanitize_callback;rest_charge_create 改为经声明式 args 用 $request->get_param()
  取值,不再手工重读 JSON 体
- credits.js:selectProduct 选中预设档位时清空自定义金额输入(否则框内残留数字会在
  提交时把 package 顶回 custom,静默覆盖用户刚点的档位);customInput 声明上移,去掉
  「先调用后赋值」的时序依赖
- balance-charge-modal.test.cjs:新增 2 项契约测试 / 6 条断言

清理的死代码:
- rest_charge_create 中手工 get_json_params() 并复刻 sanitize_text_field/sanitize_key
  的一段:与声明的 sanitize_callback 重复,使那些声明形同虚设(实为第二套取值路径)
- credits.js 提交处理器内重复读取 payment_method 的死分支(处理器开头已读取,中间无
  任何代码改动它)

放宽仅限「空串 / 键缺失」这一种情形:package=custom 而金额为空、非法或越界仍一律
400,007、abc、1e3、1.234、负数等仍被 AssetAmount::balance() 拒绝,失败方向仍是收紧。

行为变化(需知悉):
- 金额裁决权归 AssetAmount::balance(),其内部 trim() 使带首尾空格的金额被修剪后接受,
  此前被参数层 pattern 直接拒绝。这是「单一裁决者」的必然结果。
- 点选预设档位会清空自定义金额输入框,是修复该档位被静默覆盖的必要行为。

验证:node --test 全量 372 项 / 372 通过(原 370 项,新增 2 项);PHPUnit 44 项 /
106 断言通过;改动 PHP 文件 php -l 通过。变异验证:把 6 条关键断言跑在修复前的文件
内容上,6/6 转红,证明回归测试拦得住此缺陷。未做视觉验收,弹窗标记与 CSS 未改动。
2026-09-19 11:34:12 +08:00

121 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 余额充值弹窗壳契约(第 19 轮 Zibll 对齐 + 第 21 轮全页不可点击回归护栏)。
*
* 关键约束:本主题的弹窗壳是服务端静态输出,与 Zibll(refresh_modal JS
* 按需创建、且其 CSS 无 bootstrap .modal 基础规则)架构不同。若弹窗壳在
* 静止态没有被显式隐藏,main.css 的 .flex{display:flex}(后加载、与
* bootstrap.min.css 的 .modal{display:none} 同特异性、后者胜)会让
* `class="modal flex jc fade"` 壳永久 display:flex 铺满全屏,叠加 .fade
* 的 opacity:0 形成隐形遮罩 → 整页所有按钮不可点击(2026-09-17 实测故障)。
*
* 因此 credits.css 必须以 (0,2,0) 特异性压制:
* .modal[data-aksrt-charge-modal] { display: none; }
* .modal[data-aksrt-charge-modal].in { display: flex; }
* 展示态由 credits.js 自包含开关的内联 display:flex 驱动(不依赖
* jQuery/Bootstrap 运行时;block 会破坏 .flex.jc 双轴居中——
* .modal.flex .modal-dialog{margin:0} 在 block 下使对话框顶在左上角),
* 关闭后内联样式撤除即回到隐藏规则。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
const wallet = read('inc', 'helpers', 'user-center-renderers', 'credits-wallet.php');
const creditsJs = read('assets', 'js', 'credits.js');
const creditsCss = read('assets', 'css', 'routes', 'user-center', 'credits.css');
const balanceRest = read('src', 'Credits', 'BalanceServiceRest.php');
test('弹窗壳为 Zibll refresh_modal 同构静态壳并带 data 钩子', () => {
assert.match(wallet, /class="modal flex jc fade"[^>]*data-aksrt-charge-modal/);
assert.match(wallet, /data-aksrt-charge-form/);
assert.match(wallet, /data-dismiss="modal"/);
assert.match(wallet, /initiate-pay/);
});
test('弹窗壳静止态必须被 credits.css 显式隐藏(.flex 覆盖回归护栏)', () => {
// (0,2,0) 特异性压制 main.css 的 .flex{display:flex}。
assert.match(creditsCss, /\.modal\[data-aksrt-charge-modal\]\s*\{\s*display:\s*none;/);
// 打开态必须是 flex 而非 block:.modal.flex .modal-dialog{margin:0} 在
// block 下使对话框顶在左上角(无头 Chrome 几何实证),flex 下由壳的
// .flex.jc 双轴居中(Zibll 生产环境即此形态)。
assert.match(creditsCss, /\.modal\[data-aksrt-charge-modal\]\.in\s*\{\s*display:\s*flex;/);
assert.doesNotMatch(creditsCss, /\.modal\[data-aksrt-charge-modal\]\.in\s*\{\s*display:\s*block;/);
// 购买积分弹窗壳同构(服务端静态输出 .modal.flex.jc.fade),静止态同样
// 必须显式隐藏,否则隐形遮罩拦截整页点击(2026-09-18 实测回归)。
const balance = read('inc', 'helpers', 'user-center-renderers', 'credits-balance.php');
assert.match(balance, /class="modal flex jc fade"[^>]*data-aksrt-points-pay-modal/);
assert.match(creditsCss, /\.modal\[data-aksrt-points-pay-modal\]\s*\{\s*display:\s*none;/);
assert.match(creditsCss, /\.modal\[data-aksrt-points-pay-modal\]\.in\s*\{\s*display:\s*flex;/);
});
test('开关自包含:不依赖 jQuery/Bootstrap modal 运行时', () => {
assert.match(creditsJs, /function openChargeModal/);
assert.match(creditsJs, /function closeChargeModal/);
assert.match(creditsJs, /data-aksrt-charge-open/);
// 动态创建遮罩层(bootstrap.min.css 已有 .modal-backdrop 规则)。
assert.match(creditsJs, /modal-backdrop fade in/);
// 充值弹窗开关不得回退到 jQuery modal 运行时(时序异常时弹窗打不开)。
assert.doesNotMatch(creditsJs, /\.modal\(\s*['"]show['"]/);
assert.doesNotMatch(creditsJs, /hidden\.bs\.modal/);
// 打开前移除旧缓存 hidden 标记;关闭走 300ms fade-out 收尾。
assert.match(creditsJs, /removeAttribute\('hidden'\)/);
assert.match(creditsJs, /void modal\.offsetWidth/);
// 打开态内联 display 必须是 flex(block 会破坏 .flex.jc 双轴居中)。
assert.match(creditsJs, /modal\.style\.display = 'flex'/);
assert.doesNotMatch(creditsJs, /modal\.style\.display = 'block'/);
});
/**
* 余额充值提交契约(预设档位被 REST 参数层拦截的回归护栏)。
*
* custom_price 只在 package=custom 时被消费,选中预设档位时前端必带该键且值为
* 空串。WP REST 的 default 仅在「键完全缺失」时生效:键存在而值为空串时 default
* 不参与,空串直接进 pattern 校验 → 请求在参数层就被拦下(rest_invalid_param
* 「无效参数:custom_price」),业务回调根本不执行。
*
* 因此参数声明只保留 type/default/清洗,金额格式与区间的唯一裁决留在
* create_charge_order()(AssetAmount::balance + custom_limit 区间校验)。
*/
test('充值请求参数:custom_price 空串放行,金额格式只由业务层裁决', () => {
const chargeRoute = balanceRest.slice(
balanceRest.indexOf("'/pay/balance/charge'"),
balanceRest.indexOf("'/pay/transfer'"),
);
assert.match(chargeRoute, /'custom_price'\s*=>\s*\['type'\s*=>\s*'string',\s*'default'\s*=>\s*''/);
assert.doesNotMatch(
chargeRoute,
/custom_price'[\s\S]*?'pattern'/,
'custom_price must not carry a second amount rule: the empty string is a valid "not applicable" value',
);
const createCallback = balanceRest.slice(
balanceRest.indexOf('public function rest_charge_create'),
balanceRest.indexOf('public function rest_transfer'),
);
assert.match(createCallback, /\$custom_price\s*=\s*\(string\) \$request->get_param\('custom_price'\)/);
assert.match(createCallback, /\$payment_method\s*=\s*\(string\) \$request->get_param\('payment_method'\)/);
// 手工重读请求体会让声明的 default/sanitize_callback 形同虚设(第二套取值口径)。
assert.doesNotMatch(createCallback, /get_json_params\(\)|->get_params\(\)/);
});
test('充值弹窗:选中预设档位清空自定义金额,提交路径无重复读取', () => {
// 选中预设档位即放弃自定义金额;否则输入框里残留的数字会在提交时按「合法自定义
// 金额」把 package 顶回 custom,用户刚点的档位被静默忽略。
assert.match(creditsJs, /if \(value !== 'custom' && customInput\) \{[\s\S]{0,60}customInput\.value = '';/);
// 支付方式在提交处理器开头已读取,选档位不会改动它,故充值弹窗内只应存在一处读取
// (积分购买弹窗是同构的另一套初始化,各自一处,不在此断言范围)。
const chargeInit = creditsJs.slice(
creditsJs.indexOf('function initBalanceCharge'),
creditsJs.indexOf('function initTransfer'),
);
assert.strictEqual(
[...chargeInit.matchAll(/form\.querySelector\('input\[name="payment_method"\]'\)/g)].length,
1,
'the submit handler must not re-read payment_method (dead duplicate guard)',
);
});