Files
group_fqcd_jr/tests/integration/test_profile_snapshot_completeness_mysql.py
T
张胜宇 9675df8453 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)
- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 21:26:30 +08:00

77 lines
3.7 KiB
Python
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.
"""画像快照**字段完整性**的真机回归(守 `ProfileAssemblyService` 不得写残片快照)。
## 守的是什么
`profile_snapshots` 的当前版本有多个写入方,其中两个会把它置为 `is_current=1`:
1. `ProfileGenerationService` —— 走 `build_snapshot()`,覆盖读取侧白名单全部字段;
2. `ProfileAssemblyService.rebuild_profile()` —— 记忆 → 画像重建。
第 2 个此前**就地拼一个只含 `PROFILE_OWNED_FIELDS + generated_at` 的四字段残片**。
后果(2026-09-19 连库实测,客户 9001 的当前快照停在 `{investor_type, risk_tags,
generated_at, ...}`):
- 种子里明明写了 `customer_tier: gold`,被下一次重建覆盖后**分层整个消失**
⇒ 客服「我够哪一档?」只能拿风险等级顶包;
- `assessment_valid_until` / `assessment_expired` 一起消失 ⇒ 画像读取侧再也判断不出
「测评是否过期」,与 `SuitabilityService` 的 `ASSESSMENT_EXPIRED` 失败关闭口径**分叉**;
- `total_asset` / `behavior_score` 消失,画像与「账户看板」无法对照。
所以用例按真实路径重建一次,断言当前快照覆盖 `REQUIRED_SNAPSHOT_FIELDS` 全量。
`9001` 有有效期内的测评行,因此「测评有效期 / 是否过期」两个字段也应齐全。
"""
import asyncio
import pytest
from sqlalchemy import select
from app.core.profile_projection import project_profile
from app.infrastructure.db import SessionFactory
from app.model.profile import ProfileSnapshot
from app.service.profile_assembly_service import ProfileAssemblyService
from app.service.profile_generation_service import REQUIRED_SNAPSHOT_FIELDS
CUSTOMER_ID = 9001
async def _rebuild_and_read_current() -> dict[str, object]:
async with SessionFactory() as session, session.begin():
await ProfileAssemblyService(session).rebuild(CUSTOMER_ID)
async with SessionFactory() as session:
row = await session.scalar(
select(ProfileSnapshot).where(
ProfileSnapshot.customer_id == CUSTOMER_ID,
ProfileSnapshot.is_current.is_(True),
)
)
assert row is not None, "重建后必须存在一条当前版本快照"
snapshot = row.snapshot
if isinstance(snapshot, (bytes, bytearray)):
snapshot = snapshot.decode("utf-8")
return snapshot if isinstance(snapshot, dict) else {}
@pytest.mark.integration
def test_rebuild_writes_a_complete_snapshot_not_a_partial_fragment() -> None:
"""重建画像后,当前快照必须覆盖读取侧白名单的全部字段(不得是残片)。"""
snapshot = asyncio.run(_rebuild_and_read_current())
missing = [field for field in REQUIRED_SNAPSHOT_FIELDS if field not in snapshot]
assert not missing, (
f"重建写出的快照缺少读取侧字段:{missing}(实际快照={snapshot})"
)
projected = project_profile(snapshot)
assert projected.get("investor_type"), "重建后画像必须仍可投影出风险等级"
assert "total_asset" in projected, "交易侧客观字段不能被记忆重建抹掉"
assert "assessment_expired" in projected, "测评有效期判据必须保留在画像里"
# JSON 列字段不得被二次编码:正确形状是 `["money_fund"]`,不是 `['"[\\"money_fund\\"]"']`。
# 后者会被读取侧 `_localized` 当成未知值静默丢弃,等价于「偏好资产类别」整条消失。
for field in ("preferred_asset_class", "risk_tags"):
value = snapshot.get(field)
assert isinstance(value, list), f"{field} 必须是数组,实得 {value!r}"
nested = [item for item in value if isinstance(item, str) and item.strip().startswith("[")]
assert not nested, f"{field} 出现二次编码的元素:{nested}"