Files
danyang-match/ui-tests/miniapp-profile-contracts.mjs
T

246 lines
10 KiB
JavaScript
Raw Normal View History

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')