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, /