Files
wordpress-theme-aksrt/tests/widget-system/asset-ledger-contract.test.cjs
T

457 lines
25 KiB
JavaScript

const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
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('skeleton loading ships one contract and no dead spinner stylesheets', () => {
// The skeleton contract is the shared .placeholder block inside a
// .post_ajax_loader wrapper. The standalone helpers below were never wired
// to any producer and were removed; they must not come back as a second path.
for (const removed of [
'assets/css/frontend/skeleton.css',
'assets/css/frontend/ajax-spinner.css',
'assets/js/waterfall.js',
]) {
assert.equal(fs.existsSync(path.join(root, removed)), false, `${removed} must stay deleted`)
}
const build = fs.readFileSync(path.join(root, 'tools', 'build-public-assets.mjs'), 'utf8')
for (const dropped of ['skeleton.css', 'ajax-spinner.css']) {
assert.doesNotMatch(build, new RegExp(dropped.replace('.', '\\.')))
}
// Dead rule families with no producer in PHP or JS.
const css = sourceFiles(path.join(root, 'assets', 'css'))
.filter((f) => !f.includes('build') && !f.endsWith('.min.css'))
for (const file of css) {
const text = fs.readFileSync(file, 'utf8')
assert.doesNotMatch(text, /\.load-more__(?:btn|spinner)/, `${path.relative(root, file)} must not reintroduce .load-more__*`)
assert.doesNotMatch(text, /\.aksrt-loading__(?:spinner|overlay|inline|text|dot)/, `${path.relative(root, file)} must not reintroduce .aksrt-loading__*`)
assert.doesNotMatch(text, /aksrt-progress-bar/, `${path.relative(root, file)} must not reintroduce .aksrt-progress-bar`)
// A second parallel skeleton system (never wired to any producer).
assert.doesNotMatch(text, /\.aksrt-skeleton/, `${path.relative(root, file)} must not reintroduce .aksrt-skeleton-*`)
assert.doesNotMatch(text, /aksrt-skeleton-shimmer/, `${path.relative(root, file)} must not reintroduce the parallel shimmer keyframes`)
assert.doesNotMatch(text, /--skeleton-(?:base|shine)/, `${path.relative(root, file)} must not reintroduce the unused skeleton tokens`)
}
})
test('skeleton blocks share the card gutter instead of overflowing it', () => {
// .posts-row pulls the container in with a negative margin and hands every
// card a matching gutter. The card rule is scoped
// `.posts-row > *:not(.post_ajax_loader)`, so the loader is excluded from the
// compensation chain and ends up wider than the cards by both gutters.
//
// The relief must land on the skeleton's INNER `.posts-item` — the same
// element and selector the real card uses — never on the outer
// `.post_ajax_loader`. A block skeleton is `> .post_ajax_loader > .mb20 >
// .posts-item.card`, and `.posts-item.card` already carries margin:8px of its
// own: compensating the outer wrapper too would stack 8px + 8px and make the
// card skeleton 8px narrower per side than a real card (the widget card
// layout, which skips .posts-row and relies on the .widget-ajaxpager chain).
const blocks = [
{
file: 'assets/css/main.css',
selector: '.posts-row > .post_ajax_loader .posts-item',
gutters: [8, 5],
},
{
file: 'assets/css/frontend/post-card.css',
selector: '.posts-row > .post_ajax_loader .posts-item',
gutters: [8, 5],
},
]
for (const block of blocks) {
const css = fs.readFileSync(path.join(root, block.file), 'utf8')
// The rule ships a selector group (`.posts-item, .posts-mini`), so match the
// whole group up to its `{` instead of assuming the selector is alone.
const rules = (css.match(
new RegExp(`${block.selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^{]*\\{[^}]*\\}`, 'g')
) || []).map((rule) => rule.replace(/\s+/g, ' '))
// One rule per gutter value in that file (desktop 8px, mobile 5px).
for (const gutter of block.gutters) {
const matched = rules.some(
(rule) =>
rule.includes(`margin-left: ${gutter}px`) &&
rule.includes(`margin-right: ${gutter}px`)
)
assert.ok(
matched,
`${block.file} must give .post_ajax_loader .posts-item the ${gutter}px gutter in "${block.selector}"`
)
}
}
// The widget pagers apply their own negative-margin compensation chain
// (.widget-ajaxpager / .tab-pane), so the skeleton's inner item needs the same
// relief there — again on .posts-item, not on the loader wrapper.
for (const file of ['assets/css/main.css', 'assets/css/frontend/post-card.css']) {
const css = fs.readFileSync(path.join(root, file), 'utf8')
// Cover every style class the renderer can emit (.style-list / .style-card,
// and the tab pane which carries style-list or index-tab).
assert.match(css, /\.widget-main-post\.style-list \.widget-ajaxpager > \.post_ajax_loader \.posts-item/)
assert.match(css, /\.widget-main-post\.style-card \.widget-ajaxpager > \.post_ajax_loader \.posts-item/)
assert.match(css, /\.widget-tab-post \.tab-content > \.tab-pane > \.widget-ajaxpager > \.post_ajax_loader \.posts-item/)
}
// The user-home panel zeroes the container margin and the card gutters, so the
// skeleton's inner item must not pick up any leftover compensation there.
//
// The panel also turns the list into a 2-column grid at >=992px. The loader is
// a direct child of that grid, so without an explicit span it is laid out as a
// single half-width card and the whole skeleton stack collapses into one
// column. It must span every column and re-run the same 2-column template
// internally while loading, so the placeholders line up with the real cards.
const userHome = fs.readFileSync(path.join(root, 'assets/css/routes/user-home.css'), 'utf8')
assert.match(userHome, /\.aksrt-user-home__post-list\[data-view="list"\] > \.post_ajax_loader \.posts-item \{\s*margin-right: 0;\s*margin-left: 0;/)
assert.match(userHome, /\.aksrt-user-home__post-list\[data-view="list"\] > \.post_ajax_loader \{\s*grid-column: 1 \/ -1;/)
assert.match(
userHome,
/\.dashboard-panel\.is-loading \.aksrt-user-home__post-list\[data-view="list"\] > \.post_ajax_loader \{[^}]*display: grid;[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/s
)
})
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 only the reason label and 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, /get_user_meta\([^\n]*'(?:points_record|balance_record)'/)
assert.match(source, /\$row\['value'\]/)
assert.match(source, /\$row\['desc'\]/)
assert.match(source, /\$row\['time'\]/)
assert.match(source, /\$row\['order_num'\]/)
assert.doesNotMatch(source, /ledger_entry|transaction_id|flow_number|withdrawal_number/)
}
})
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('private function credit_commission', 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 recent asset records read the user-meta histories', () => {
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, /'balance_record'/)
assert.match(controller, /'points_record'/)
assert.match(controller, /'balance_value'/)
assert.match(controller, /'points_value'/)
assert.match(page, /用户近期资产记录/)
assert.match(page, /api\.getCreditLog\(params\)/)
assert.doesNotMatch(page, /统一资产账本|transaction_id|operation_key|toFixed|number_format/)
assert.match(page, /Math\.trunc\(raw\)/)
assert.match(page, /padStart\(3, '0'\)/)
})
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'/)
const productTypes = ['READING', 'DOWNLOAD', 'IMAGE', 'VIDEO', 'VIP', 'RECHARGE', 'POINTS']
for (const type of productTypes) assert.match(domain, new RegExp(`ORDER_PRODUCT_${type}\\s*=`))
for (const label of ['付费阅读', '付费下载', '付费图片', '付费视频', '购买会员', '余额充值', '购买积分']) {
assert.ok(domain.includes(label), `missing order product label: ${label}`)
}
const statuses = ['PENDING', 'PAID', '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: ['REFUNDED'],
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.match(repository, /SUM\(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END\) AS paid_legacy/)
assert.match(repository, /'paid' => \(int\) \(\$row\['paid'\] \?\? 0\) \+ \(int\) \(\$row\['paid_legacy'\] \?\? 0\)/)
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', 'refunded', 'closed'\]/)
for (const status of ['pending', 'paid', '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/)
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/)
assert.match(header, /data-aksrt-wallet-value data-aksrt-count-format="cut-count"/)
assert.match(header, /data-aksrt-points-value data-aksrt-count-format="cut-count"/)
assert.match(creditsJs, /element\.dataset\.aksrtCountFormat === 'cut-count' \? formatCutCount\(/)
})
test('header wallet count formatting matches the server cut-count thresholds', () => {
const creditsJs = fs.readFileSync(path.join(root, 'assets', 'js', 'credits.js'), 'utf8')
const formatterSource = creditsJs.match(/function formatCutCount\(value\) \{[\s\S]*?\n\t\}/)
assert.ok(formatterSource, 'credits runtime must define the header cut-count formatter')
const formatCutCount = vm.runInNewContext('(' + formatterSource[0] + ')')
assert.equal(formatCutCount('0.00'), '0')
assert.equal(formatCutCount('9999'), '9999')
assert.equal(formatCutCount('10000'), '1W+')
assert.equal(formatCutCount('12345'), '1.2W+')
assert.equal(formatCutCount('1000000'), '100W+')
})
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*''\)/)
})