diff --git a/tests/widget-system/after-sale-policy-contract.test.cjs b/tests/widget-system/after-sale-policy-contract.test.cjs index ed2b9ab7..7bc9d4f7 100644 --- a/tests/widget-system/after-sale-policy-contract.test.cjs +++ b/tests/widget-system/after-sale-policy-contract.test.cjs @@ -1,361 +1,379 @@ -'use strict' - -/** - * 「售后策略」功能契约: - * 1) 后台 schema(PHP SchemaPayment after_sale 分区)与 SPA JS schema 的键、顺序、 - * 类型、默认值 1:1 对齐,且 schema 与侧边页均已注册(/payment/after-sale 可达); - * 2) 运行时(PaymentRuntime)缺键回退 schema 默认、HTML 走 kses、时效边界正确; - * 3) 售后工单:images 列、create 图片清洗与上限、上传路由权限、latest_for_order; - * 4) 订单视图模型输出 after_sale 状态(按钮矩阵的唯一数据源); - * 5) 前台:申请/超时效/售后详细三个独立弹窗、modal-mini + 蓝色炫彩头、placeholder 与 - * 图片上传配置化、不可再申请提示原文、订单详情弹窗不再内嵌售后表单(死代码清理)。 - */ - -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 = (file) => fs.readFileSync(path.join(root, file), 'utf8') - -const FIELD_ORDER = [ - 'after_sale_s', - 'after_sale_time_limit', - 'after_sale_expired_text', - 'after_sale_reasons', - 'after_sale_desc', - 'after_sale_materials', - 'after_sale_reason_placeholder', - 'after_sale_img_s', -] - -/** Extract the balanced [...] block assigned to a PHP array key inside a section body. */ -function phpSection(src, sectionKey) { - const start = src.indexOf(`'${sectionKey}' => [`) - assert.notEqual(start, -1, `PHP schema has a ${sectionKey} section`) - const open = src.indexOf('[', start) - let depth = 0 - for (let i = open; i < src.length; i++) { - const ch = src[i] - if (ch === "'" || ch === '"') { - const quote = ch - i++ - while (i < src.length) { - if (src[i] === '\\') { i += 2; continue } - if (src[i] === quote) break - i++ - } - continue - } - if (ch === '[') depth++ - else if (ch === ']') { - depth-- - if (depth === 0) return src.slice(open, i + 1) - } - } - throw new Error(`unbalanced PHP section: ${sectionKey}`) -} - -test('after-sale schema is 1:1 between PHP and SPA', () => { - const php = phpSection(read('src/Admin/Schema/SchemaPayment.php'), 'after_sale') - const js = read('assets/admin/src/schema/sections/payment/afterSale.js') - - assert.match(php, /'key'\s*=>\s*'after_sale'/) - assert.match(php, /'title'\s*=>\s*'售后策略'/) - assert.match(php, /'option_key'\s*=>\s*'aksrt_settings'/) - - // 字段键、顺序、类型一致(JS 侧 defaults 与 groups 同序,按首次出现去重) - const phpKeys = [...php.matchAll(/'key'\s*=>\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) - const jsKeys = [...js.matchAll(/key:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) - const sectionKeys = (keys) => [...new Set(keys.filter((key) => FIELD_ORDER.includes(key)))] - assert.deepEqual(sectionKeys(phpKeys), FIELD_ORDER) - assert.deepEqual(sectionKeys(jsKeys), FIELD_ORDER) - - for (const [phpType, jsType] of [ - ['switch', 'switch'], - ['number', 'number'], - ['textarea', 'textarea'], - ['group', 'group'], - ['input', 'input'], - ]) { - assert.ok(php.includes(`'type' => '${phpType}'`), `missing PHP field type ${phpType}`) - assert.ok(js.includes(`type: '${jsType}'`), `missing JS field type ${jsType}`) - } - - // 默认值一致(开关/时效/占位文案/预置原因/所需材料) - assert.match(php, /'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true/) - assert.match(js, /after_sale_s:\s*true/) - assert.match(js, /key: 'after_sale_s', label: '售后功能', type: 'switch', default: true/) - assert.match(php, /'after_sale_time_limit', 'label' => '申请售后时效\(天\)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650/) - assert.match(js, /key: 'after_sale_time_limit', label: '申请售后时效\(天\)', type: 'number', default: 7, min: 0, max: 3650/) - assert.match(php, /'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因'/) - assert.match(js, /key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因'/) - assert.match(php, /'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true/) - assert.match(js, /key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true/) - - for (const reason of ['质量问题', '假冒品牌', '其他原因']) { - assert.ok(php.includes(`'t' => '${reason}'`), `missing PHP preset reason ${reason}`) - assert.ok(js.includes(`{ t: '${reason}' }`), `missing JS preset reason ${reason}`) - } - for (const material of ['联系邮箱', '联系电话']) { - assert.ok(php.includes(`'t' => '${material}'`), `missing PHP material ${material}`) - assert.ok(js.includes(`{ t: '${material}' }`), `missing JS material ${material}`) - } - - // HTML 配置项(超时效提示 / 申请说明)走 kses 通道 - assert.match(php, /'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html'/) - assert.match(php, /'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html'/) - assert.match(js, /key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html'/) - assert.match(js, /key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html'/) -}) - -test('after-sale schema and sidebar page are registered under 支付&付费', () => { - const schemaIndex = read('assets/admin/src/schema/sections/payment/index.js') - assert.match(schemaIndex, /import \{ afterSaleSchema \} from '\.\/afterSale\.js'/) - assert.match(schemaIndex, /after_sale:\s*afterSaleSchema/) - - const section = read('assets/admin/src/constants/sections/payment.js') - assert.match(section, /key: 'payment-after-sale'/) - assert.match(section, /name: 'PaymentAfterSale'/) - assert.match(section, /label: '售后策略'/) - assert.match(section, /route: '\/payment\/after-sale'/) - assert.match(section, /canonicalDomain: 'payment'/) - assert.match(section, /section: 'after_sale'/) - - // 侧栏条目位于提现策略之后(支付&付费末位) - assert.ok(section.indexOf("key: 'payment-after-sale'") > section.indexOf("key: 'payment-withdrawal'")) - - const schemaRegistry = read('assets/admin/src/schema/index.js') - assert.match(schemaRegistry, /paymentSchemas/) -}) - -test('after-sale runtime resolves defaults, overrides and the expiry boundary matrix', () => { - const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-policy-harness.php')], { - cwd: root, - encoding: 'utf8', - }) - assert.equal(result.status, 0, result.stderr || result.stdout) - const payload = JSON.parse(result.stdout) - - assert.deepEqual(payload.defaults, { - enabled: true, - time_limit: 7, - expired_text: '
抱歉,您的订单已超过售后申请时效
', - reasons: [ - { slug: 'c440cdf71825', name: '质量问题' }, - { slug: '2ead35a456a0', name: '假冒品牌' }, - { slug: 'fba26eab6b2b', name: '其他原因' }, - ], - materials: ['联系邮箱', '联系电话'], - desc: '', - img_enabled: true, - reason_placeholder: '必填,请您详细填写申请原因', - }) - - assert.equal(payload.overridden.enabled, false) - assert.equal(payload.overridden.time_limit, 30) - assert.equal(payload.overridden.img_enabled, false) - assert.equal(payload.overridden.reason_placeholder, '请描述问题') - assert.deepEqual(payload.overridden.reasons, [ - { slug: 'c440cdf71825', name: '质量问题' }, - { slug: '327e0ab68567', name: '发错货' }, - ]) - assert.deepEqual(payload.overridden.materials, ['联系邮箱']) - // HTML 配置项剥离 script,保留 div - assert.equal(payload.overridden.expired_text, '
已超过时效
') - assert.equal(payload.overridden.desc, '
请保持商品完好
') - - assert.deepEqual(payload.expired_ok, { - within: true, - exact_boundary: true, - just_expired: true, - long_expired: true, - no_limit: true, - missing_paid: true, - }) -}) - -test('after-sale tickets persist evidence images and expose the latest row', () => { - const schema = read('src/Core/DBMigratorPart2.php') - assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/) - - // images 列随 schema 2.5.1 引入:dbDelta 只在版本变化时重跑,列变更必须同步升版本, - // 否则老安装不会 ALTER TABLE,写入直接报 Unknown column 'images'。 - const migrator = read('src/Core/DBMigrator.php') - const version = migrator.match(/const SCHEMA_VERSION = '(\d+)\.(\d+)\.(\d+)'/) - assert.ok(version, 'DBMigrator exposes a numeric SCHEMA_VERSION') - const [major, minor, patch] = version.slice(1).map(Number) - assert.ok( - major > 2 || (major === 2 && minor > 5) || (major === 2 && minor === 5 && patch >= 1), - `SCHEMA_VERSION must be at least 2.5.1 after adding order_aftersale.images, got ${major}.${minor}.${patch}` - ) - - const service = read('src/Payment/AfterSaleService.php') - assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\]\)/) - assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/) - assert.match(service, /'images' => wp_json_encode\(\$images\)/) - assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/) - assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/) - // normalize 输出 images 数组(坏 JSON / 空串回落空数组) - assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/) - - // 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传 - assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/) - assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/) - assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/) - assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/) - assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/) - assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/) - - // 提交 REST 透传 images - assert.match(service, /is_array\(\$images\) \? \$images : \[\]\)/) - - // 既有售后资产契约(锁 / 退款 / 完成)不受影响 - assert.match(service, /GET_LOCK\(%s, 5\)/) - assert.match(service, /Order::STATUS_PAID/) -}) - -test('order view model exposes the after-sale button matrix', () => { - const userOrders = read('src/Payment/UserOrders.php') - assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/) - - assert.match(userOrders, /'after_sale' => \$after_sale_state,/) - assert.match(userOrders, /'enabled'\s*=> \$enabled,/) - assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/) - assert.match(userOrders, /'has_application' => \$has_application,/) - assert.match(userOrders, /'open'\s*=> \$open,/) - assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/) - assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && !\$open && \$has_application,/) - assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/) - - // 仅已支付订单开放;开关关闭整体不处理售后 - assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/) - // 时效以支付时间为准 - assert.match(userOrders, /\$this->after_sale_state\(\$group, \$order_number, \(string\) \(\$order\['paid_at'\] \?\? ''\), \$after_sale_service, \$after_sales\)/) - // 列表模式单行查询,详情模式复用全量工单 - assert.match(userOrders, /\$after_sales = \$detail && \$after_sale_service \? \$after_sale_service->list_for_order\(\$order_number\) : \[\];/) - assert.match(userOrders, /\$latest = \$service->latest_for_order\(\$order_number\);/) - assert.match(userOrders, /\$view\['after_sales'\] = \$after_sales;/) - - // 旧 can_after_sale 双路已移除 - assert.ok(!userOrders.includes('can_after_sale'), 'can_after_sale must be replaced by the after_sale state') -}) - -test('frontend after-sale modals are standalone and config-driven', () => { - const js = read('assets/js/order-after-sale.js') - - // 三个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区 - assert.match(js, /className: 'aksrt-after-sale-apply-modal'/) - assert.match(js, /className: 'aksrt-after-sale-detail-modal'/) - assert.match(js, /className: 'aksrt-after-sale-expired-modal'/) - assert.match(js, /modal-colorful-header colorful-bg jb-blue/) - assert.match(js, /AksrtModalRuntime\.open\(\{/) - - // 滚动容器与禁封申诉弹窗同口径:no-scrollbar 完全隐藏滚动条(含细滚动条), - // 避免 modal-mini 弹窗边缘出现可见滚动栏。 - assert.match(js, /'
'/) - assert.match(js, /'
'/) - assert.ok(!js.includes('mini-scrollbar'), 'after-sale modals must not render a visible scrollbar') - - // 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置) - assert.match(js, /reasonOptions\(\)/) - assert.match(js, /materialOptions\(\)/) - assert.match(js, /data-aksrt-after-sale-reason=/) - assert.match(js, /data-aksrt-after-sale-material=/) - assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/) - assert.match(js, /name="reason"/) - assert.match(js, /notyf\('请填写申请说明', 'danger'\)/) - assert.match(js, /notyf\('请选择售后原因', 'danger'\)/) - - // 图片上传:开关控制渲染,最多 3 张,上传/删除/提交 - assert.match(js, /if \(cfg\.imgEnabled\) \{/) - assert.match(js, /MAX_IMAGES = 3/) - assert.match(js, /\/after-sale\/upload-image'/) - assert.match(js, /data-aksrt-after-sale-image-remove/) - assert.match(js, /images: collectImages\(shell\)/) - - // 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出 - assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/) - assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/) - - // 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了 - assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/) - assert.match(js, /REAPPLY_BLOCKED_TEXT = '该无法再次申请售后,该订单已售后,暂不支持再次申请售后。'/) - assert.match(js, /escapeHtml\(REAPPLY_BLOCKED_TEXT\)/) - assert.match(js, /data-aksrt-modal-close>我知道了 { - const js = read('assets/js/order-center.js') - - // 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理) - assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed') - assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed') - assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed') - - // 列表卡片与订单详情弹窗底部共用新的售后按钮 - assert.match(js, /function afterSaleButtons\(order\)/) - assert.match(js, /var payload = escapeHtml\(JSON.stringify\(\{/) - assert.match(js, /data-aksrt-after-sale-detail="' \+ payload \+ '"/) - assert.match(js, /data-aksrt-after-sale-apply="' \+ payload \+ '"/) - assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\);/) - assert.ok(js.includes('html += afterSaleButtons(order);'), 'detail modal footer renders the after-sale buttons') - assert.match(js, /data-aksrt-after-sale-detail="/) - assert.match(js, /data-aksrt-after-sale-apply="/) - - // 售后记录区与取消申请保留在订单详情弹窗 - assert.match(js, /售后记录/) - assert.match(js, /data-aksrt-after-sale-cancel/) - - // refresh 供售后弹窗提交后刷新列表与统计 - assert.match(js, /function refresh\(orderNumber\) \{/) - assert.match(js, /window\.AKSRTOrderCenter = \{ init: init, destroy: destroy, refresh: refresh \}/) - assert.match(js, /liveStates\.forEach\(function \(state\) \{/) -}) - -test('after-sale assets, template buttons and runtime data are wired', () => { - const scripts = read('src/Core/ScriptsPart1b.php') - assert.match(scripts, /'aksrt-order-after-sale'/) - assert.match(scripts, /\/assets\/js\/order-after-sale\.js'/) - - const scripts1c = read('src/Core/ScriptsPart1c.php') - assert.match(scripts1c, /'afterSale'\s*=> \(function \(\) \{/) - assert.match(scripts1c, /PaymentRuntime::get_after_sale_settings\(\)/) - assert.match(scripts1c, /'maxImages'\s*=> 3/) - - const template = read('inc/helpers/user-center-renderers/profile-orders.php') - assert.match(template, /\$after_sale = is_array\(\$order\['after_sale'\] \?\? null\) \? \$order\['after_sale'\] : \[\];/) - // 载荷随按钮渲染(order-after_sale.js 同步消费),点击即弹窗、无前置请求 - assert.match(template, /\$after_sale_payload = wp_json_encode\(\[/) - assert.match(template, /'after_sale'\s*=> \$after_sale,/) - assert.match(template, /data-aksrt-after-sale-detail="<\?php echo esc_attr\(\$after_sale_payload\);/) - assert.match(template, /data-aksrt-after-sale-apply="<\?php echo esc_attr\(\$after_sale_payload\);/) - assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/) - assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag') - - const css = read('assets/css/frontend/modal-runtime.css') - assert.match(css, /\.aksrt-after-sale-apply-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) - assert.match(css, /\.aksrt-after-sale-detail-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) - assert.match(css, /\.aksrt-after-sale-expired-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) - assert.match(css, /width: min\(400px, calc\(100vw - 32px\)\)/) - assert.match(css, /\.aksrt-after-sale-apply-modal \.modal-colorful-header/) - assert.match(css, /\.aksrt-after-sale-apply-modal \.colorful-bg > \.colorful-make/) - - const field = read('assets/admin/src/components/form/AksField.vue') - assert.match(field, /import draggable from 'vuedraggable'/) - assert.match(field, /v-model="repeaterDragList"/) - assert.match(field, /handle="\.aksrt-field__repeater-handle"/) - assert.match(field, /function repeaterDragKey\(item\)/) -}) +'use strict' + +/** + * 「售后策略」功能契约: + * 1) 后台 schema(PHP SchemaPayment after_sale 分区)与 SPA JS schema 的键、顺序、 + * 类型、默认值 1:1 对齐,且 schema 与侧边页均已注册(/payment/after-sale 可达); + * 2) 运行时(PaymentRuntime)缺键回退 schema 默认、HTML 走 kses、时效边界正确; + * 3) 售后工单:images 列、create 图片清洗与上限、上传路由权限、latest_for_order; + * 4) 订单视图模型输出 after_sale 状态(按钮矩阵的唯一数据源); + * 5) 前台:申请/超时效/售后详细三个独立弹窗、modal-mini + 蓝色炫彩头、placeholder 与 + * 图片上传配置化、不可再申请提示原文、订单详情弹窗不再内嵌售后表单(死代码清理)。 + */ + +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 = (file) => fs.readFileSync(path.join(root, file), 'utf8') + +const FIELD_ORDER = [ + 'after_sale_s', + 'after_sale_time_limit', + 'after_sale_expired_text', + 'after_sale_reasons', + 'after_sale_desc', + 'after_sale_materials', + 'after_sale_reason_placeholder', + 'after_sale_img_s', +] + +/** Extract the balanced [...] block assigned to a PHP array key inside a section body. */ +function phpSection(src, sectionKey) { + const start = src.indexOf(`'${sectionKey}' => [`) + assert.notEqual(start, -1, `PHP schema has a ${sectionKey} section`) + const open = src.indexOf('[', start) + let depth = 0 + for (let i = open; i < src.length; i++) { + const ch = src[i] + if (ch === "'" || ch === '"') { + const quote = ch + i++ + while (i < src.length) { + if (src[i] === '\\') { i += 2; continue } + if (src[i] === quote) break + i++ + } + continue + } + if (ch === '[') depth++ + else if (ch === ']') { + depth-- + if (depth === 0) return src.slice(open, i + 1) + } + } + throw new Error(`unbalanced PHP section: ${sectionKey}`) +} + +test('after-sale schema is 1:1 between PHP and SPA', () => { + const php = phpSection(read('src/Admin/Schema/SchemaPayment.php'), 'after_sale') + const js = read('assets/admin/src/schema/sections/payment/afterSale.js') + + assert.match(php, /'key'\s*=>\s*'after_sale'/) + assert.match(php, /'title'\s*=>\s*'售后策略'/) + assert.match(php, /'option_key'\s*=>\s*'aksrt_settings'/) + + // 字段键、顺序、类型一致(JS 侧 defaults 与 groups 同序,按首次出现去重) + const phpKeys = [...php.matchAll(/'key'\s*=>\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) + const jsKeys = [...js.matchAll(/key:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) + const sectionKeys = (keys) => [...new Set(keys.filter((key) => FIELD_ORDER.includes(key)))] + assert.deepEqual(sectionKeys(phpKeys), FIELD_ORDER) + assert.deepEqual(sectionKeys(jsKeys), FIELD_ORDER) + + for (const [phpType, jsType] of [ + ['switch', 'switch'], + ['number', 'number'], + ['textarea', 'textarea'], + ['group', 'group'], + ['input', 'input'], + ]) { + assert.ok(php.includes(`'type' => '${phpType}'`), `missing PHP field type ${phpType}`) + assert.ok(js.includes(`type: '${jsType}'`), `missing JS field type ${jsType}`) + } + + // 默认值一致(开关/时效/占位文案/预置原因/所需材料) + assert.match(php, /'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true/) + assert.match(js, /after_sale_s:\s*true/) + assert.match(js, /key: 'after_sale_s', label: '售后功能', type: 'switch', default: true/) + assert.match(php, /'after_sale_time_limit', 'label' => '申请售后时效\(天\)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650/) + assert.match(js, /key: 'after_sale_time_limit', label: '申请售后时效\(天\)', type: 'number', default: 7, min: 0, max: 3650/) + assert.match(php, /'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因'/) + assert.match(js, /key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因'/) + assert.match(php, /'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true/) + assert.match(js, /key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true/) + + for (const reason of ['质量问题', '假冒品牌', '其他原因']) { + assert.ok(php.includes(`'t' => '${reason}'`), `missing PHP preset reason ${reason}`) + assert.ok(js.includes(`{ t: '${reason}' }`), `missing JS preset reason ${reason}`) + } + for (const material of ['联系邮箱', '联系电话']) { + assert.ok(php.includes(`'t' => '${material}'`), `missing PHP material ${material}`) + assert.ok(js.includes(`{ t: '${material}' }`), `missing JS material ${material}`) + } + + // HTML 配置项(超时效提示 / 申请说明)走 kses 通道 + assert.match(php, /'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html'/) + assert.match(php, /'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html'/) + assert.match(js, /key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html'/) + assert.match(js, /key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html'/) +}) + +test('after-sale schema and sidebar page are registered under 支付&付费', () => { + const schemaIndex = read('assets/admin/src/schema/sections/payment/index.js') + assert.match(schemaIndex, /import \{ afterSaleSchema \} from '\.\/afterSale\.js'/) + assert.match(schemaIndex, /after_sale:\s*afterSaleSchema/) + + const section = read('assets/admin/src/constants/sections/payment.js') + assert.match(section, /key: 'payment-after-sale'/) + assert.match(section, /name: 'PaymentAfterSale'/) + assert.match(section, /label: '售后策略'/) + assert.match(section, /route: '\/payment\/after-sale'/) + assert.match(section, /canonicalDomain: 'payment'/) + assert.match(section, /section: 'after_sale'/) + + // 侧栏条目位于提现策略之后(支付&付费末位) + assert.ok(section.indexOf("key: 'payment-after-sale'") > section.indexOf("key: 'payment-withdrawal'")) + + const schemaRegistry = read('assets/admin/src/schema/index.js') + assert.match(schemaRegistry, /paymentSchemas/) +}) + +test('after-sale runtime resolves defaults, overrides and the expiry boundary matrix', () => { + const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-policy-harness.php')], { + cwd: root, + encoding: 'utf8', + }) + assert.equal(result.status, 0, result.stderr || result.stdout) + const payload = JSON.parse(result.stdout) + + assert.deepEqual(payload.defaults, { + enabled: true, + time_limit: 7, + expired_text: '
抱歉,您的订单已超过售后申请时效
', + reasons: [ + { slug: 'c440cdf71825', name: '质量问题' }, + { slug: '2ead35a456a0', name: '假冒品牌' }, + { slug: 'fba26eab6b2b', name: '其他原因' }, + ], + materials: ['联系邮箱', '联系电话'], + desc: '', + img_enabled: true, + reason_placeholder: '必填,请您详细填写申请原因', + }) + + assert.equal(payload.overridden.enabled, false) + assert.equal(payload.overridden.time_limit, 30) + assert.equal(payload.overridden.img_enabled, false) + assert.equal(payload.overridden.reason_placeholder, '请描述问题') + assert.deepEqual(payload.overridden.reasons, [ + { slug: 'c440cdf71825', name: '质量问题' }, + { slug: '327e0ab68567', name: '发错货' }, + ]) + assert.deepEqual(payload.overridden.materials, ['联系邮箱']) + // HTML 配置项剥离 script,保留 div + assert.equal(payload.overridden.expired_text, '
已超过时效
') + assert.equal(payload.overridden.desc, '
请保持商品完好
') + + assert.deepEqual(payload.expired_ok, { + within: true, + exact_boundary: true, + just_expired: true, + long_expired: true, + no_limit: true, + missing_paid: true, + }) +}) + +test('after-sale tickets persist evidence images and expose the latest row', () => { + const schema = read('src/Core/DBMigratorPart2.php') + assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/) + + // images 列随 schema 2.5.1 引入:dbDelta 只在版本变化时重跑,列变更必须同步升版本, + // 否则老安装不会 ALTER TABLE,写入直接报 Unknown column 'images'。 + const migrator = read('src/Core/DBMigrator.php') + const version = migrator.match(/const SCHEMA_VERSION = '(\d+)\.(\d+)\.(\d+)'/) + assert.ok(version, 'DBMigrator exposes a numeric SCHEMA_VERSION') + const [major, minor, patch] = version.slice(1).map(Number) + assert.ok( + major > 2 || (major === 2 && minor > 5) || (major === 2 && minor === 5 && patch >= 1), + `SCHEMA_VERSION must be at least 2.5.1 after adding order_aftersale.images, got ${major}.${minor}.${patch}` + ) + + const service = read('src/Payment/AfterSaleService.php') + assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\], array \$apply_form = \[\]\)/) + // 结构化申请表单:预置原因 / 申请资料 / 申请说明独立入库(reason 融合串保持原样) + assert.match(service, /\$apply_form = \$this->sanitize_apply_form\(\$apply_form\);/) + assert.match(service, /private function sanitize_apply_form\(array \$form\): array/) + assert.match(service, /'apply_form' => \$apply_form === \[\] \? null : wp_json_encode\(\$apply_form/) + assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/) + assert.match(service, /'images' => wp_json_encode\(\$images\)/) + assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/) + assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/) + // normalize 输出 images 数组(坏 JSON / 空串回落空数组) + assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/) + + // 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传 + assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/) + assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/) + assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/) + assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/) + assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/) + assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/) + + // 提交 REST 透传 images + // rest_submit 同步上报结构化申请表单(reason_label / materials / description) + assert.match(service, /'reason_label' => \(string\) \$request->get_param\('reason_label'\),/) + assert.match(service, /'materials' +\=> is_array\(\$materials\) \? \$materials : \[\],/) + assert.match(service, /'description' +\=> \(string\) \$request->get_param\('description'\),/) + + // 既有售后资产契约(锁 / 退款 / 完成)不受影响 + assert.match(service, /GET_LOCK\(%s, 5\)/) + assert.match(service, /Order::STATUS_PAID/) +}) + +test('order view model exposes the after-sale button matrix', () => { + const userOrders = read('src/Payment/UserOrders.php') + assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/) + + assert.match(userOrders, /'after_sale' => \$after_sale_state,/) + assert.match(userOrders, /'enabled'\s*=> \$enabled,/) + assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/) + assert.match(userOrders, /'has_application' => \$has_application,/) + assert.match(userOrders, /'open'\s*=> \$open,/) + assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/) + // 可再次申请售后按工单状态推理:rejected(被拒绝)/ cancelled(已取消)允许再次申请; + // pending / processing / approved(流转中)与 completed(已完结)不允许 + assert.match(userOrders, /\$reapplyable_statuses = \[AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED\];/) + assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && \$has_application && in_array\(\$latest_status, \$reapplyable_statuses, true\),/) + assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/) + assert.match(userOrders, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/) + + // 仅已支付订单开放;开关关闭整体不处理售后 + assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/) + // 时效以支付时间为准 + assert.match(userOrders, /\$this->after_sale_state\(\$group, \$order_number, \(string\) \(\$order\['paid_at'\] \?\? ''\), \$after_sale_service, \$after_sales\)/) + // 列表模式单行查询,详情模式复用全量工单 + assert.match(userOrders, /\$after_sales = \$detail && \$after_sale_service \? \$after_sale_service->list_for_order\(\$order_number\) : \[\];/) + assert.match(userOrders, /\$latest = \$service->latest_for_order\(\$order_number\);/) + assert.match(userOrders, /\$view\['after_sales'\] = \$after_sales;/) + + // 旧 can_after_sale 双路已移除 + assert.ok(!userOrders.includes('can_after_sale'), 'can_after_sale must be replaced by the after_sale state') +}) + +test('frontend after-sale modals are standalone and config-driven', () => { + const js = read('assets/js/order-after-sale.js') + + // 三个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区 + assert.match(js, /className: 'aksrt-after-sale-apply-modal'/) + assert.match(js, /className: 'aksrt-after-sale-detail-modal'/) + assert.match(js, /className: 'aksrt-after-sale-expired-modal'/) + assert.match(js, /modal-colorful-header colorful-bg jb-blue/) + assert.match(js, /AksrtModalRuntime\.open\(\{/) + + // 滚动容器与禁封申诉弹窗同口径:no-scrollbar 完全隐藏滚动条(含细滚动条), + // 避免 modal-mini 弹窗边缘出现可见滚动栏。 + assert.match(js, /'
'/) + assert.match(js, /'
'/) + assert.ok(!js.includes('mini-scrollbar'), 'after-sale modals must not render a visible scrollbar') + + // 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置) + assert.match(js, /reasonOptions\(\)/) + assert.match(js, /materialOptions\(\)/) + assert.match(js, /data-aksrt-after-sale-reason=/) + assert.match(js, /data-aksrt-after-sale-material=/) + assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/) + assert.match(js, /name="reason"/) + assert.match(js, /notyf\('请填写申请说明', 'danger'\)/) + assert.match(js, /notyf\('请选择售后原因', 'danger'\)/) + + // 图片上传:开关控制渲染,最多 3 张,上传/删除/提交 + assert.match(js, /if \(cfg\.imgEnabled\) \{/) + assert.match(js, /MAX_IMAGES = 3/) + assert.match(js, /\/after-sale\/upload-image'/) + assert.match(js, /data-aksrt-after-sale-image-remove/) + assert.match(js, /images: collectImages\(shell\)/) + assert.match(js, /reason_label: selectedReasonLabel\(shell\)/) + assert.match(js, /materials: collectMaterials\(shell\)/) + assert.match(js, /description: reasonText,/) + + // 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出 + assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/) + assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/) + + // 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了 + // 申请表单分行展示:原因 / 申请资料 / 申请说明各自独立成行(与申请时间同格式); + // 老数据无 apply_form 时兜底展示融合串 + assert.match(js, /detailRow\('原因', String\(applyForm\.reason_label \|\| ''\)\)/) + assert.match(js, /detailRow\('申请说明', String\(applyForm\.description \|\| ''\)\)/) + assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/) + assert.match(js, /REAPPLY_BLOCKED_TEXT = '该订单当前状态暂不支持再次申请售后。'/) + assert.match(js, /escapeHtml\(REAPPLY_BLOCKED_TEXT\)/) + assert.match(js, /data-aksrt-modal-close>我知道了 { + const js = read('assets/js/order-center.js') + + // 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理) + assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed') + assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed') + assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed') + + // 列表卡片与订单详情弹窗底部共用新的售后按钮 + assert.match(js, /function afterSaleButtons\(order\)/) + assert.match(js, /var payload = escapeHtml\(JSON.stringify\(\{/) + assert.match(js, /data-aksrt-after-sale-detail="' \+ payload \+ '"/) + assert.match(js, /data-aksrt-after-sale-apply="' \+ payload \+ '"/) + assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\);/) + assert.ok(js.includes('html += afterSaleButtons(order);'), 'detail modal footer renders the after-sale buttons') + assert.match(js, /data-aksrt-after-sale-detail="/) + assert.match(js, /data-aksrt-after-sale-apply="/) + + // 售后记录区与取消申请保留在订单详情弹窗 + assert.match(js, /售后记录/) + assert.match(js, /data-aksrt-after-sale-cancel/) + + // refresh 供售后弹窗提交后刷新列表与统计 + assert.match(js, /function refresh\(orderNumber\) \{/) + assert.match(js, /window\.AKSRTOrderCenter = \{ init: init, destroy: destroy, refresh: refresh \}/) + assert.match(js, /liveStates\.forEach\(function \(state\) \{/) +}) + +test('after-sale assets, template buttons and runtime data are wired', () => { + const scripts = read('src/Core/ScriptsPart1b.php') + assert.match(scripts, /'aksrt-order-after-sale'/) + assert.match(scripts, /\/assets\/js\/order-after-sale\.js'/) + + const scripts1c = read('src/Core/ScriptsPart1c.php') + assert.match(scripts1c, /'afterSale'\s*=> \(function \(\) \{/) + assert.match(scripts1c, /PaymentRuntime::get_after_sale_settings\(\)/) + assert.match(scripts1c, /'maxImages'\s*=> 3/) + + const template = read('inc/helpers/user-center-renderers/profile-orders.php') + assert.match(template, /\$after_sale = is_array\(\$order\['after_sale'\] \?\? null\) \? \$order\['after_sale'\] : \[\];/) + // 载荷随按钮渲染(order-after_sale.js 同步消费),点击即弹窗、无前置请求 + assert.match(template, /\$after_sale_payload = wp_json_encode\(\[/) + assert.match(template, /'after_sale'\s*=> \$after_sale,/) + assert.match(template, /data-aksrt-after-sale-detail="<\?php echo esc_attr\(\$after_sale_payload\);/) + assert.match(template, /data-aksrt-after-sale-apply="<\?php echo esc_attr\(\$after_sale_payload\);/) + assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/) + assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag') + + const css = read('assets/css/frontend/modal-runtime.css') + assert.match(css, /\.aksrt-after-sale-apply-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) + assert.match(css, /\.aksrt-after-sale-detail-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) + assert.match(css, /\.aksrt-after-sale-expired-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) + assert.match(css, /width: min\(400px, calc\(100vw - 32px\)\)/) + assert.match(css, /\.aksrt-after-sale-apply-modal \.modal-colorful-header/) + assert.match(css, /\.aksrt-after-sale-apply-modal \.colorful-bg > \.colorful-make/) + + const field = read('assets/admin/src/components/form/AksField.vue') + assert.match(field, /import draggable from 'vuedraggable'/) + assert.match(field, /v-model="repeaterDragList"/) + assert.match(field, /handle="\.aksrt-field__repeater-handle"/) + assert.match(field, /function repeaterDragKey\(item\)/) +})