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

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

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

而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为
'',于是必然校验失败。另一个更隐蔽的变体:后台若关掉「自定义充值金额」,模板里
根本不渲染该输入框,customEl 为 null,custom 仍是 '',同样报错——也就是说这个
缺陷在只放开预设档位的配置下 100% 复现。

同文件的积分兑换路径(/pay/balance/points-exchange)早已写成
custom: validCustom ? custom : '0',余额充值这条只是漏了同样的处理。

- BalanceServiceRest:custom_price 的 pattern 改为整体可选分组
  ^(?:(?:0|[1-9]\d*)(?:\.\d{1,2})?)?$,显式放行空串;rest_charge_create 把空串
  归一化为 '0',与参数声明的 default 语义对齐,避免 AssetAmount::balance() 拿到
  空串后抛 balance_decimal_invalid
- credits.js:非自定义金额时显式送 '0',与积分兑换路径写法一致
- 契约测试补 4 条断言:模式可选、允许空串、回调归一化、前端送 '0'

两层都改是有意的:JS 会被浏览器与 CDN 缓存,旧缓存仍会发空串,后端放行兜住;
后端放行则保证任何客户端都不再被这个字段卡住。放宽仅针对空串,金额格式校验
不放松——007、abc、-1、1e3、1.234 与带首尾空格的值仍一律拒绝。

验证:新 pattern 逐例实测,''、0、1、9.99、9999.99 放行,007、abc、-1、1e3、
1.234、带空格全部拒绝;变异验证把 pattern 改回旧写法后该用例立刻转红,证明回归
测试确实拦得住此缺陷;node --test 全量 370 项 / 370 通过;PHPUnit 44 项 / 106
断言通过。断言未直接比对含反斜杠的整串——JS 字面量与 PHP 单引号串的反斜杠层数
不同会静默比对失败,故拆为不含反斜杠的片段。
2026-09-18 23:41:27 +08:00

339 lines
19 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.
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..', '..')
function sourceFiles(dir) {
const out = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'build' || entry.name === 'vendor' || entry.name === 'node_modules') continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) out.push(...sourceFiles(full))
else if (/\.(php|js|vue|cjs)$/.test(entry.name)) out.push(full)
}
return out
}
test('old asset implementations and compatibility aliases are absent', () => {
assert.equal(fs.existsSync(path.join(root, 'src', 'Credits', 'Credits.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src', 'Credits', 'Balance.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src', 'Payment', 'Rebate.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src', 'Admin', 'Native', 'PayRebate.php')), false)
assert.equal(fs.existsSync(path.join(root, 'assets', 'admin', 'src', 'pages', 'payment', 'PaymentRebate.vue')), false)
assert.equal(fs.existsSync(path.join(root, 'assets', 'admin', 'src', 'pages', 'payment', 'components', 'RebateDetailDrawer.vue')), false)
const files = [
...sourceFiles(path.join(root, 'src')),
...sourceFiles(path.join(root, 'inc')),
...sourceFiles(path.join(root, 'assets', 'js')),
...sourceFiles(path.join(root, 'templates')),
]
const forbidden = [
/aksrt_credits(?:\b|_)/,
/['"]aksrt_balance['"]/,
/\bcredit_log\b/,
/\bbalance_log\b/,
/aksrt_credits_(?:added|deducted)/,
/aksrt_balance_(?:added|deducted)/,
/credits_(?:added|deducted)/,
// points_pass_exchange_* 现为正式设置键(卡密兑换积分功能,对齐参考主题命名),
// 由 card-pass-modal-redeem.test.cjs 覆盖,不再作为遗留别名禁用。
/legacy_task_points/,
/transfer_refund|recovery_failed/,
/\bPayRebate\b/,
/\bPaymentRebate\b/,
/\bRebateDetailDrawer\b/,
/\brebate_log\b/,
/\/payment\/rebate(?:\b|\/)/,
/\bpay_rebate\b/,
/aksrtpay_rebate_page/,
]
const violations = []
for (const file of files) {
const text = fs.readFileSync(file, 'utf8')
for (const pattern of forbidden) {
if (pattern.test(text)) violations.push(`${path.relative(root, file)}: ${pattern}`)
}
}
assert.deepEqual(violations, [])
})
test('asset tables are InnoDB and operation keys are unique', () => {
const schema = fs.readFileSync(path.join(root, 'src', 'Core', 'DBMigratorPart2.php'), 'utf8')
for (const table of ['asset_account', 'asset_transaction', 'asset_entry']) {
assert.match(schema, new RegExp(`CREATE TABLE \\{\\$prefix\\}aksrt_${table} \\([\\s\\S]*?\\) ENGINE=InnoDB`))
}
assert.match(schema, /UNIQUE KEY uniq_operation_key \(operation_key\)/)
assert.match(schema, /UNIQUE KEY uniq_transaction_line \(transaction_id, line_no\)/)
})
test('user asset submissions send idempotency keys', () => {
const request = fs.readFileSync(path.join(root, 'assets', 'js', 'core', 'request.js'), 'utf8')
assert.match(request, /createIdempotencyKey/)
for (const file of ['credits.js', 'reward.js', 'vip-purchase-coordinator.js', 'cms-download.js']) {
const source = fs.readFileSync(path.join(root, 'assets', 'js', file), 'utf8')
assert.match(source, /Idempotency-Key/, `${file} must send Idempotency-Key`)
}
})
test('profile asset records show order references and only the changed amount', () => {
for (const file of ['credits-transactions.php', 'credits-wallet-ledger.php']) {
const source = fs.readFileSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', file), 'utf8')
assert.match(source, /\$record_note\s*=\s*\(string\) \$row\['reason'\] === 'points_exchange'[\s\S]*?\(string\) \$row\['ref_key'\]/)
assert.doesNotMatch(source, /uc-transactions__balance|交易 #%d|\$row\['fee_minor'\]|\$row\['ref_id'\]/)
}
})
test('asset record tabs and pagination stay in the DOM without URL state', () => {
const profile = fs.readFileSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', 'profile-credits.php'), 'utf8')
const points = fs.readFileSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', 'credits-transactions.php'), 'utf8')
const wallet = fs.readFileSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', 'credits-wallet-ledger.php'), 'utf8')
const credits = fs.readFileSync(path.join(root, 'assets', 'js', 'credits.js'), 'utf8')
const center = fs.readFileSync(path.join(root, 'src', 'User', 'Center.php'), 'utf8')
assert.match(profile, /data-aksrt-asset-tab="balance"/)
assert.match(profile, /data-aksrt-asset-tab="points"/)
assert.match(points, /aksrt_render_asset_pagination\([^;]+, 'points'\)/)
assert.match(wallet, /aksrt_render_asset_pagination\([^;]+, 'balance'\)/)
assert.doesNotMatch(profile + points + wallet, /\$_GET\[['"](?:wallet_page|credits_page)['"]\]/)
assert.match(credits, /data-aksrt-asset-page/)
assert.match(credits, /user-center\/panel\/credits\?asset=/)
assert.match(center, /'asset'\s*=>\s*\[/)
assert.match(center, /\$context\['asset_request'\]/)
})
test('core asset paths never use floating-point money helpers', () => {
const files = [
'src/Ledger/LedgerCommand.php',
'src/Ledger/LedgerService.php',
'src/Credits/BalanceService.php',
'src/Credits/BalanceServiceCharge.php',
'src/Credits/BalanceServiceRest.php',
'src/Payment/BalanceGateway.php',
'src/Payment/ChargeCard.php',
'src/Payment/CommissionLedger.php',
'src/Payment/Revenue.php',
'src/Payment/Withdrawal.php',
'src/Admin/UserController.php',
'src/Content/PayRest.php',
'src/Content/VipServicePurchase.php',
]
const forbidden = [/\(float\)/, /floatval\s*\(/, /round\s*\(/, /number_format\s*\(/]
const violations = []
for (const relative of files) {
const text = fs.readFileSync(path.join(root, relative), 'utf8')
for (const pattern of forbidden) {
if (pattern.test(text)) violations.push(`${relative}: ${pattern}`)
}
}
assert.deepEqual(violations, [])
})
test('points orders bypass cash commissions and purchase dead paths stay removed', () => {
const commission = fs.readFileSync(path.join(root, 'src', 'Payment', 'CommissionLedger.php'), 'utf8')
const paidStart = commission.indexOf('public function record_paid_order')
const paidEnd = commission.indexOf('public function settle_due', paidStart)
const paidMethod = commission.slice(paidStart, paidEnd)
const pointsGuard = paidMethod.indexOf('AssetType::POINTS->value')
const affiliateGuard = paidMethod.indexOf('Settings::affiliate_enabled()')
assert.ok(paidStart >= 0 && paidEnd > paidStart)
assert.ok(pointsGuard >= 0 && affiliateGuard > pointsGuard)
assert.match(paidMethod, /\$currency\s*=\s*sanitize_key/)
const pay = fs.readFileSync(path.join(root, 'inc', 'helpers', 'aksrt-pay.php'), 'utf8')
const payTypesStart = pay.indexOf('function aksrt_get_pay_types')
const cashMethodsStart = pay.indexOf('function aksrt_list_cash_payment_methods', payTypesStart)
const payTypesMethod = pay.slice(payTypesStart, cashMethodsStart)
assert.ok(payTypesStart >= 0 && cashMethodsStart > payTypesStart)
assert.equal((payTypesMethod.match(/(?:^|\n)\s*return\b/g) || []).length, 1)
const cashier = fs.readFileSync(path.join(root, 'assets', 'js', 'cms-download.js'), 'utf8')
assert.match(cashier, /\[data-action="aksrt-need-purchase"\], \[data-aksrt-pay-content\]/)
assert.doesNotMatch(cashier, /const cashierLink\s*=\s*e\.target\.closest\('\.cashier-link'\)/)
})
test('content preview is read-only while purchase requires an operation key', () => {
const source = fs.readFileSync(path.join(root, 'src', 'Content', 'PayRest.php'), 'utf8')
const previewStart = source.indexOf('public function rest_preview')
const purchaseStart = source.indexOf('public function rest_purchase')
const purchaseEnd = source.indexOf('public function rest_order_status', purchaseStart)
assert.ok(previewStart >= 0 && purchaseStart > previewStart && purchaseEnd > purchaseStart)
assert.doesNotMatch(source.slice(previewStart, purchaseStart), /OperationKey::from_request/)
assert.match(source.slice(purchaseStart, purchaseEnd), /OperationKey::from_request\(\$request, 'content-purchase'/)
})
test('native financial actions and REST adjustments carry UUID operation keys', () => {
const income = fs.readFileSync(path.join(root, 'src', 'Admin', 'Native', 'PayIncome.php'), 'utf8')
const withdrawals = fs.readFileSync(path.join(root, 'src', 'Admin', 'Native', 'PayWithdrawals.php'), 'utf8')
const points = fs.readFileSync(path.join(root, 'src', 'User', 'CreditsAdmin.php'), 'utf8')
const balance = fs.readFileSync(path.join(root, 'src', 'User', 'BalanceAdmin.php'), 'utf8')
const restUsers = fs.readFileSync(path.join(root, 'src', 'Admin', 'UserController.php'), 'utf8')
for (const source of [income, withdrawals, points, balance]) {
assert.match(source, /wp_generate_uuid4\(\)/)
assert.match(source, /wp_is_uuid\(/)
}
assert.match(points, /PHP_INT_MAX/)
assert.match(points, /preg_match\('\/\^-\?/)
assert.match(points, /strcmp\(\$unsigned_delta, \$max_integer\)/)
assert.match(points, /current_user_can\('edit_user', \$user_id\)/)
assert.match(balance, /current_user_can\('edit_user', \$user_id\)/)
assert.match(restUsers, /OperationKey::from_request\(\$request, 'admin-adjustment'/)
assert.match(restUsers, /current_user_can\('edit_user', \$user_id\)/)
})
test('charge cards have no legacy input or metadata aliases', () => {
const source = fs.readFileSync(path.join(root, 'src', 'Payment', 'ChargeCard.php'), 'utf8')
assert.doesNotMatch(source, /\$params\[['"]code['"]\]/)
assert.doesNotMatch(source, /\$data\[['"]code['"]\]/)
assert.doesNotMatch(source, /\$raw\[['"]vip_days['"]\]/)
assert.doesNotMatch(source, /\$raw\[['"]credits['"]\]/)
assert.match(source, /\$params\['password'\]/)
assert.match(source, /\$raw\['days'\]/)
assert.match(source, /\$raw\['points'\]/)
})
test('VIP card redemption is one locked transaction with bounded retry and replay checks', () => {
const source = fs.readFileSync(path.join(root, 'src', 'Payment', 'ChargeCard.php'), 'utf8')
const start = source.indexOf('private function redeem_vip_card')
const end = source.indexOf('private function vip_card_replay_result', start)
const method = source.slice(start, end)
assert.ok(start >= 0 && end > start)
assert.match(method, /for \(\$attempt = 1; \$attempt <= 3; \$attempt\+\+\)/)
assert.match(method, /START TRANSACTION/)
assert.match(method, /FOR UPDATE/)
assert.match(method, /request_key = %s FOR UPDATE/)
assert.match(method, /COMMIT/)
assert.match(method, /ROLLBACK/)
assert.match(method, /\[1205, 1213\]/)
assert.match(method, /vip_card_replay_result/)
})
test('admin ledger keeps cash minor values as decimal strings', () => {
const controller = fs.readFileSync(path.join(root, 'src', 'Admin', 'UserController.php'), 'utf8')
const page = fs.readFileSync(path.join(root, 'assets', 'admin', 'src', 'pages', 'CmsCredits.vue'), 'utf8')
assert.match(controller, /asset_minor_for_response/)
assert.match(controller, /decimal_from_minor/)
assert.doesNotMatch(page, /\/\s*100(?:\D|$)/)
})
test('asset purchases share the order center and refresh every wallet surface', () => {
const domain = fs.readFileSync(path.join(root, 'src', 'Core', 'DomainModel.php'), 'utf8')
const order = fs.readFileSync(path.join(root, 'src', 'Payment', 'Order.php'), 'utf8')
const query = fs.readFileSync(path.join(root, 'src', 'Payment', 'OrderCenterQuery.php'), 'utf8')
const userOrders = fs.readFileSync(path.join(root, 'src', 'Payment', 'UserOrders.php'), 'utf8')
const repository = fs.readFileSync(path.join(root, 'src', 'Payment', 'OrderRepositoryCore.php'), 'utf8')
const adminRepository = fs.readFileSync(path.join(root, 'src', 'Payment', 'OrderRepositoryAdmin.php'), 'utf8')
const payOrders = fs.readFileSync(path.join(root, 'src', 'Admin', 'Native', 'PayOrders.php'), 'utf8')
const paymentRuntime = fs.readFileSync(path.join(root, 'src', 'Settings', 'PaymentRuntime.php'), 'utf8')
const paymentSchema = fs.readFileSync(path.join(root, 'assets', 'admin', 'src', 'schema', 'sections', 'payment', 'knowledge.js'), 'utf8')
const balanceRest = fs.readFileSync(path.join(root, 'src', 'Credits', 'BalanceServiceRest.php'), 'utf8')
const chargeCard = fs.readFileSync(path.join(root, 'src', 'Payment', 'ChargeCard.php'), 'utf8')
const creditsJs = fs.readFileSync(path.join(root, 'assets', 'js', 'credits.js'), 'utf8')
const sidebar = fs.readFileSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', 'sidebar-commerce.php'), 'utf8')
const header = fs.readFileSync(path.join(root, 'inc', 'helpers', 'aksrt-header-userbox.php'), 'utf8')
assert.match(domain, /ORDER_PRODUCT_POINTS\s*=\s*'points'/)
assert.match(domain, /self::ORDER_PRODUCT_POINTS\s*=>\s*__\('积分购买'/)
const statuses = ['PENDING', 'PAID', 'REFUNDING', 'REFUNDED', 'CLOSED']
assert.deepEqual(
[...domain.matchAll(/public const ORDER_STATUS_([A-Z_]+)\s*=\s*'([^']+)'/g)].map((match) => [match[1], match[2]]),
statuses.map((status) => [status, status.toLowerCase()]),
)
assert.deepEqual(
[...order.matchAll(/public const STATUS_([A-Z_]+)\s*=\s*\\AKSRT\\Core\\DomainModel::ORDER_STATUS_([A-Z_]+);/g)].map((match) => [match[1], match[2]]),
statuses.map((status) => [status, status]),
)
for (const status of statuses) {
assert.match(domain, new RegExp(`ORDER_STATUS_${status}\\s*=\\s*'${status.toLowerCase()}'`))
assert.match(order, new RegExp(`STATUS_${status}\\s*=\\s*\\\\AKSRT\\\\Core\\\\DomainModel::ORDER_STATUS_${status}`))
assert.match(query, new RegExp(`STATUS_${status}\\s*=\\s*'${status.toLowerCase()}'`))
}
for (const status of ['CREATED', 'DELIVERED', 'COMPLETED', 'PENDING_REVIEW', 'CANCELLED', 'EXPIRED']) {
assert.doesNotMatch(domain, new RegExp(`ORDER_STATUS_${status}`))
assert.doesNotMatch(order, new RegExp(`STATUS_${status}`))
}
const transitionSource = domain.slice(domain.indexOf('public static function order_transitions'), domain.indexOf('public static function normalize_order_status'))
const transitions = Object.fromEntries(
[...transitionSource.matchAll(/self::ORDER_STATUS_([A-Z_]+)\s*=>\s*\[([\s\S]*?)\],/g)].map((match) => [
match[1],
[...match[2].matchAll(/self::ORDER_STATUS_([A-Z_]+)/g)].map((target) => target[1]),
]),
)
assert.deepEqual(transitions, {
PENDING: ['PAID', 'CLOSED'],
PAID: ['REFUNDING'],
REFUNDING: ['REFUNDED', 'PAID'],
REFUNDED: [],
CLOSED: [],
})
assert.match(userOrders, /return isset\(DomainModel::order_status_labels\(\)\[\$status\]\) \? \$status : ''/)
assert.doesNotMatch(userOrders, /can_open|entitlement_label|resolve_entitlement_url|post_permalink_cache/)
assert.match(repository, /SUM\(CASE WHEN status = %s THEN 1 ELSE 0 END\) AS paid/)
assert.doesNotMatch(repository, /\$completed_statuses|\$completed_placeholders/)
assert.match(adminRepository, /\$valid_statuses = array_keys\(\\AKSRT\\Core\\DomainModel::order_status_labels\(\)\)/)
assert.doesNotMatch(payOrders, /<select name="new_status">/)
assert.doesNotMatch(`${order}\n${repository}\n${userOrders}\n${payOrders}`, /['"]force['"]\s*=>\s*true/)
assert.match(paymentRuntime, /\$status_keys = \['pending', 'paid', 'refunding', 'refunded', 'closed'\]/)
for (const status of ['pending', 'paid', 'refunding', 'refunded', 'closed']) {
assert.match(paymentSchema, new RegExp(`payment_order_status_${status}`))
}
assert.doesNotMatch(paymentSchema, /payment_order_status_(?:completed|cancelled|processing)/)
assert.match(balanceRest, /register_rest_route\('aksrt\/v1', '\/pay\/assets'/)
assert.match(balanceRest, /'product_type'\s*=>\s*\\AKSRT\\Core\\DomainModel::ORDER_PRODUCT_POINTS/)
assert.match(balanceRest, /\$existing_order\s*=\s*\$repo->get_by_request_key\(\$operation_key\)/)
assert.match(balanceRest, /\$order_number\s*=\s*\$repo->allocate_order_number\(\)/)
assert.doesNotMatch(balanceRest, /['"]PX['"]\s*\.|hash\('sha256',\s*\$operation_key\)/)
assert.match(balanceRest, /\['type'\s*=>\s*'order',\s*'key'\s*=>\s*\$order_number\]/)
assert.match(balanceRest, /'order_number'\s*=>\s*\$order_number/)
assert.match(chargeCard, /self::TYPE_CREDITS[\s\S]*?ORDER_PRODUCT_POINTS/)
// 选中预设档位时 custom_price 既可能「键存在但值为空串」(自定义输入框存在但未填),
// 也可能根本不存在于表单(后台关闭了自定义金额)。WP REST 仅在键缺失时应用 default,
// 空串会被送进 pattern 校验并被拒,表现为 rest_invalid_param「无效参数:custom_price」。
// 因此:模式必须放行空串,回调必须把空串归一化为声明的 default,前端必须显式送 '0'。
//
// 注意:不要拿含反斜杠的整串去 includes()——JS 字面量与 PHP 单引号串的转义层数不同,
// 会静默比对失败。这里拆成不含反斜杠的片段断言,语义等价且与转义层数无关。
assert.ok(
balanceRest.includes("'pattern' => '^(?:(?:0|[1-9]"),
'custom_price pattern must be optional (the old bare pattern rejected the empty string)',
)
assert.ok(
balanceRest.includes("{1,2})?)?$'"),
'custom_price pattern must allow the empty string so preset packages pass validation',
)
assert.ok(
balanceRest.includes("if ($custom_price === '') {"),
'rest_charge_create must normalise an empty custom_price to the declared default',
)
assert.ok(
creditsJs.includes("custom_price: validCustom ? custom : '0',"),
'balance charge must send 0 rather than an empty string when a preset package is selected',
)
assert.match(creditsJs, /function refreshAssetBalances\(/)
assert.match(creditsJs, /querySelectorAll\('\[data-aksrt-wallet-value\]'/)
assert.match(creditsJs, /querySelectorAll\('\[data-aksrt-points-value\]'/)
assert.match(sidebar, /data-aksrt-wallet-value/)
assert.match(sidebar, /data-aksrt-points-value/)
assert.match(header, /data-aksrt-wallet-value/)
assert.match(header, /data-aksrt-points-value/)
})
test('recharge completion always credits the ledger and expiry SQL is strict-mode safe', () => {
const repository = fs.readFileSync(path.join(root, 'src', 'Payment', 'OrderRepositoryCore.php'), 'utf8')
const charge = fs.readFileSync(path.join(root, 'src', 'Credits', 'BalanceServiceCharge.php'), 'utf8')
const gateway = fs.readFileSync(path.join(root, 'src', 'Payment', 'Gateway.php'), 'utf8')
assert.match(repository, /\$new_status === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID/)
assert.match(charge, /'status'\s*=>\s*\\AKSRT\\Payment\\Order::STATUS_PAID/)
assert.match(charge, /\\AKSRT\\Payment\\Order::STATUS_PENDING,\s*\\AKSRT\\Payment\\Order::STATUS_PAID/)
assert.doesNotMatch(charge, /Order::STATUS_(?:CREATED|COMPLETED)/)
assert.match(gateway, /ORDER_PRODUCT_RECHARGE[\s\S]*?update_status\(\$order_number, Order::STATUS_PAID/)
assert.doesNotMatch(repository, /expires_at\s*<>\s*''/)
assert.doesNotMatch(repository, /NULLIF\(cancelled_at,\s*''\)/)
})