Files
danyang-match/ui-tests/miniapp-profile-contracts.mjs
T
WorkBuddy 44f75acebe fix(uat): 闭环 UAT 4 项硬阻塞(推荐恒空/保存即失败/越权端点/文档不一致)+ 小程序字段契约
UAT 走查(docs/UAT-WALKTHROUGH-2026-09-23.md)发现 4 项硬阻塞,本次闭环其中 3 项
(#2 微信登录、#5 照片直传不在本轮范围),并顺带修掉走查中新发现的跨端字段契约缺陷。

后端
- #6 推荐恒空:新增 MatchScoringService 作为唯一评分事实源(复用 UserPreference 既有
  10 维权重,零新造常量);RecommendationEngineService.calculateScore/generateReasons 由
  桩(return 50.0)改为委托;MatchEligibilityService 3 处桩全部去雷。此前 50 < 阈值 60
  → GET /recommendations 恒返回空数组。
- #4 保存即失败:updateProfile / updateBasicProfile / savePreference 改为
  「无则插入、有则更新」。注意 UserPreference 实体 @TableId=id 与库主键 user_id 不一致
  (V037 后补的可空列),故按 user_id 定位更新而非 updateById(后者会静默不更新)。
  gender / birth_date 为 NOT NULL 且向导不采集,缺失时从已通过实名认证的 user_identities
  补齐,仍缺则抛 400 业务拒绝,不再让 DataIntegrityViolationException 渲染成 500。
  GET /me/profile 的 preferences 补回 5 个此前未返回字段(regionPriority / maritalPriority /
  minEducationCode / educationPriority / childrenPriority)。
- #8 越权端点:POST /activities/{id}/interests/confirm 不再直调「建 Match 原语」
  RelationshipService.confirmActivityInterest,改为与 /activities/{id}/mutual/* 同一条
  二阶段同意链路(createMutualConsent + confirmConnection),单方调用不再可能建 Match
  (INV-014 / INV-015)。
- #10 文档不一致:MatchmakerController 类注释更正(operator 端点实际在 /operator/matchmaker)。
- 存量 500 家族:RuntimeExceptionThrowGuardTest 的正则只认 throw new RuntimeException(,
  而 17 处裸抛写作 orElseThrow(() -> new RuntimeException(...)),整体绕过守卫(CI 全绿却
  仍有 500 家族缺陷)→ 17 处全量改为 GlobalExceptionHandler.BusinessException;
  守卫扩为「整段代码 + 该形态」,并加自检断言防止守卫退化成恒真空断言。

小程序(跨端字段名 / 取值契约)
- 字段名对齐后端规范名:hasChildren→childrenStatus、occupationCategory→occupationCode、
  workRegion→workRegionCode、ageMin/ageImportance→minAge/agePriority、
  educationAccepted/maritalStatusAccepted→minEducationCode/maritalStatuses。
  此前字段名对不上,后端读不到 → 字段被静默丢弃。
- 取值修 4 类违反 DB CHECK(写入直接 500)的项:has→yes;high_school→senior_high、
  phd→doctor(chk_profile_edu);occasional→sometimes(chk_profile_smoking/drinking)。
- 预填改读服务端真实字段:此前读 profile.hasChildren / occupationCategory / workRegion,
  而服务端返回的是 childrenStatus / occupationCode / workRegionCode → 表单永远回填不上。
- profile-confirm 完成度改为直接采用服务端 profileCompletion(AGENTS 第 9 条:DB 是
  Source of Truth),废弃原先 22 条「读服务端不存在字段」的本地猜测式断言。
- 择偶条件页新增「展示形态 ↔ 服务端规范形态」适配器(toApiPayload / fromApiPayload)。

测试与门禁
- 新增 MatchScoringServiceTest、RecommendationEngineServiceBehaviorTest、
  ProfileSaveUpsertBehaviorTest、ActivityInterestConfirmConsentBehaviorTest 与
  ui-tests/miniapp-profile-contracts.mjs(桩 stores,真跑三个页面的事件处理与 onNext,
  断言提交负载用规范字段名与枚举码)。
- MIN_GUARDS 21→23(并修正上一轮漏改:该值当时仍为 21,与守卫数不符,使「数量下限」
  保护形同虚设)。
- 负向注入实测:把 #6 评分改回桩 50.0、#4 改回恒 insert、#8 改回绕开二阶段、并注入
  orElseThrow 形态裸异常 → 4 个守卫全部变红(含精确定位到 RecommendationEngineService.java:282)。
- 后端:795 tests / 0 failures / 3 skipped;UI 守卫 23/23;语种审计 0 命中;tsc 通过;
  wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。

仍未修(需产品/数据决策,见报告 8.3)
- regionScope 需 GB/T 2260 单值区划码,而小程序选项含「镇江其他辖区」这类非单值项 →
  不臆造区划码(该约束默认 preferred 软约束,不误过滤);
- occupationPreference 后端无对应列;settlementPreference 后端无取值字典。
2026-09-23 14:33:43 +08:00

246 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')