import assert from 'node:assert/strict' import path from 'node:path' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' /** * 小程序「资料 / 择偶条件」提交契约守卫 * * 覆盖 2026-09-23 UAT 走查发现的一类缺陷:**跨端字段名与取值不一致**。 * 页面把「展示形态」直接提交给后端,而后端读的是规范字段名与枚举码: * * hasChildren(bool) ↔ children_status(none|yes) → 字段被静默丢弃 * occupationCategory ↔ occupation_code → 字段被静默丢弃 * workRegion ↔ work_region_code → 字段被静默丢弃 * ageMin/ageImportance ↔ minAge/agePriority → 字段被静默丢弃 * educationAccepted=['本科以上'] ↔ minEducationCode=bachelor → 字段被静默丢弃 * maritalStatusAccepted=['未婚'] ↔ maritalStatuses=['never_married'] * * 另有**取值违反数据库 CHECK 约束**(写入直接 500): * education_code=high_school → 仅允许 junior_high/senior_high/… * education_code=phd → 仅允许 doctor * smoking/drinking=occasional → 仅允许 never/sometimes/often * * 这些都在 `.js` 层,tsc 管不到(页面是 .js / 类型声明为 any), * 静态扫描与真机编译也都发现不了 —— 只有行为断言(真实调用页面处理函数、 * 断言提交负载)能兜住。这正是本项目反复记录的「全绿 ≠ 有效」。 * * 断言方式:mock `../../stores`,真实 require 页面模块,调用其事件处理函数与 * onNext(),然后断言被捕获的提交负载。 */ const here = path.dirname(fileURLToPath(import.meta.url)) const REPO = path.resolve(here, '..') const require = createRequire(import.meta.url) // ---------------- wx 桩 ---------------- const storage = {} globalThis.wx = { getStorageSync: (k) => (k in storage ? storage[k] : ''), setStorageSync: (k, v) => { storage[k] = v }, removeStorageSync: (k) => { delete storage[k] }, getSystemInfoSync: () => ({ statusBarHeight: 20, windowWidth: 375, windowHeight: 667 }), getMenuButtonBoundingClientRect: () => ({ top: 24, bottom: 56, left: 278, right: 365, width: 87, height: 32 }), navigateTo: () => {}, navigateBack: () => {}, showToast: () => {}, request: () => {}, } // ---------------- 捕获页面定义 ---------------- let page = null globalThis.Page = (opts) => { page = opts } // ---------------- stores 桩(捕获提交负载) ---------------- const calls = { profile: [], preferences: [] } const fakeStores = { userStore: { updateProfile: async (payload) => { calls.profile.push(payload) return {} }, updatePreferences: async (payload) => { calls.preferences.push(payload) return {} }, fetchProfile: async () => ({}), fetchPreferences: async () => ({}), fetchReviewStatus: async () => ({}), }, appStore: { toast: () => {}, toastError: () => {}, confirm: async () => true, }, } const storesEntry = require.resolve(path.join(REPO, 'miniapp', 'stores', 'index.js')) require.cache[storesEntry] = { id: storesEntry, filename: storesEntry, loaded: true, exports: fakeStores, } /** 载入页面并造一个可调用的实例(setData 合并进 data) */ function loadPage(rel) { page = null require(path.join(REPO, rel)) assert.ok(page, rel + ' 必须调用 Page()') const inst = Object.create(page) inst.data = JSON.parse(JSON.stringify(page.data)) inst.setData = function (patch) { Object.assign(this.data, patch) } return inst } // ===================================================================== // 1. profile-basic:子女 / 学历 / 居住区域 // ===================================================================== const basic = loadPage('miniapp/pages/profile-basic/profile-basic.js') // 子女:选项码必须是 DB CHECK 允许的 none/yes(曾用 'has') const childCount = basic.data.childrenOptions.length basic.onChildrenChange({ detail: { value: childCount - 1 } }) assert.equal( basic.data.childrenStatus, 'yes', "子女选项「有子女」必须映射为 children_status='yes'('has' 违反 chk_profile_children)", ) basic.onChildrenChange({ detail: { value: 0 } }) assert.equal(basic.data.childrenStatus, 'none', "子女选项「无子女」必须映射为 children_status='none'") // 学历:取值必须在 chk_profile_edu 白名单内 const eduValues = basic.data.educationOptions.map((o) => o.value) const allowedEdu = ['primary', 'junior_high', 'senior_high', 'vocational', 'college', 'bachelor', 'master', 'doctor'] for (const v of eduValues) { assert.ok(allowedEdu.includes(v), "学历选项码 '" + v + "' 不在 chk_profile_edu 白名单内(写入会 500)") } assert.ok(!eduValues.includes('high_school'), "学历不得再出现 high_school(白名单是 senior_high/junior_high)") assert.ok(!eduValues.includes('phd'), '学历不得再出现 phd(白名单是 doctor)') basic.onEducationChange({ detail: { value: eduValues.indexOf('bachelor') } }) assert.equal(basic.data.educationCode, 'bachelor') // 居住区域:区划码来自字典 basic.onRegionChange({ detail: { value: 0 } }) assert.equal(basic.data.regionName, '丹阳') assert.equal(basic.data.regionCode, '321181', '丹阳的区划码必须是 321181') // 提交负载:字段名必须是后端规范名 Object.assign(basic.data, { regionName: '丹阳', regionCode: '321181', heightCm: '162', maritalStatus: 'never_married', childrenStatus: 'none', educationCode: 'bachelor', occupationCode: 'government', }) await basic.onNext() const profileBody = calls.profile.at(-1) assert.ok(profileBody, 'profile-basic.onNext 必须提交资料') assert.deepEqual( Object.keys(profileBody).sort(), ['childrenStatus', 'educationCode', 'heightCm', 'maritalStatus', 'occupationCode', 'regionCode', 'regionName'].sort(), 'PUT /me/profile 必须用后端规范字段名(hasChildren/occupationCategory 会被静默丢弃)', ) assert.equal(profileBody.childrenStatus, 'none') assert.equal(profileBody.heightCm, 162, 'heightCm 必须是数字而不是字符串') assert.equal(profileBody.regionCode, '321181') // ===================================================================== // 2. profile-lifestyle:职业 / 工作地 / 吸烟饮酒 // ===================================================================== const lifestyle = loadPage('miniapp/pages/profile-lifestyle/profile-lifestyle.js') lifestyle.onOccupationCodeChange({ detail: { value: 0 } }) assert.equal(lifestyle.data.occupationCode, 'government', '职业类别必须写 occupation_code 的码') lifestyle.onSmokingChange({ detail: { value: 1 } }) assert.equal( lifestyle.data.smokingStatus, 'sometimes', "「偶尔」必须是 sometimes(occasional 违反 chk_profile_smoking)", ) lifestyle.onDrinkingChange({ detail: { value: 1 } }) assert.equal(lifestyle.data.drinkingStatus, 'sometimes', "「偶尔」必须是 sometimes(occasional 违反 chk_profile_drinking)") Object.assign(lifestyle.data, { occupationCode: 'government', workRegionCode: '丹阳', smokingStatus: 'never', drinkingStatus: 'never', longTermDanyang: 'yes', futureDanyang: 'willing', }) await lifestyle.onNext() const lifestyleBody = calls.profile.at(-1) assert.deepEqual( Object.keys(lifestyleBody).sort(), ['drinkingStatus', 'futureDanyang', 'longTermDanyang', 'occupationCode', 'smokingStatus', 'workRegionCode'].sort(), 'PUT /me/profile(生活页)必须用 occupationCode / workRegionCode 等规范字段名', ) assert.equal(lifestyleBody.occupationCode, 'government') // ===================================================================== // 3. profile-preference:择偶条件(展示形态 → 规范形态) // ===================================================================== const pref = loadPage('miniapp/pages/profile-preference/profile-preference.js') Object.assign(pref.data.preference, { ageMin: 28, ageMax: 38, ageImportance: 'required', heightMin: 170, heightMax: 185, heightImportance: 'preferred', maritalStatusAccepted: ['未婚', '离异'], maritalImportance: 'preferred', educationAccepted: ['本科以上'], educationImportance: 'preferred', acceptChildren: 'any', smokingPreference: '不限', drinkingPreference: '不限', marriagePlanPreference: '1年内', }) await pref.onNext() const prefBody = calls.preferences.at(-1) assert.ok(prefBody, 'profile-preference.onNext 必须提交择偶条件') assert.equal(prefBody.minAge, 28, 'ageMin 必须映射为 minAge') assert.equal(prefBody.agePriority, 'required', "ageImportance 必须映射为 agePriority(chk_pref_priority 只允许 required/preferred)") assert.equal(prefBody.minHeightCm, 170, 'heightMin 必须映射为 minHeightCm') assert.equal(prefBody.heightPriority, 'preferred') assert.deepEqual( prefBody.maritalStatuses, ['never_married', 'divorced'], '婚姻多选必须映射为枚举码(直接发中文会被静默丢弃)', ) assert.equal(prefBody.minEducationCode, 'bachelor', '「本科以上」必须映射为 minEducationCode=bachelor') assert.equal(prefBody.childrenAcceptance, 'any', 'acceptChildren 必须映射为 childrenAcceptance') assert.equal(prefBody.marriagePlanPreference, 'one_year', '「1年内」必须映射为 one_year') assert.equal(prefBody.smokingPreference, null, '「不限」必须映射为 null,而不是把中文「不限」发出去') assert.equal(prefBody.drinkingPreference, null) assert.equal(prefBody.educationPriority, 'preferred') // 反向:不得把展示形态的字段名一起发给后端 for (const legacy of ['ageMin', 'ageMax', 'ageImportance', 'heightMin', 'heightMax', 'educationAccepted', 'maritalStatusAccepted']) { assert.ok(!(legacy in prefBody), '不得把展示形态字段 ' + legacy + ' 发给后端(后端读不到 → 静默丢弃)') } // 反向:婚姻/学历映射方向不得反 Object.assign(pref.data.preference, { maritalStatusAccepted: ['丧偶'], educationAccepted: ['大专以上'], }) await pref.onNext() assert.deepEqual(calls.preferences.at(-1).maritalStatuses, ['widowed'], '「丧偶」必须映射为 widowed') assert.equal(calls.preferences.at(-1).minEducationCode, 'college', '「大专以上」必须映射为 college') console.log('Miniapp profile contracts passed')