246 lines
10 KiB
JavaScript
246 lines
10 KiB
JavaScript
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')
|